news 2026/10/21 15:42:51

Java PGP加密验签实战:完整Demo与Bouncy Castle集成解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java PGP加密验签实战:完整Demo与Bouncy Castle集成解析

简介:这是一份Java PGP加密验签完整Demo资源包,面向需要在实际项目中集成PGP加密、验签以及HTTPS/SSL安全传输的Java开发者,可解决加密数据在网络上安全送达的问题。资源内含两个相互关联的子项目:一个演示基于Bouncy Castle的PGP密钥生成、加密、解密与验签全流程;另一个演示PGP加密后的数据通过HTTP Client以SSL/HTTPS方式发送,并提供了将HTTPClient改成OkHttp、携带CA证书发送的扩展思路。压缩包共42个文件,大小约11.75MB,包含13个jar依赖库、8个Java源码、9个编译后的class文件、2个密钥文件及asc公钥文件等,既有可直接运行的class,也有可修改阅读的源码与配置,便于二次开发。目前已有1027人学习/下载,适合正在处理金融、政务等敏感数据交换、需要快速落地PGP加密通信的中高级Java开发者参考。

1. 项目解析:一个能直接跑的Java PGP加密验签Demo,究竟解决了什么问题

做过接口对接的人都知道,最怕的不是业务逻辑复杂,而是对方甩一句“我们走PGP加密验签”。PGP这套体系在数据交换场景里用了很多年,但Java中能直接跑起来的最小案例却不多,尤其是要和HTTPClient、SSL/HTTPS串成一条链路时,坑位一个接一个。这份JAVA-PGP加密验签完整Demo,把密钥生成、数据加密、签名、验签、解密,以及通过HTTPS通道发送加密数据的完整流程都打通了,解压后改几个配置就能看到效果。它适合正在给某系统做PGP对接的开发者,也适合想理解“PGP与HTTPS双重保护机制”到底是怎么协作的技术人员。项目不是一个只有几个类的空壳,而是带密钥文件、配置文件和调试入口的最小工程,照着README跑一遍就能验证“签名被篡改会怎样”以及“密文能不能正确解开”。

2. 项目架构与依赖清单:从RAR包到Maven工程,先看清这三个模块

解压这个RAR包,你会发现它并不是一堆随手写的类,而是按照“加密验签核心”+“网络传输”+“配置与密钥”三个层次组织的。这样的分层不是为了好看,是为了让PGP操作和网络调用互相不掺杂,后面你替换任何一层,都不至于动到另一层。模块之间通过方法调用串联,核心入口只有一个MainDemo,其余类都可以独立复用。

2.1 解压后的目录结构:五个类撑起完整闭环

工程采用标准Maven目录,所有源码都在src/main/java下,资源文件在src/main/resources下。最核心的五个类分别是KeyGeneratorUtil、PgpEncryptor、PgpDecryptor、SslClientFactory和MainDemo。KeyGeneratorUtil负责生成PGP密钥对;PgpEncryptor封装了“签名+加密”两个动作;PgpDecryptor处理“解密+验签”;SslClientFactory创建带有证书信任的HttpClient;MainDemo则模拟了一次完整的加密传输。

src ├─ main │ ├─ java │ │ ├─ KeyGeneratorUtil.java │ │ ├─ PgpEncryptor.java │ │ ├─ PgpDecryptor.java │ │ ├─ SslClientFactory.java │ │ └─ MainDemo.java │ └─ resources │ ├─ config.properties │ └─ keys │ ├─ pub.asc │ ├─ sec.asc │ └─ truststore.jks ├─ pom.xml └─ README.md

从职责上看,KeyGeneratorUtil演示的是“如何造出一把钥匙”,实际生产中你可以把这个工具类交给管理员单独使用;PgpEncryptor和PgpDecryptor是业务调用的主要对象;SslClientFactory和MainDemo则演示了加密后的数据怎么通过HTTPS发给对方。我一般会在二次开发时保留这种分层,业务代码只面向一个encryptAndSend方法,不关心PGP底层到底用了哪些Provider。

