Skip to content

feat(cms): encrypt content to an RSA public key with RSAES-OAEP over SHA-256 - #285

Merged
lomigmegard merged 3 commits into
mainfrom
feat/cms-ktri-recipient
Oct 31, 2026
Merged

lomigmegard merged 3 commits into
mainfrom
feat/cms-ktri-recipient

Conversation

@lomigmegard

@lomigmegard lomigmegard commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

encrypt_data reads recipient: in place of encryption_key: to seal content to an RSA-2048 or RSA-4096 public key held elsewhere. The CMS container carries a KeyTransRecipientInfo written by hand: the content-encryption key, drawn from the step's backend, is protected with RSAES-OAEP over SHA-256 and the recipient is named by the SHA-256 of its SPKI. expect_recipient: checks the key as on wrap_key, and rite verify matches the container against the recorded recipient.

decrypt_data opens such a container through open_data_key, and also reads a CMS file sealed elsewhere and supplied as a material. Both directions are checked against openssl cms with OAEP over SHA-256. PKCS#1 v1.5 and OAEP with other parameters are refused by name.

It also fixes a fingerprint mismatch already in wrap_key: an RSA public key supplied without the NULL algorithm parameters was recorded under one fingerprint and named in the container under another, so rite verify failed on an honest run. PublicKeyDer now keeps one canonical encoding, and the names a key goes by have their own types in rite-sdk: KeyFingerprint, Rfc5280KeyId and cms::RecipientIdentifier.

EC recipients (kari) follow in a separate PR.

Refs #202

…SHA-256

The CMS envelope carries the protection of its content-encryption key and
writes the recipient form that protection takes: a KEKRecipientInfo for AES
key wrap, a KeyTransRecipientInfo for RSAES-OAEP over SHA-256, named by the
SHA-256 of the key's SPKI as its subject key identifier. The OAEP parameters
are encoded as OpenSSL writes them.

encrypt_data reads recipient: in place of encryption_key: to seal content to
an RSA-2048 or RSA-4096 public key held elsewhere. The content-encryption
key's bytes come from the step's backend and are sealed on the host, with no
backend key involved. expect_recipient checks the key received against a
declared fingerprint, and the recipient is recorded in a
wrap_recipient_recorded fact, as for wrap_key, so rite verify compares it
with the identifier the container carries. wrap_key and encrypt_data share
the recipient check, which runs before anything is recorded.

decrypt_data takes the protection from the container and passes it to
open_data_key, which opens an RSA-protected data key under an RSA key
allowed to unwrap. It also reads a material's bytes as a container sealed
outside the ceremony, such as one written by `openssl cms -encrypt`. A key
transport recipient is matched by either name a public key goes by: the
SHA-256 of its SPKI, or the SHA-1 of RFC 5280 method 1, which is what a
certificate's subject key identifier usually holds.

Reading a container is exact:
- a KEKRecipientInfo must be version 4, a KeyTransRecipientInfo version 2
  with a subject key identifier or version 0 with an issuer and serial
  number, and neither may carry bytes after its encrypted key;
- describing an RSAES-OAEP recipient checks its parameters, and opening
  admits only OAEP over SHA-256 with MGF1 over SHA-256 and the empty label;
- PKCS#1 v1.5 is refused by name, as is a key of the wrong kind or a
  content key that is not 32 bytes.

Refs #202
Comment thread crates/rite-openssl/tests/interop.rs Dismissed
…ey goes by

An RSA public key supplied without the NULL algorithm parameters, which
OpenSSL reads as the same key, had two fingerprints: the recipient fact
hashed the bytes as supplied and the backend holding the private half
reported the encoding OpenSSL exports. wrap_key then failed rite verify on
an honest run, and decrypt_data refused content sealed with encrypt_data
to such a key as addressed elsewhere. PublicKeyDer::new now writes absent
RSA parameters as NULL, so every fingerprint is taken over one encoding.
The fingerprint of a key supplied that way is now the one its holder's
backend reports, and no longer the digest of the file as supplied.

The names a key goes by get their own types in rite-sdk:
- KeyFingerprint, the sha256: name over the SPKI. Only
  PublicKeyDer::fingerprint computes one, and the wrap_recipient_recorded
  fact, expect_recipient and the throwaway certificate's subject key
  identifier use it in place of five separate SHA-256 computations.
- Rfc5280KeyId, the SHA-1 identifier X.509 tooling writes, which Rite
  only reads.
- cms::RecipientIdentifier, the bytes a container names its recipient
  by. They are matched against a key or a recorded name and never read as
  one, so a refusal no longer guesses from their length whether they are a
  fingerprint or a check value, and shows them as plain hex.

rite verify parses the names the transcript records and compares them
with the container's identifier, instead of rendering the identifier as
a string to compare. Each field is read as the kind of name it is called,
and a name is compared only in the recipient form that carries that kind:
a check value in a KEKRecipientInfo, a fingerprint in the public-key
forms.

The transcript format and both schemas are unchanged.
@lomigmegard
lomigmegard merged commit 70d2c9c into main Oct 31, 2026
14 checks passed
@lomigmegard
lomigmegard deleted the feat/cms-ktri-recipient branch October 31, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants