smime-tree
S/MIME sign, verify, encrypt, and decrypt via caller-provided key traits. Implements RFC 5751 (S/MIME v3.2) over CMS (RFC 5652) with no async, no network calls, and no commitment to where keys live.
Why this crate exists
S/MIME libraries typically own the keys — they expect a PKCS#12 file, a software
keystore, or a specific HSM SDK. smime-tree inverts this: key operations are
defined by traits (SigningKey, DecryptionKey) that the caller implements. The
crate handles CMS structure parsing, algorithm dispatch, certificate chain validation,
and MIME formatting; the caller decides where the private key actually lives — in
memory, a hardware token, an HSM, or a remote signing service.
smime-tree depends on mime-tree for byte-range extraction:
verify() uses ParsedPart.body_range to locate the exact signed bytes in the
original message buffer, which is required for correct digest computation.
Operations
| Function | Input | Output |
|---|---|---|
sign(content_mime, key, now) |
Raw MIME bytes + SigningKey + current time |
multipart/signed MIME bytes |
verify(signed_content, signature_der, trust_anchors, now, revocation) |
Signed content + DER signature | VerificationResult |
encrypt(inner_mime, recipients) |
MIME bytes + recipient certificates | application/pkcs7-mime bytes |
decrypt(enveloped_der, key) |
DER blob + DecryptionKey |
Inner MIME bytes |
decrypt returns raw bytes. Feed them to mime_tree::parse() to get the part
tree. If the result is itself S/MIME, loop — this crate does not recurse.
How it works
Verify
verify() takes the raw bytes of the signed MIME part and the DER-encoded
application/pkcs7-signature blob. It:
- Parses the DER blob as a CMS
ContentInfo→SignedData. - For each
SignerInfo, recomputes the message digest over the supplied content bytes (RFC 5652 §5.4) and checks it against themessageDigestsigned attribute. - Verifies the
SignerInfosignature over the DER-encodedSignedAttributesSET. - Locates the signer's certificate in the
SignedDatacertificate bag and walks the chain to a caller-supplied trust anchor (RFC 5280 §6.1). - Returns a
VerificationResultwith oneSignerResultperSignerInfo— all failures are reported, not just the first.
The caller extracts the exact signed bytes using mime-tree byte ranges. Using
the wrong byte slice (e.g. re-encoded content) will cause digest mismatch.
Decrypt
decrypt() takes a DER-encoded ContentInfo wrapping EnvelopedData. It:
- Iterates the
RecipientInfolist; for each entry callsDecryptionKey::matches_recipient()to find the right one. - For KTRI (RSA): calls
DecryptionKey::decrypt_cek()with the encrypted key bytes. - For KARI (ECDH): calls
DecryptionKey::agree_ecdh()with the ephemeral public key, UKM, and wrapped CEK; the trait implementation performs the ECDH exchange and key unwrap. - Decrypts the content with the recovered CEK (AES-128-CBC or AES-256-CBC).
- Returns the plaintext bytes. Feed them to
mime_tree::parse()to get the part tree.
Sign
sign() builds a detached CMS SignedData and wraps it in a multipart/signed
MIME structure. It:
- Hashes
content_mimewith the selected digest algorithm. - Builds
SignedAttributes: content-type, message-digest, signing-time (now). - Calls
SigningKey::sign()over the DER-encodedSignedAttributesSET. - Constructs
SignedDatawith the signer's certificate embedded in the bag. - Base64-encodes the DER blob into the
application/pkcs7-signatureMIME part.
Encrypt
encrypt() builds a CMS EnvelopedData with one RecipientInfo per certificate:
- RSA certificates →
KeyTransRecipientInfo(RSA-OAEP key transport). - EC P-256/P-384 certificates →
KeyAgreeRecipientInfo(ECDH-ES + AES key wrap).
A random CEK is generated for each message. Content is encrypted with AES-256-CBC.
Implementing the key traits
DecryptionKey
use ;
For ECDH (P-256/P-384) decryption, also override agree_ecdh. The default
implementation returns UnsupportedAlgorithm.
SigningKey
use ;
use Certificate;
The digest algorithm is derived from the certificate key type by default
(P-256 → SHA-256, P-384 → SHA-384, P-521 → SHA-512, RSA → SHA-256).
Override preferred_digest_algorithm() to force a specific algorithm.
Verification
use ;
use SystemTime;
let result = verify?;
for signer in &result.signers
Use mime-tree byte ranges to extract the exact signed bytes from the raw message:
let signed_part = msg.part_index.find_by_id.unwrap;
let = signed_part.body_range;
let signed_bytes = &raw;
Revocation checking
NoRevocationCheck accepts all certificates. To enforce revocation policy,
implement RevocationChecker:
This crate makes no network calls. Keeping the trust store and revocation data fresh is the caller's responsibility.
Design invariants
- No async. All operations are synchronous.
- No network calls. No OCSP or CRL fetch at runtime.
- No JMAP dependency.
- Key operations are trait-based. Keys may live in memory, an HSM, or a hardware token — the crate does not care.
- Caller handles recursion. Decrypted bytes are returned as-is. If they contain another S/MIME layer, the caller loops.
Specification references
| RFC | Title |
|---|---|
| RFC 5751 | S/MIME Version 3.2 Message Specification |
| RFC 5652 | Cryptographic Message Syntax (CMS) |
| RFC 5280 | PKIX Certificate and CRL Profile (certificate chain validation) |
| RFC 5753 | Use of ECC Algorithms in CMS (ECDH P-256/P-384 key agreement) |
| RFC 8017 | PKCS#1 v2.2 — RSA Cryptography Standard (RSA key transport) |
| RFC 3565 | AES Algorithm in CMS (AES-128-CBC, AES-256-CBC content encryption) |
| RFC 5083 | AES-GCM in CMS (AuthEnvelopedData) |
| RFC 2634 | Enhanced Security Services (triple-wrap, countersignatures) |
License
Licensed under either of MIT or Apache-2.0 at your option.