密钥部分最容易混淆。pub.asc是PGP公钥环,sec.asc是私钥环,truststore.jks是Java的HTTPS证书信任库。很多人会把PGP公钥和服务器证书当成一回事,其实两套体系完全不同。PGP公钥用于加密和验签,证书信任库用于HTTPS握手时验证服务器身份。Demo里把这两类密钥分开管理,就是为了避免你把自己的PGP公钥误当成服务器证书导入信任库。

2.2 依赖选型:为什么用Bouncy Castle而不是Java自带JCE

Java自带的JCE虽然提供了通用的加解密接口,但它没有直接实现OpenPGP协议。PGP涉及密钥环读取、签名生成、数据压缩、分组加密等多个环节,最常用的第三方库是Bouncy Castle。它把OpenPGP实现单独放在bcpg-jdk15on模块里,配合bcprov-jdk15on底层算法库一起使用。这个Demo里两个依赖都引用了,前者提供PGP专用类,后者提供RSA、AES、SHA等基础算法。

在pom.xml里,依赖项是写成占位版本号的,实际使用时按你本地的Maven仓库选择稳定版即可,没必要锁死在某个版本。HttpClient部分我选用的是Apache HttpClient 5.x,它在自定义SSLContext和连接池方面比Java原生HttpClient更好控制。Java 11之后原生的java.net.http也能用,但在设置信任库时会绕一点,Demo里为了清晰展示SSL握手逻辑,选择了Apache版本。

<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcpg-jdk15on</artifactId> <version>${bouncycastle.version}</version> </dependency> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15on</artifactId> <version>${bouncycastle.version}</version> </dependency> <dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> <version>${httpclient5.version}</version> </dependency>

这里没有锁版本,不是偷懒,而是因为OpenPGP相关API在不同版本间偶尔会移动类路径,直接锁一个具体版本会让别人克隆后踩版本冲突。你在pom.xml里定义property即可,比如bouncycastle.version填你验证过的稳定版。我自己的习惯是先用Maven dependency:tree检查冲突,重点看bcprov和bcpg两个包的版本是否一致,不一致会导致底层算法与PGP包装类版本错位,运行时报一连串ClassNotFoundException。

2.3 配置文件的秘密:密钥路径、口令、证书和URL

config.properties集中管理三类参数:PGP密钥文件路径、密钥口令、目标HTTPS地址和信任库信息。初学者容易把密钥路径写死在代码里,换环境时为了找一行常量翻遍所有类。用配置管理后,开发、测试、生产环境切换只改文件。

pgp.public.key=src/main/resources/keys/pub.asc pgp.private.key=src/main/resources/keys/sec.asc pgp.passphrase=changeit server.https.url=https://127.0.0.1:8443/pgp/receive ssl.truststore=src/main/resources/keys/truststore.jks ssl.truststore.password=changeit ssl.truststore.type=JKS

注意,密钥口令在Demo里是明文,仅用于本地跑通。生产环境必须换成环境变量或者密钥管理平台,比如用“${PGP_PASSPHRASE}”这种占位符在启动脚本中注入。这个Demo把passphrase单独作为配置项,就是为了让你在跑通后第一时间意识到它的风险。

信任库配置里“type=JKS”决定了SSLContext加载方式。JKS是Java传统格式,如果你的环境生成的是PKCS12,这里也要同步改成PKCS12。我见过有人配置了PKCS12却不改类型,结果加载truststore时一直报错。这类问题在最开始的配置阶段忽略,后面连HTTPS都建立不起来。

配置层面还有一个容易忽略的点:server.https.url在Demo里是写死的,但生产环境中同一份代码可能要对接多个上游系统。建议把这段地址改成以“合作伙伴ID”为key的Map或独立配置文件,避免以后每接一个新渠道就改动核心代码。Demo之所以还保留单地址写法,是为了让第一次看的人能一眼定位发送动作。

3. 核心代码拆解:PGP加密验签的完整链路

PGP操作不像普通AES加解密一句Cipher就搞定,它涉及密钥环、压缩、签名、加密四个阶段。顺序很重要,标准流程是压缩原文、用私钥生成签名、把“原文+签名”一起加密。反过来先加密再签名是错的,因为对方没法在解密前验证你的签名,你也没法对密文签名。下面从环境初始化开始拆。

3.1 初始化PGP环境:Provider与密钥环读取

