
简介这是一份面向Java开发者的PGP加密与数字签名实战Demo资源适用于需要在HTTP通信中集成端到端安全机制的中高级工程师尤其适合处理敏感数据传输、合规性验签如金融、政务接口等场景。资源包含两个完整可运行项目一个专注PGP密钥生成、文件加解密与签名验签全流程实现另一个则基于HttpClient封装PGP加密后的内容发送逻辑并预留OkHttp替换接口支持携带CA证书的HTTPS安全投递。压缩包共42个文件涵盖13个核心Bouncy Castle加密库jar包、8个关键Java源码、2个密钥文件public.key/private.key、2个ASCII格式公钥证书.asc以及项目配置类文件整体11.75MB结构清晰、依赖明确开箱即用。目前已有1028人学习下载读者可直接复用加解密工具类、理解PGP与SSL/TLS协同工作模式并获得真实环境下的证书集成参考方案。1. 这不是“加个密就完事”的玩具JAVA-PGP加密验签完整Demo专治HTTP通信中「数据被篡改却浑然不觉」的玄学翻车你有没有遇到过这种场景用 HttpClient 调第三方 API传的是 JSON对方说“你发的签名不对”你查日志发现请求体明文是对的但对方验签失败或者更糟——某天突然发现生产环境里一批订单金额被中间人悄悄改了两位小数而你的 HTTPS SSL 看起来明明牢不可破别急着骂运维或怀疑证书链PGPPretty Good Privacy层的端到端加密验签才是 HTTP 协议之上真正兜底的数据完整性与身份可信性防线。这个JAVA-PGP加密验签完整Demo.rar不是教科书示例它是一套可直接嵌入 Spring Boot 微服务、兼容 JDK 8–17、带完整密钥生成→消息签名→密文传输→服务端解密→验签全流程的实战包。它解决的不是“能不能加密”而是“怎么让签名在 HttpClient 的 RequestBody 流式写入、GZIP 压缩、代理转发、Nginx 缓存等真实链路中依然稳如磐石”。适合正在做金融级接口对接、政务系统数据交换、或被甲方反复要求提供“不可抵赖性证明”的 Java 后端工程师。2. PGP 在 Java 生态里为什么选 Bouncy Castle 而不是原生 JCE2.1 为什么 JDK 自带的 JCE 不足以支撑生产级 PGP 验签Java 原生安全库JCE只提供基础的对称/非对称算法如 AES、RSA但PGP 是一套协议规范不是单一算法它包含密钥环KeyRing管理、OpenPGP 消息封装格式RFC 4880、压缩ZIP/ZLIB、对称加密CAST5/AES、公钥加密RSA/ElGamal、数字签名DSA/ECDSA、以及最关键的——子包嵌套结构Signature Subpackets和签名时间戳绑定机制。JCE 没有PGPSignatureGenerator、PGPEncryptedDataGenerator这类高层抽象你得自己拼接二进制头、计算 SHA-1/SHA256 摘要、手动处理 MPIMultiple Precision Integer编码……这在 HTTP 场景下极易因字节序、填充方式、摘要截断长度不一致导致验签失败。某跨平台系统曾因此在灰度期连续三天验签失败率 37%最后发现是 JDK 11 的MessageDigest默认输出字节数组顺序与 OpenPGP 规范要求的“大端网络序”不一致而 Bouncy Castle 内部已做标准化处理。2.2 Bouncy Castle 的三个不可替代能力Bouncy CastleBC是 Java 生态中唯一被广泛验证、持续维护、且完全遵循 RFC 4880 的 OpenPGP 实现。它的核心价值不在“能用”而在“能稳用”密钥环原子操作PGPSecretKeyRing和PGPPublicKeyRing支持从文件/流/字节数组加载并自动解析子密钥Subkey、用户IDUserID、签名认证Self-Signature、密钥过期时间等元数据。HTTP 客户端调用时可按userIdapipartner.com动态匹配对应密钥而非硬编码 keyId。流式加解密支持PGPCompressedDataGeneratorPGPEncryptedDataGenerator可直接包装HttpEntity#getContent()返回的InputStream避免将整个报文加载进内存——这对 10MB 的 PDF 报表附件传输至关重要。签名时间戳强绑定BC 的PGPSignatureGenerator允许显式注入SignatureSubpacket例如强制添加Signature Creation Time和Issuer Fingerprint服务端验签时可校验“签名是否在密钥有效期内生成”杜绝用已吊销密钥伪造旧签名。提示本 Demo 使用 BC 1.70 版本2023 年后发布已修复 JDK 17 下SecureRandom初始化阻塞问题。若你项目仍用 BC 1.60 以下请务必升级否则在容器化部署时可能因/dev/random耗尽导致线程卡死。2.3 如何在 Spring Boot 项目中无侵入集成不是简单加个bcprov-jdk15on.jar就完事。BC 需注册为安全提供者Security Provider且必须在SpringApplication.run()之前完成否则RestTemplate或WebClient初始化时会跳过 BC 提供的算法。本 Demo 采用SpringApplicationRunListener方式在prepareEnvironment()阶段注入// src/main/java/com/demo/config/BouncyCastleRegistrar.java public class BouncyCastleRegistrar implements SpringApplicationRunListener { public BouncyCastleRegistrar(SpringApplication application, String[] args) {} Override public void starting(ConfigurableBootstrapContext bootstrapContext) { // 必须在 Security 初始化前注册否则部分算法不可见 if (Security.getProvider(BC) null) { Security.insertProviderAt(new BouncyCastleProvider(), 1); } } }并在META-INF/spring.factories中声明org.springframework.boot.SpringApplicationRunListener\ com.demo.config.BouncyCastleRegistrar这样做的好处是所有后续创建的HttpClient、RestTemplate、甚至KeyStore.getInstance(PKCS12)都能无缝使用 BC 提供的增强算法无需修改任何业务代码。3. HttpClient 与 PGP 的三重握手如何把签名消息塞进 HTTP Body 而不被破坏3.1 HTTP 层面的“隐形杀手”Content-Encoding 与 Transfer-Encoding很多开发者以为“只要我用setContentType(application/pgp-encrypted)就万事大吉”结果在 Nginx 或 CDN 后验签失败。根本原因是 HTTP 协议栈的多层编码干扰干扰层行为说明PGP 影响Content-Encoding: gzipHttpClient 或服务端自动对 body 压缩PGP 密文是二进制流gzip 会破坏其字节结构导致解密时InvalidPacketExceptionTransfer-Encoding: chunked分块传输时每块前缀含长度十六进制字符串如a\r\n...若未在 chunked 编码前完成 PGP 封装PGP 解析器会把a\r\n当作无效 packet 头Content-Type字符集声明如charsetUTF-8某些老旧 HttpClient 实现会尝试对 body 做字符编码转换PGP 密文含大量非 ASCII 字节0x00–0xFFUTF-8 强制转码会引入 \uFFFD 替换符彻底毁掉密文本 Demo 的解决方案是在 HttpClient 发送前将 PGP 操作收束为最终字节数组禁用所有自动编码走原始ByteArrayEntity。// src/main/java/com/demo/client/PgpHttpClient.java public HttpResponse sendEncryptedRequest(String url, String plainText, PGPSecretKey secretKey) throws Exception { // 1. 生成 PGP 加密签名后的字节数组非字符串 byte[] pgpMessage PgpUtil.encryptAndSign(plainText.getBytes(StandardCharsets.UTF_8), secretKey, publicKeyRing); // 公钥环来自配置 // 2. 构造原始字节数组实体禁用所有自动处理 ByteArrayEntity entity new ByteArrayEntity(pgpMessage); entity.setContentType(application/pgp-encrypted); // 不带 charset entity.setChunked(false); // 强制关闭分块 // 3. 设置 HttpClient 参数绕过默认 Content-Encoding CloseableHttpClient client HttpClients.custom() .disableContentCompression() // 关键禁用 gzip 压缩 .build(); HttpPost post new HttpPost(url); post.setEntity(entity); return client.execute(post); }注意disableContentCompression()是 Apache HttpClient 4.5 才有的方法。若你用的是老版本4.3需手动设置RequestConfig并禁用ContentEncoding头。3.2 如何让服务端正确识别并解析 PGP 消息服务端不能依赖RequestBody String因为 Spring 默认会用StringHttpMessageConverter尝试 UTF-8 解码而 PGP 密文不是合法 UTF-8。必须接管原始字节流// src/main/java/com/demo/controller/PgpController.java PostMapping(value /api/v1/data, consumes application/pgp-encrypted) public ResponseEntityString handlePgpData(RequestBody byte[] encryptedBytes) { try { // 直接传入字节数组给 PGP 解析器 PgpDecryptionResult result PgpUtil.decryptAndVerify( encryptedBytes, secretKeyRing, // 服务端私钥环 publicKeyRing // 用于验签的对方公钥环 ); if (!result.isValidSignature()) { return ResponseEntity.status(400).body(Signature verification failed); } String plainText new String(result.getDecryptedData(), StandardCharsets.UTF_8); return ResponseEntity.ok(Success: plainText.length() chars received); } catch (PgpException e) { log.error(PGP decryption failed, e); return ResponseEntity.status(400).body(Decryption error: e.getMessage()); } }关键点在于consumes application/pgp-encryptedRequestBody byte[]的组合它会跳过所有HttpMessageConverter直通ServletInputStream确保字节零失真。3.3 PGP 消息体的结构化设计为什么不用 base64 编码有人会问“二进制 HTTP body 不好调试为啥不 base64” —— 这是个血泪经验。Base64 编码会使体积膨胀 33%更重要的是base64 编码后的字符串可能被中间件如某些 WAF误判为“可疑 payload”而拦截或清洗。某公司曾因 WAF 对base64字符串做 HTML 实体转义→#43;导致解密时IllegalStateException: unknown object in stream。本 Demo 采用ASCII Armor 封装即-----BEGIN PGP MESSAGE-----开头的文本格式它本质仍是 base64但被 OpenPGP 规范明确定义所有合规中间件都认识它且支持行末补齐、换行符标准化\r\n比裸 base64 更健壮。生成代码如下// PgpUtil.java 中的封装方法 public static String armorEncode(byte[] binaryData) { ByteArrayOutputStream out new ByteArrayOutputStream(); ArmoredOutputStream armoredOut new ArmoredOutputStream(out); try { armoredOut.write(binaryData); armoredOut.close(); return out.toString(StandardCharsets.US_ASCII); } catch (IOException e) { throw new RuntimeException(e); } }这样生成的 message 既可读方便日志记录和人工排查又符合标准还能被gpg --dearmor直接转回二进制。4. 避坑那些让你凌晨三点还在看 WireShark 的 PGP 验签失败现场4.1 现象PGPException: Unknown signature type: 0x00原因客户端签名时用了PGPSignature.BINARY_DOCUMENT类型 0x00但服务端验签代码硬编码只接受PGPSignature.CANONICAL_TEXT_DOCUMENT类型 0x01。OpenPGP 规范中BINARY_DOCUMENT表示“原始字节不做归一化”而CANONICAL_TEXT_DOCUMENT会将\n统一转为\r\n再哈希——若客户端发的是 JSON含\n服务端用CANONICAL模式验签必然失败。解决统一使用PGPSignature.BINARY_DOCUMENT并在文档中明确约定“所有 PGP 消息均以二进制模式处理”禁用任何文本归一化。4.2 现象InvalidPacketException: unknown packet type 19原因BC 1.70 默认启用Experimental Packet Type 19即Symmetrically Encrypted Integrity Protected Data Packet但某些老旧 PGP 库如早期 GnuPG不识别该类型直接抛异常。解决降级为传统加密包在PGPEncryptedDataGenerator初始化时指定PGPEncryptedDataGenerator encGen new PGPEncryptedDataGenerator( new JcePGPDataEncryptorBuilder(PGPEncryptedData.CAST5) .setWithIntegrityPacket(false) // 关键禁用 integrity packet .setSecureRandom(new SecureRandom()) );4.3 现象Signature check failed: signature doesnt match但gpg --verify成功原因服务端验签时PGPSignatureGenerator未设置setHashAlgorithm导致 BC 默认用SHA1而客户端用的是SHA256。更隐蔽的是某些 BC 版本在PGPSignatureGenerator初始化时若未显式调用generateOnePassVersion(true)会生成不带 One-Pass Signature 的消息而部分服务端解析器要求必须有该包。解决客户端签名时强制指定signGen.init(PGPSignature.BINARY_DOCUMENT, signingKey); signGen.setHashAlgorithm(HashAlgorithmTags.SHA256); // 显式设哈希 signGen.generateOnePassVersion(true); // 强制生成 One-Pass 包服务端验签前先检查signature.getHashAlgorithm()是否等于SHA256不匹配则拒绝。4.4 现象NullPointerExceptionatPGPSignature.verifyCertification()原因公钥环PGPPublicKeyRing中缺少自签名Self-Signature或用户IDUserID包。BC 验签时会遍历所有PGPSignature子包若某个PGPSignature的getEncoded()返回 null因损坏或截断就会 NPE。解决加载公钥环后强制校验完整性public static void validatePublicKeyRing(PGPPublicKeyRing ring) { for (PGPPublicKey key : ring.getPublicKeys()) { if (key.isEncryptionKey() || key.isSigningKey()) { for (IteratorPGPSignature sigIt key.getSignatures(); sigIt.hasNext(); ) { PGPSignature sig sigIt.next(); if (sig.getEncoded() null) { throw new IllegalArgumentException(Corrupted signature in key key.getKeyID()); } } } } }4.5 现象本地测试全通过上 K8s 后SecureRandom初始化超时原因容器内/dev/random设备熵池不足new SecureRandom()阻塞等待。BC 1.68 已优化但若你项目间接依赖了老版本 BC如通过spring-boot-starter-security仍可能触发。解决启动 JVM 时强制指定熵源java -Djava.security.egdfile:/dev/./urandom -jar app.jar或在代码中替换SecureRandom// 替换全局 SecureRandom 实例 SecureRandom prng new SecureRandom(); prng.setProvider(new SecureRandom().getProvider()); // 强制用默认 provider5. 密钥生命周期管理从生成、分发到轮换一个都不能少5.1 生成符合生产要求的密钥对参数不是随便填的很多 Demo 用RSA_GENERAL 1024 位这在 2024 年已是高危操作。本 Demo 的KeyGenerator类强制执行以下策略主密钥Master KeyRSA 4096 位仅用于证书签名Certify永不用于加密或签名数据子密钥Subkey加密子密钥RSA 3072 位有效期 2 年签名子密钥Ed25519非 RSA因 EdDSA 签名更快、密钥更短、抗量子特性更好用户ID 格式API Service servicedomain.com含邮箱便于追溯签名策略主密钥对子密钥签名时必须包含Signature Creation Time、Key Expiration Time、Preferred Symmetric AlgorithmsAES256三个子包。生成代码节选// KeyGenerator.java public static PGPSecretKeyRing generateKeyRing(String userId, String passphrase) { PGPDigestCalculator sha256Calc new JcaPGPDigestCalculatorProviderBuilder() .build().get(HashAlgorithmTags.SHA256); // 主密钥4096 RSA仅 Certify RSAKeyPairGenerator masterKpg new RSAKeyPairGenerator(); masterKpg.init(new RSAKeyGenerationParameters( BigInteger.valueOf(0x10001), new SecureRandom(), 4096, 12)); // 子密钥3072 RSA 加密 Ed25519 签名 AsymmetricCipherKeyPair encKeyPair generateRsaKeyPair(3072); AsymmetricCipherKeyPair signKeyPair generateEd25519KeyPair(); // 构建主密钥 PGPKeyRingGenerator keyRingGen new PGPKeyRingGenerator( PGPSignature.POSITIVE_CERTIFICATION, masterKeyPair, userId, sha256Calc, null, null, new JcaPGPContentSignerBuilder(masterKeyPair.getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA256), new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.AES_256, sha256Calc) .setProvider(BC).build(passphrase.toCharArray()) ); // 添加加密子密钥 keyRingGen.addSubKey(encKeyPair, null, null); // 添加签名子密钥 keyRingGen.addSubKey(signKeyPair, null, null); return keyRingGen.generateKeyRing(); }提示Ed25519 子密钥需 BC 1.69 支持。若目标环境受限可降级为 RSA 3072但必须禁用MD5和SHA1哈希算法。5.2 密钥分发的两种安全路径密钥绝不能以明文文件形式邮件发送。本 Demo 提供双通道分发方案方式适用场景安全要点离线 USB 交付首次对接、高敏感系统使用gpg --export --armor导出公钥环.asc私钥环.asc用强密码加密后交付接收方用gpg --decrypt解密HTTPS API 注册SaaS 多租户、动态密钥轮换租户调用POST /v1/keys/register传入armoredPublicKey和tenantId服务端校验userId格式、密钥强度、签名有效性后存入数据库服务端密钥加载逻辑// KeyManager.java public PGPPublicKeyRing loadPublicKeyRing(String tenantId) { String armored keyRepository.findByTenantId(tenantId).getArmoredPublicKey(); try (ArmoredInputStream aIn new ArmoredInputStream( new ByteArrayInputStream(armored.getBytes(StandardCharsets.US_ASCII)))) { PGPPublicKeyRingCollection pgpPub new PGPPublicKeyRingCollection(aIn); // 只取第一个 ring单租户单密钥 return pgpPub.getKeyRings().nextElement(); } }5.3 密钥轮换的平滑过渡技巧密钥不能“一刀切”切换必须支持新旧密钥并存期Grace Period。本 Demo 的PgpVerifier实现了双密钥链路public class PgpVerifier { private final MapString, PGPPublicKeyRing activeKeys; // 当前有效密钥 private final MapString, PGPPublicKeyRing deprecatedKeys; // 已废弃但仍在宽限期的密钥 public boolean verify(byte[] data, String issuerUserId) { // 优先用 activeKeys 验签 PGPPublicKeyRing ring activeKeys.get(issuerUserId); if (ring ! null verifyWithRing(data, ring)) { return true; } // 宽限期尝试 deprecatedKeys ring deprecatedKeys.get(issuerUserId); if (ring ! null verifyWithRing(data, ring)) { log.warn(Deprecated key used for {}, issuerUserId); return true; } return false; } }宽限期设为 30 天由定时任务每天扫描deprecatedKeys中密钥的getExpirationTime()过期则自动清理。6. 验证闭环用真实抓包 单元测试构建不可绕过的 PGP 信任链6.1 用 Wireshark 抓包验证 PGP 消息的“端到端保真”光跑单元测试不够必须看到真实字节流。本 Demo 提供PgpTrafficCapture工具类可在测试中启动嵌入式 WireMock捕获原始 HTTP 请求体// PgpTrafficCaptureTest.java Test public void testPgpRequestIntegrity() throws Exception { // 1. 启动 WireMock记录原始请求体 WireMockServer wireMock new WireMockServer(options().port(8089)); wireMock.start(); wireMock.stubFor(post(/api/v1/data) .willReturn(aResponse().withStatus(200))); // 2. 发送 PGP 请求 PgpHttpClient client new PgpHttpClient(wireMock.baseUrl()); client.sendEncryptedRequest(/api/v1/data, {\amount\:100}, secretKey); // 3. 获取原始字节数组 LoggedRequest request wireMock.findAll(RequestPatternBuilder.newRequestPattern() .withMethod(POST) .withUrl(/api/v1/data)).get(0); byte[] rawBody request.getBody(); // 4. 用 BC 独立解析验证结构 try (InputStream in new ByteArrayInputStream(rawBody)) { PGPObjectFactory factory new PGPObjectFactory(in, new BcKeyFingerprintCalculator()); Object obj factory.nextObject(); assertTrue(obj instanceof PGPEncryptedDataList); // 必须是加密数据列表 } }抓包后用 Wireshark 打开.pcap文件过滤http.request.uri contains v1/data右键 → “Follow → HTTP Stream”确认响应体开头是-----BEGIN PGP MESSAGE-----且无乱码、无\r\n损坏。这是 PGP 在 HTTP 链路中存活的铁证。6.2 单元测试覆盖的五个致命边界本 Demo 的PgpUtilTest包含 5 个不可跳过的测试用例每个都对应一个真实翻车场景测试用例验证点失败后果testSignWithExpiredKey()客户端用已过期的私钥签名服务端应拒绝旧密钥泄露后被滥用testDecryptWithWrongPassphrase()私钥环密码错误应抛PGPException而非NullPointerException日志无法定位密钥加载失败原因testVerifyTamperedSignature()手动篡改密文最后 1 字节验签必须返回false中间人篡改无法被发现testHandleChunkedEncoding()模拟 HttpClient 分块发送验证PgpUtil是否能正确重组字节流Nginx 后验签失败testEd25519VsRsaInteroperability()用 Ed25519 签名服务端用 RSA 公钥验签反之亦然必须失败算法不匹配导致静默失败每个测试都用ParameterizedTest覆盖 JDK 8/11/17 三版本确保 BC 兼容性。6.3 生产环境的 PGP 健康检查 Endpoint上线后不能等故障才排查。本 Demo 内置/actuator/pgp-health端点返回结构化诊断{ status: UP, details: { keyRingLoaded: true, masterKeyValid: true, subKeysExpiringSoon: [2024-12-01], signatureAlgorithm: Ed25519, encryptionAlgorithm: AES256, lastVerified: 2024-05-20T14:22:33Z } }实现逻辑Component public class PgpHealthIndicator implements HealthIndicator { Override public Health health() { Health.Builder builder Health.up(); try { // 检查密钥环加载 assertNotNull(publicKeyRing); // 检查主密钥未过期 assertTrue(masterKey.getExpirationTime() null || masterKey.getExpirationTime().after(new Date())); // 检查子密钥有效期预警 30 天内过期 ListDate expiringSoon findExpiringSubKeys(30); builder.withDetail(keyRingLoaded, true) .withDetail(masterKeyValid, true) .withDetail(subKeysExpiringSoon, expiringSoon) .withDetail(signatureAlgorithm, getSignatureAlgorithm()) .withDetail(lastVerified, new Date()); } catch (Exception e) { builder.down().withException(e); } return builder.build(); } }从那以后我每次上线新密钥都强制走一遍/actuator/pgp-health Wireshark 抓包双验证再通知对接方。不是信不过代码是信不过自己手抖复制错一行userId。希望帮到你。本文还有配套的精品资源点击获取