Java中如何实现PGP/MIME邮件签名?签名无效问题排查
PGP/MIME邮件签名无效问题排查与解决方案
问题描述
用Java实现RFC3156定义的PGP/MIME邮件签名,使用PGPainless SOP和Angus Mail库。纯文本签名功能正常,未签名邮件发送正常,但签名后的邮件在Thunderbird、Evolution中均提示“存在签名但无效”。尝试过移除首尾换行、替换CRLF、切换哈希算法等操作,问题依然存在。
最小复现代码
@Test public void testSendSignedMessage() throws MessagingException, IOException { String SECRET_KEY = "..."; String PASSWORD = "..."; Properties properties = new Properties(); properties.put("mail.smtp.auth", true); properties.put("mail.smtp.ssl.enable", "true"); properties.put("mail.smtp.host", "smtp.cia.gov"); properties.put("mail.smtp.port", "465"); properties.put("mail.store.protocol", "imaps"); properties.put("mail.imap.host", "imap.cia.gov"); properties.put("mail.imap.port", "993"); properties.put("mail.imap.ssl.enable", "true"); Session session = Session.getInstance(properties, new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication("jason.bourne@cia.gov", "treadstone"); } }); MimeMessage email = new MimeMessage(session); email.setFrom("jason.bourne@cia.gov"); email.setRecipient(Message.RecipientType.TO, new InternetAddress("pamela.landy@cia.gov")); email.setSubject("Get some rest"); MimeBodyPart textPart = new MimeBodyPart(); textPart.setText("You look tired."); MimeMultipart multipart = new MimeMultipart(); multipart.addBodyPart(textPart); email.setContent(multipart); this.signMessage(email, SECRET_KEY.getBytes(), PASSWORD.getBytes(), session); Transport.send(email); } private void signMessage(MimeMessage email, byte[] secretKey, byte[] password, Session session) throws MessagingException, IOException { Multipart signedMultipart = new MimeMultipart(); MimeBodyPart contentBody = new MimeBodyPart(); MimeBodyPart signatureBody = new MimeBodyPart(); Multipart contentMultipart = (Multipart) email.getContent(); contentBody.setContent(contentMultipart); signedMultipart.addBodyPart(contentBody); Part temporaryEmail = new MimeMessage(session); temporaryEmail.setContent(contentMultipart); OutputStream outputStream = new ByteArrayOutputStream(); temporaryEmail.writeTo(outputStream); String boundary = this.parseBoundary(String.valueOf(outputStream)).orElse(""); signatureBody.attachFile(this.createSignatureAttachment(contentMultipart, secretKey, password, boundary)); signatureBody.setHeader("Content-Type", "application/pgp-signature; name=\"signature.asc\""); signatureBody.setHeader("Content-Description", "OpenPGP signature"); signatureBody.setHeader("Content-Disposition", "attachment; filename=\"signature.asc\""); signedMultipart.addBodyPart(signatureBody); email.setContent(signedMultipart); outputStream = new ByteArrayOutputStream(); email.writeTo(outputStream); boundary = parseBoundary(String.valueOf(outputStream)).orElse(""); email.setHeader( "Content-Type", "multipart/signed; micalg=\"pgp-sha256\"; protocol=\"application/pgp-signature\"%s".formatted( boundary.isEmpty() ? "" : "; boundary=\"%s\"".formatted(boundary) ) ); } private File createSignatureAttachment( Multipart message, byte[] secretKey, byte[] password, String boundary ) throws IOException, MessagingException { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); message.writeTo(outputStream); String messageContent = outputStream .toString(StandardCharsets.UTF_8) .replaceFirst("^-*%s-*".formatted(boundary), "") // Remove the leading boundary .replaceFirst("-*%s-*$".formatted(boundary), "") // Remove the trailing boundary .replaceFirst("^[\n\r]+", "") // Remove leading newlines .replaceFirst("[\n\r\\s]+$", "") // Remove trailing newlines and whitespace .replaceAll("(?<!\r)\n", "\r\n"); // Convert line endings to CRLF SOP sop = new SOPImpl(); DetachedSign signature = sop.sign().key(secretKey).withKeyPassword(password); InputStream inputStream = signature.data( new ByteArrayInputStream(messageContent.getBytes()) ).toByteArrayAndResult().getInputStream(); Path tempDirectory = Path.of(System.getProperty("java.io.tmpdir")); File signatureDirectory = tempDirectory .resolve("signature-" + UUID.randomUUID()) .toFile(); signatureDirectory.mkdirs(); File encryptedFile = new File(signatureDirectory, "signature.asc"); try (FileOutputStream fileOutputStream = new FileOutputStream(encryptedFile)) { fileOutputStream.write(inputStream.readAllBytes()); } return encryptedFile; } private Optional<String> parseBoundary(final String contentTypeHeader) { Matcher matcher = Pattern .compile("boundary=\"(?<boundary>.+)\"", Pattern.CASE_INSENSITIVE) .matcher(contentTypeHeader); if (matcher.find()) return Optional.of(matcher.group("boundary")); return Optional.empty(); }
依赖配置
<dependency> <groupId>jakarta.mail</groupId> <artifactId>jakarta.mail-api</artifactId> <version>${jakarta-mail.version}</version> </dependency> <dependency> <groupId>org.eclipse.angus</groupId> <artifactId>angus-mail</artifactId> <version>${angus-mail.version}</version> </dependency> <dependency> <groupId>org.pgpainless</groupId> <artifactId>pgpainless-sop</artifactId> <version>${pgpainless.version}</version> </dependency>
生成的邮件正文
------=_Part_1_166454155.1747153500629 Content-Type: multipart/mixed; boundary="----=_Part_0_1604052588.1747153500624" ------=_Part_0_1604052588.1747153500624 Content-Type: text/plain; charset=us-ascii Content-Transfer-Encoding: 7bit You look tired. ------=_Part_0_1604052588.1747153500624-- ------=_Part_1_166454155.1747153500629 Content-Type: application/pgp-signature; name="signature.asc" Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename="signature.asc" Content-Description: OpenPGP signature -----BEGIN PGP SIGNATURE----- iHUEABYKACcFg... -----END PGP SIGNATURE----- ------=_Part_1_166454155.1747153500629--
错误分析与修正
核心错误点
- 签名内容被手动篡改:RFC3156要求签名的是multipart/signed第一个part的完整原始MIME内容(包括所有头字段、换行符、边界),手动裁剪边界、移除换行的操作破坏了字节流一致性,导致客户端验证时内容和签名时的内容不匹配。
- 临时邮件错误引入额外头:用
temporaryEmail.writeTo()输出的内容包含邮件头(如From/To),但实际需要签名的是邮件的正文multipart部分,不是整个邮件。 - 签名part的Content-Disposition错误:PGP/MIME规范中,签名part应该是
inline而非attachment,客户端会忽略作为附件的签名,无法关联到正文内容。 - 手动设置boundary易出错:应该让MimeMultipart自动管理boundary,手动解析再设置容易出现格式差异(比如空格、换行位置)。
修正后的关键代码
private void signMessage(MimeMessage email, byte[] secretKey, byte[] password, Session session) throws MessagingException, IOException { // 提取原始内容part Multipart contentMultipart = (Multipart) email.getContent(); MimeBodyPart contentBody = new MimeBodyPart(); contentBody.setContent(contentMultipart); // 直接对contentBody的原始字节流签名,不做任何修改 ByteArrayOutputStream contentOut = new ByteArrayOutputStream(); contentBody.writeTo(contentOut); byte[] contentBytes = contentOut.toByteArray(); // 生成分离式签名 SOP sop = new SOPImpl(); DetachedSign signature = sop.sign().key(secretKey).withKeyPassword(password); byte[] signatureBytes = signature.data(new ByteArrayInputStream(contentBytes)) .toByteArrayAndResult().getBytes(); // 创建签名part MimeBodyPart signatureBody = new MimeBodyPart(); signatureBody.setContent(signatureBytes, "application/pgp-signature"); signatureBody.setHeader("Content-Description", "OpenPGP signature"); // 必须设置为inline,不能是attachment signatureBody.setHeader("Content-Disposition", "inline; filename=\"signature.asc\""); // 创建multipart/signed容器 MimeMultipart signedMultipart = new MimeMultipart("signed"); signedMultipart.addBodyPart(contentBody); signedMultipart.addBodyPart(signatureBody); // 手动设置协议和哈希算法,符合RFC3156 signedMultipart.setSubType("signed; protocol=\"application/pgp-signature\"; micalg=\"pgp-sha256\""); // 替换邮件内容 email.setContent(signedMultipart); // 让JavaMail自动处理Content-Type头,无需手动设置boundary }
替代库推荐
- PGPainless Mail模块:直接提供
PGPMime类,一键生成PGP/MIME签名/加密邮件,完全符合RFC3156,无需手动拼接MIME结构,代码量大幅减少。依赖只需添加pgpainless-mail。 - BouncyCastle Mail:功能全面,但需要手动处理更多MIME细节,学习成本较高。
内容的提问来源于stack exchange,提问作者Pixelcode
相关产品推荐
相关产品推荐