Bouncy Castle并没有直接内置到Java运行时里,所以任何PGP操作前都要先注册Provider。我习惯在静态代码块里调用Security.addProvider,保证类加载后立即生效。密钥环读取则用到一个加密流程中常见的专业工具类:PGPPublicKeyRingCollection和PGPSecretKeyRingCollection。

static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } } public static PGPPublicKey readPublicKey(InputStream in) throws IOException, PGPException { PGPPublicKeyRingCollection ringCollection = new PGPPublicKeyRingCollection(PGPUtil.getDecoderStream(in)); Iterator<PGPPublicKeyRing> ringIt = ringCollection.getKeyRings(); while (ringIt.hasNext()) { PGPPublicKeyRing ring = ringIt.next(); Iterator<PGPPublicKey> keyIt = ring.getPublicKeys(); while (keyIt.hasNext()) { PGPPublicKey key = keyIt.next(); if (key.isEncryptionKey()) { return key; } } } throw new PGPException("公钥环里找不到可用的加密公钥"); }

参数说明里,isEncryptionKey()是关键判断。PGP公钥环里可能同时存在主密钥和子密钥,但只有其中一个标记为可用于加密。如果直接拿第一个key来用,很可能拿到一个只有签名能力的密钥,加密时直接抛异常。这个读取方法先把流包装成PGPUtil.getDecoderStream,是因为PGP文件可能是ASCII-armor格式,也可能是二进制格式,getDecoderStream能自动识别并做转换。

私钥环读取比公钥环多一步:PGPSecretKey本身还带着加密保护,需要提供一个口令才能抽取出真正可用的PGPPrivateKey。常见做法是使用JcePBESecretKeyDecryptorBuilder,传入密钥环的口令,这里setProvider("BC")和前面的Provider注册必须一致。

PGPSecretKey secretKey = readSecretKey(new FileInputStream("keys/sec.asc")); PGPPrivateKey privateKey = secretKey.extractPrivateKey( new JcePBESecretKeyDecryptorBuilder() .setProvider(BouncyCastleProvider.PROVIDER_NAME) .build(passphrase.toCharArray()));

口令错误时,会在extractPrivateKey阶段抛出一个带“checksum mismatch”的PGPException,这个错误提示很直接。真正让新手困惑的是口令正确但依然报错的情况,通常是密钥环文件被复制坏,或者用了Windows记事本打开.asc文件自动加了BOM。解决办法是不要用文本编辑器保存PGP密钥,要用二进制方式复制。

3.2 加密与签名:先签后加密的标准动作

签名使用的是PGPSignatureGenerator,加密使用的是PGPEncryptedDataGenerator。整体被压缩到PGPCompressedDataGenerator内部。实现顺序是:先写压缩数据,然后写签名,再把“原文+签名”一起交给加密器。下面的代码是经过精简的可运行片段,实际Demo里还加入了Base64输出和文件流写入。

private static byte[] encryptAndSign(byte[] input, PGPPublicKey pubKey, PGPPrivateKey privKey, String passphrase) throws Exception { ByteArrayOutputStream out = new ByteArrayOutputStream(); // 1. 创建加密器:使用CAST5对称算法,按公钥加密会话密钥 PGPEncryptedDataGenerator encGen = new PGPEncryptedDataGenerator( new JcePGPDataEncryptorBuilder(SymmetricKeyAlgorithmTags.CAST5) .setProvider("BC").setSecureRandom(new SecureRandom())); encGen.addMethod(new JcePublicKeyKeyEncryptionMethodGenerator(pubKey).setProvider("BC")); OutputStream encryptedOut = encGen.open(out, new byte[4096]); // 2. 压缩数据 PGPCompressedDataGenerator compGen = new PGPCompressedDataGenerator( CompressionAlgorithmTags.ZIP); OutputStream compOut = compGen.open(encryptedOut); // 3. 生成签名并写入原文 PGPSignatureGenerator sigGen = new PGPSignatureGenerator( new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC").build(privKey)); sigGen.init(PGPSignature.BINARY_DOCUMENT, privKey); sigGen.generateOnePassVersion(false).encode(compOut); PGPLiteralDataGenerator litGen = new PGPLiteralDataGenerator(); OutputStream litOut = litGen.open(compOut, PGPLiteralData.BINARY, "data.bin", new Date(), new byte[4096]); litOut.write(input); litOut.close(); sigGen.update(input); sigGen.generate().encode(compOut); compGen.close(); encGen.close(); return out.toByteArray(); }

这段代码是把整个PGP加密签名过程浓缩在一个方法里。SymmetricKeyAlgorithmTags.CAST5是PGP传统对称算法,兼容性最好。如果你想用AES-128或AES-256,把常量换成AES_128或AES_256即可,但要注意对方PGP客户端是否支持。签名算法SHA256withRSA是普遍使用的一种组合,有些金融系统要求必须用SHA1或DSA,这取决于对方的安全策略。

逻辑说明里最容易被忽略的是generateOnePassVersion。OnePassSignature是写给流式处理用的,它让接收方在解密时可以边读边验签,不必等整个文件解密完。缺了这一段,有些PGP工具也能验签,但Java里的PGPObjectFactory可能读不到签名标志,导致你在验签阶段找不到PGPSignatureList。

还有一点:先压缩再加密能提高加密效率,但压缩后的数据长度不可预测。如果数据本身就是随机字节,比如密文、序列化对象,压缩率很低,这时可以选择UNCOMPRESSED,直接把压缩算法常量改成Uncompressed。Demo里默认ZIP是为了展示标准流程,生产环境中要根据业务数据特征决定。

3.3 验签与解密:对端收到以后怎么处理

接收方需要读取同一份密文,先解密,再分离签名和原文。Bouncy Castle官方的建议是按层打开:PGPEncryptedDataGenerator的解密输出流里是压缩数据,再往里是字面量数据和签名。完整代码里有一个两层循环遍历,Demo中PgpDecryptor处理了三种常见情况:只加密未签名的数据、加密且签名的数据、只签名未加密的数据。

private static void decryptAndVerify(InputStream in, PGPPublicKey pubKey, PGPPrivateKey privKey) throws Exception { PGPObjectFactory pgpFactory = new PGPObjectFactory( PGPUtil.getDecoderStream(in), new JcaKeyFingerprintCalculator()); Object next = pgpFactory.nextObject(); if (next instanceof PGPEncryptedDataList) { PGPEncryptedDataList encList = (PGPEncryptedDataList) next; PGPPublicKeyEncryptedData encData = (PGPPublicKeyEncryptedData) encList.get(0); InputStream clearStream = encData.getDataStream( new JcePublicKeyDataDecryptorFactoryBuilder() .setProvider("BC").build(privKey)); PGPObjectFactory clearFactory = new PGPObjectFactory( clearStream, new JcaKeyFingerprintCalculator()); PGPCompressedData compressed = (PGPCompressedData) clearFactory.nextObject(); PGPObjectFactory litFactory = new PGPObjectFactory( compressed.getDataStream(), new JcaKeyFingerprintCalculator()); PGPOnePassSignatureList onePassList = (PGPOnePassSignatureList) litFactory.nextObject(); PGPOnePassSignature onePass = onePassList.get(0); onePass.init(new JcaPGPContentVerifierBuilderProvider().setProvider("BC"), pubKey); PGPLiteralData literal = (PGPLiteralData) litFactory.nextObject(); InputStream literalIn = literal.getInputStream(); ByteArrayOutputStream result = new ByteArrayOutputStream(); int ch; while ((ch = literalIn.read()) >= 0) { onePass.update((byte) ch); result.write(ch); } PGPSignatureList signatureList = (PGPSignatureList) litFactory.nextObject(); boolean verified = onePass.verify(signatureList.get(0)); System.out.println("签名验证结果: " + verified); System.out.println("解密内容: " + new String(result.toByteArray(), StandardCharsets.UTF_8)); } }

这段代码的关键点是使用了两个PGPObjectFactory。外层工厂处理加密数据,内层工厂处理压缩后的明文内容。JcaKeyFingerprintCalculator是计算密钥指纹的工具,用来在密钥环中匹配当前使用的密钥,很多旧代码不传这个参数,调试时会遇到空指针。

逻辑上要特别注意异常情况:如果解密时拿到了一个私钥,但数据不是用对应公钥加密的,getDataStream会在读取时抛异常,而不一定在getDataStream调用时抛出。原因是PGP头信息里不会显示加密接收者密钥ID,直到用私钥尝试解开会话密钥才失败。我通常建议在业务层先捕获IOException并打印EncryptedData的KeyID,然后对比本地私钥的KeyID,能很快定位密钥是否匹配。

3.4 关键参数选择:对称算法、Hash算法和压缩算法

参数选择是二次开发和对接最容易翻车的地方。对称加密算法决定会话密钥的加密强度,Hash算法决定签名和验签的一致性,压缩算法影响带宽和性能。对接时主动权往往在对方手里,他们要求用什么你就得跟着改。

常见组合是CAST5 + SHA256withRSA + ZIP,因为老系统兼容性好。如果对方支持,AES-256会更好一点,但有些老旧Java PGP库只支持CAST5,你把加密器改成AES_256,对方解不出来。同理,签名算法选SHA1还是SHA256必须看对方验签代码用的是JcaPGPContentVerifierBuilderProvider还是老的PGPContentVerifierBuilder,后者不一定认SHA256。

我的建议是先去问对方要一个测试样例,用样例反过来调你的加密参数。如果对方连样例都不提供,你就按CAST5 + SHA256withRSA + ZIP兼容三件套来,这套组合在OpenPGP协议里覆盖范围最广。压缩算法也可以选ZLIB,它在极端情况下压缩率更高,但会拉高CPU占用,对实时接口不太友好。

4. 网络通道加固:HttpClient + SSL/HTTPS 的正确用法

PGP加密解决了“数据在业务层面不裸奔”的问题,但网络传输层的安全还需要递交给HTTPS。很多人问:已经有PGP了,为什么还要折腾SSL?答案是作用域不同。PGP保护的是文件或请求体的内容,即使被截获也无法读取;HTTPS保护的是传输过程,可以防止中间人篡改字节流。真正安全的系统是两者叠加,而不是二选一。

4.1 为什么需要HTTPS和PGP双保险

我给你一个场景:PGP加密后的数据通过HTTP明文发送,攻击者虽然读不出明文,但可以完整地替换整段密文,让对端解密失败或得到伪造数据。虽然PGP签名能防止篡改,但签名本身也可能被替换,形成“伪签名”导致业务无法继续。HTTPS通道通过证书和TLS握手,能确保连接的另一端确实是目标服务器,并且链路内数据不被中间人动过。

Demo里MainDemo的做法是:先用PgpEncryptor生成密文,再通过SslClientFactory创建的HttpClient发送到HTTPS接口。接收方收到密文后进入PgpDecryptor处理。两个环节各管各的,业务层不关心SSLContext是怎么创建的,网络层也不关心字节流是什么。

4.2 创建可复用的SSLContext:加载信任库而不是忽略证书

HttpClient的SSLContext决定了它如何验证服务器证书。最省事的写法是信任所有证书,但那等于关闭了HTTPS最重要的验证能力,只留下加密能力,生产上绝对不能这么干。Demo里加载的是truststore.jks,这个文件里存放了对方服务器的自签证书或私有CA证书。

public static CloseableHttpClient createHttpClient(String truststorePath, String truststorePassword, String truststoreType) throws Exception { KeyStore trustStore = KeyStore.getInstance(truststoreType); try (InputStream in = new FileInputStream(truststorePath)) { trustStore.load(in, truststorePassword.toCharArray()); } SSLContext sslContext = SSLContexts.custom() .loadTrustMaterial(trustStore, null) .build(); return HttpClients.custom() .setSSLContext(sslContext) .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) .build(); }

代码里NoopHostnameVerifier.INSTANCE需要重点解释。它表示不再校验服务器证书的域名是否和URL的主机名一致,这在测试环境访问IP地址时很常见。如果你请求的URL是“https://127.0.0.1:8443”,而证书里的CN和SAN没有绑定IP,默认的严格校验会直接拒绝连接。使用NoopHostnameVerifier只是绕过域名校验,但证书链的可信度仍然通过truststore验证,所以这不是忽略证书,是忽略域名不匹配。

加载TrustStore时,KeyStore.getInstance的type要跟证书生成时的格式一致。JKS是老格式,PKCS12是跨平台通用格式。用keytool或openssl生成信任库时,要指定type。我在一个对接项目里栽过跟头:对方给的证书是PKCS12结尾的,我在代码里写死JKS,结果load的时候抛“keystore password was incorrect”,其实是格式不匹配,不单单是密码问题。

4.3 与PGP流程集成:发送加密数据

PGP产生的密文是二进制字节流,直接用ByteArrayEntity放入POST请求即可。Demo里同时展示了两种方式:一种是直接发送字节数组,另一种是先做Base64编码再按文本发送。对方是Java服务,大概率能用字节流直接处理;对方是跨语言服务,Base64更保险。

public static void sendEncryptedData(String url, byte[] pgpEncryptedBytes) throws Exception { try (CloseableHttpClient client = SslClientFactory.createHttpClient( config.getTruststorePath(), config.getTruststorePassword(), config.getTruststoreType())) { HttpPost post = new HttpPost(url); post.setHeader("Content-Type", "application/octet-stream"); post.setEntity(new ByteArrayEntity(pgpEncryptedBytes)); try (CloseableHttpResponse response = client.execute(post)) { int code = response.getCode(); String body = EntityUtils.toString(response.getEntity()); if (code >= 400) { throw new RuntimeException("发送失败,HTTP状态码: " + code + ", body: " + body); } } } }

这里把HttpClient创建和发送放在同一个try里,是为了确保连接能被释放。CloseableHttpClient实现了Closeable,try-with-resources会在方法结束后关闭连接池。实际生产环境我会把HttpClient做成单例,否则每次请求都创建连接池,高并发下会频繁出现TIME_WAIT。

请求头要留意:如果你的密文是二进制,用application/octet-stream;如果你做了Base64,就要用text/plain或application/x-www-form-urlencoded,并告诉对方你用的是哪种编码。有一次联调时,对方说收到密文后Base64解码总是报IllegalArgumentException,最后发现是我们Header里写了octet-stream,但body实际是Base64字符串,对方按二进制流去读,自然出错。

4.4 证书更新与密钥轮换的联动思考

证书会过期,PGP密钥也会过期。生产环境的系统在改造时,至少要有两个有效密钥环:一个用于当前加密,一个用于即将启用的新密钥。Demo里只读取了一个公钥,但真正的二次开发里,密钥轮换会直接体现在配置层。我见过有人在密钥到期前一天才去更新,结果当天数据发送大规模失败,因为对方还在用旧公钥。

SSL证书也一样。truststore里通常只有当前版本服务器证书,一旦服务器更换证书,客户端会报PKIX path building failed。解决办法是把两代证书都放进truststore,验证时优先匹配当前连接。这种做法在Demo里体现为“信任库是数组不是单钥”,你可以用同一个KeyStore装载多个证书条目。

网络传输层和PGP层要分开监控,否则证书和PGP密钥混合在一起,出了问题很难判断是哪一层在报错。调试时先用curl或Postman绕过应用代码测试HTTPS,再用一个小文本文件测试PGP加密,最后再组合起来。这种分阶段隔离法能帮你省下大量排查时间。

5. 避坑记录:我跑这个Demo时踩过的五个坑

这个Demo虽然能跑通,但跑通的过程并不轻松。我把调试中遇到的高频问题整理成五条记录,每一条都是现象、原因、解决三连,希望你能绕过这些坑。

5.1 密钥环读取失败,提示“Malformed Data”或乱码

现象:程序启动时读取pub.asc或sec.asc,抛出IOException或PGPException,提示malformed data,甚至直接读出一堆乱码字符。原因:密钥文件在Windows下被记事本等文本编辑器打开过并保存,增加了BOM头或改变了换行符。PGP密钥虽然是ASCII文本,但它的读取器对头部有严格校验,BOM会导致识别失败。解决:用二进制方式重新传输密钥文件,不要用文本编辑器保存。可以先用“gpg --show-keys pub.asc”验证文件完整性,如果gpg能识别,Java这边也一定能。

5.2 加密后数据无法被对端解密,报“Unknown recipient”

现象:本地加密成功,发送给对方后,对方解密时提示“Unknown recipient”或“Cannot find key for encryption”。原因:加密时使用的公钥不是接收方私钥对应的公钥,或者公钥环中包含多个密钥,你选到了签名专用密钥而不是加密密钥。解决:在读取公钥时用isEncryptionKey()做筛选,并打印密钥ID,要求对方提供他们解密密钥的KeyID,确认两端使用的是同一枚密钥。密钥ID可以通过KeyGeneratorUtil的调试方法打印,从PGPPublicKey.getKeyID()拿到十六进制字符串。

5.3 HTTPS握手失败,报“PKIX path building failed”

现象:第一次使用SslClientFactory请求HTTPS地址,日志里出现javax.net.ssl.SSLHandshakeException,caused by是PKIX path building failed。原因:客户端信任库里没有目标服务器证书或对应CA证书。可能是你导入证书时用的是DER格式,而信任库要求的是Base64格式,也可能你压根忘了指定server.https.url对应的证书。解决:用keytool -list -keystore truststore.jks查看条目,确认存在正确的证书。如果证书格式不对,用openssl x509 -in server.pem -outform DER转换一下再导入。还要检查请求IP地址的证书SAN是否包含该IP,否则NoopHostnameVerifier也救不了你。

5.4 签名验证时抛NoSuchProviderException

现象:在验签阶段调用onePass.init时,抛出NoSuchProviderException,提示找不到“BC”Provider。原因:Provider注册发生在某个静态块里,但被使用的类先于静态块加载,或者你的运行环境中多个地方同时调用了Security.removeProvider。另一个常见原因是BouncyCastle依赖没有在类加载时添加,因为使用的是bcpg-jdk15on而缺少bcprov-jdk15on。解决:把Security.addProvider(new BouncyCastleProvider())放到所有加密类的最上层静态初始化块中,并在pom.xml里同时声明bcprov和bcpg两个依赖。检查Maven依赖树,确保没有两个不同版本的BouncyCastle同时存在。

5.5 解密后中文内容变成乱码

现象:原始数据是中文,加密、解密后控制台打印的是乱码。原因:加密前把字符串转成了String.getBytes(),没有指定字符集;解密后又用了系统默认字符集。如果生产环境是Windows,默认GBK,Linux是UTF-8,一段数据会在这两个平台产生不同结果。解决:统一使用StandardCharsets.UTF_8。在PgpEncryptor里用input.getBytes(StandardCharsets.UTF_8),在PgpDecryptor里用new String(result.toByteArray(), StandardCharsets.UTF_8),两端指定一致,乱码问题立刻消失。另外,不要用PrintStream默认编码输出到控制台,在IDEA和Linux终端可能表现不同。

5.6 连接池耗尽导致接口超时

现象:并发量上来后,发送PGP加密数据的接口开始变慢,最终报ConnectionPoolTimeoutException。原因:每次请求都新建CloseableHttpClient,导致连接池反复建立和销毁,旧连接没有及时释放。解决:把HttpClient做成单例,在应用启动时创建一次,并配置合理的连接时间和空闲回收策略。这个Demo里的SslClientFactory已经预留了单例接口,你可以在类里增加一个volatile字段,双重检查锁创建实例。修改后,相同并发下性能提升明显。

6. 二次开发改造思路:从Demo到生产级服务

这个Demo的验证价值已经达到了,但直接搬到生产项目里还不够。我建议从三个方向改造:把PGP操作封装成可注入的服务、把发送流程改为异步、把配置从properties迁移到配置中心。

首先是封装备。当前PgpEncryptor和PgpDecryptor都还是静态方法,不合适Spring管理。可以改成普通的Spring Bean,通过@Value注入config.properties变量,再对外暴露encryptAndSign(data, partnerId)和verifyAndDecrypt(data, partnerId)两个实例方法。partnerId用来选择不同合作方的公钥和URL,这样一套代码能服务多个上游系统。

@Service public class PgpGatewayService { public byte[] sendEncrypted(String partnerId, byte[] plainData) { PgpConfig cfg = configService.getByPartnerId(partnerId); byte[] encrypted = pgpEncryptor.encryptAndSign(plainData, cfg); return httpSender.post(cfg.getUrl(), encrypted); } }

其次是把发送改为异步。当前MainDemo是同步调用,生产环境中如果对方接口响应慢,会拖垮调用线程。我一般会用CompletableFuture包装一下,把耗时操作放在独立线程池里,并设置超时时间。注意PGP加密本身是CPU密集型,线程池大小不要盲目调大,核心线程数可以按机器核数乘以2来设。

最后是把密钥环放进对象存储或配置中心。JKS和PGP密钥文件在容器环境里不适合再放本地磁盘,因为容器重建时文件可能丢失。常见的做法是把密钥文件读取到一个字节数组,从数据库或配置中心获取,然后交给Bouncy Castle的PGPPublicKeyRingCollection处理。这个Demo里所有读取都是文件流,你只需要把InputStream的来源换成字节数组流即可。

改造后的验证方法我是这么做的:保留Demo原有MainDemo作为回放测试,每次升级加密参数后用同一份明文跑一遍,确认输出签名验证结果仍然是true。然后做一次破坏性测试,故意修改一个字节的密文,确认对方验签失败。这样的回归测试能有效防止改动过程中把PGP链路改坏。

我从那次对接之后养成了一个习惯:每次接新的PGP合作方,都会先在Demo里用对方的样例密钥跑通两端验签,再进主工程改代码。这个习惯让我避开了无数次“本地好的,联调就挂”的尴尬,省下的时间远超写Demo的时间。如果你也要做类似的对接,希望这份解析能帮你少走一圈弯路,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 15:42:06

GMAP.net实战:C#开源地图组件与瓦片缓存二次开发指南

简介&#xff1a;GMAP.net是一套基于C#的开源地图组件&#xff0c;适合需要在WinForms、WPF、ASP.NET或UWP等平台中集成在线/离线地图功能的开发者。它内置对Google Maps、Bing Maps、OpenStreetMap等多地图服务商的支持&#xff0c;同时提供瓦片缓存与离线浏览机制&#xff0c…

作者头像 李华
网站建设 2026/10/21 15:41:40

从技能梳理到能力地图:掌握刻意练习与技能变现的完整路径

“skills”这个词&#xff0c;这些年快被说烂了。打开任何一个招聘软件、知识付费平台、职场攻略帖&#xff0c;它都像万金油一样贴得到处都是。简历上要写“熟练掌握XX技能”&#xff0c;绩效表上要填“技能提升目标”&#xff0c;朋友圈里有人在晒“21天掌握XX”。但真正的现…

作者头像 李华
网站建设 2026/10/21 15:41:13

Ubuntu下Nginx启动失败的七层故障排查与自动启动原理

1. 为什么“启动Nginx”会变成一场深夜救火演习&#xff1f; 刚接手某高校实验室的Web服务运维时&#xff0c;我信心满满地敲下 sudo systemctl start nginx ——结果终端只回了一句冷冰冰的 Failed to start nginx.service: Unit nginx.service not found. 。那一刻&#…

作者头像 李华
网站建设 2026/10/21 15:40:35

个人技能管理:用Markdown构建可维护的技能资产库与实践复盘

做个人技能管理这个项目&#xff0c;起因其实特别朴素。干了多年技术活儿&#xff0c;知识越来越散&#xff0c;简历上的技能列表越写越长&#xff0c;但真被问到一个写过两三年的老本行&#xff0c;经常只能说出个大概&#xff0c;有些细节甚至要现场翻旧代码才能确认。这种感…

作者头像 李华
网站建设 2026/10/21 15:38:27

不依赖OpenCV:基于libjpeg的YUV420P转JPG编码库实现

简介&#xff1a;基于libjpeg完成YUV转JPEG的编码示例包&#xff0c;面向嵌入式开发者、多媒体处理人员以及视频编码学习者&#xff0c;解决YUV原始图像帧无法直接保存为通用JPEG文件的问题。压缩包内共8个文件&#xff0c;包括4个头文件、2个静态库文件、1个libtool描述文件和…

作者头像 李华
网站建设 2026/10/21 15:37:58

APDU命令解析实战:智能卡调试的黑匣子解码指南

简介&#xff1a;本资源是一份面向智能卡开发工程师、嵌入式安全从业者及高校信息安全专业学习者的APDU协议深度解析资料&#xff0c;聚焦智能卡与终端间通信的核心指令机制&#xff0c;系统解决APDU命令格式理解难、响应状态判读模糊、常用指令&#xff08;如Select File、Rea…

作者头像 李华