From 691497c841fc9c7f394bd0040c730cd1ef08f181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Wed, 28 Oct 2026 21:43:38 +0100 Subject: [PATCH 1/3] feat(cms): encrypt content to an RSA public key with RSAES-OAEP over 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 --- crates/rite-model/src/params/crypto.rs | 16 + crates/rite-model/src/transcript.rs | 22 +- crates/rite-model/src/types.rs | 27 +- crates/rite-openssl/src/backend.rs | 220 +++++- crates/rite-openssl/src/lib.rs | 2 +- crates/rite-openssl/tests/interop.rs | 276 ++++++- crates/rite-runtime/src/display.rs | 6 +- crates/rite-sdk/src/cms.rs | 745 +++++++++++++++--- crates/rite-sdk/src/key_material.rs | 42 + crates/rite-sdk/src/types.rs | 4 + crates/rite-stdlib/src/crypto/content.rs | 23 +- crates/rite-stdlib/src/crypto/decrypt_data.rs | 250 +++++- crates/rite-stdlib/src/crypto/encrypt_data.rs | 238 +++++- crates/rite-stdlib/src/crypto/mod.rs | 69 +- crates/rite-stdlib/src/crypto/wrap_key.rs | 39 +- crates/rite-stdlib/tests/actions.rs | 473 ++++++++++- crates/rite/src/container_checks.rs | 96 ++- .../development/cryptographic-dependencies.md | 5 +- docs/encrypting-content.md | 133 +++- docs/schema/ceremony.schema.json | 69 +- docs/schema/transcript.schema.json | 4 +- 21 files changed, 2471 insertions(+), 288 deletions(-) diff --git a/crates/rite-model/src/params/crypto.rs b/crates/rite-model/src/params/crypto.rs index 249b4f5..8bdad8e 100644 --- a/crates/rite-model/src/params/crypto.rs +++ b/crates/rite-model/src/params/crypto.rs @@ -297,6 +297,22 @@ pub struct EncryptDataParams { #[serde(default)] #[schemars(schema_with = "content_schemes")] pub scheme: Option, + /// The expected fingerprint of the recipient's public key, in the form + /// `sha256:<64 lowercase hex digits>`, for a step that reads `recipient`. + /// The step fails if the recipient key does not match. + // + // Content sealed to the wrong recipient is readable by whoever holds that + // key and by nobody the ceremony meant. Lowercase only, for the reason + // given at `WrapKeyParams`. + #[serde(default)] + #[schemars( + regex(pattern = r"^sha256:[0-9a-f]{64}$"), + extend( + "x-message" = "'expect_recipient' must be a fingerprint of the form \ + 'sha256:<64 lowercase hex digits>'" + ) + )] + pub expect_recipient: Option, } /// The schemes that carry content, which are the ones `encrypt_data` writes. diff --git a/crates/rite-model/src/transcript.rs b/crates/rite-model/src/transcript.rs index 811bd10..6a30179 100644 --- a/crates/rite-model/src/transcript.rs +++ b/crates/rite-model/src/transcript.rs @@ -1253,11 +1253,12 @@ pub enum StepFact { )] fingerprint: Option, }, - /// A key held outside the ceremony was used as the recipient of a wrap. + /// A key held outside the ceremony was used as the recipient of a wrap or + /// of encrypted content. /// - /// Its own fact rather than a field on the wrap operation: the method of a - /// wrap is publishable and the identity of a custodian may not be, and a - /// fact carries one confidentiality level. + /// Its own fact rather than a field on the operation: the method of a wrap + /// or an encryption is publishable and the identity of a custodian may not + /// be, and a fact carries one confidentiality level. /// /// The fingerprint records the bytes that were present. Nothing in the /// ceremony corroborates that they belong to the intended recipient, and @@ -1266,14 +1267,17 @@ pub enum StepFact { #[cfg_attr( test, schemars( - description = "A key held outside the ceremony received a wrapped key. A separate \ - fact from the operation, so the method of a wrap can be published while the recipient \ - stays withheld." + description = "A key held outside the ceremony received a wrapped key or encrypted \ + content. A separate fact from the operation, so the method can be published while the \ + recipient stays withheld." ) )] WrapRecipientRecorded { - /// Step that wrapped to this recipient. - #[cfg_attr(test, schemars(description = "The step that wrapped the key."))] + /// Step that wrapped or encrypted to this recipient. + #[cfg_attr( + test, + schemars(description = "The step that wrapped the key or encrypted the content.") + )] step: StepId, /// The artifact or material the recipient key came from, as named in /// the ceremony. diff --git a/crates/rite-model/src/types.rs b/crates/rite-model/src/types.rs index 8d24548..7e945dc 100644 --- a/crates/rite-model/src/types.rs +++ b/crates/rite-model/src/types.rs @@ -130,15 +130,20 @@ pub enum ActionType { /// The wrapped data identifies its scheme. The step checks the recovered /// key against `algorithm` when one is given. UnwrapKey, - /// Encrypt data using a key held by a backend. + /// Encrypt data for a key held by a backend or for a public key. /// - /// The backend generates a content-encryption key, encrypts the data with - /// it, and protects it with the step's encryption key. The data itself is - /// not sent to the backend. + /// Reads `data` and exactly one of `encryption_key` or `recipient`. With + /// `encryption_key`, the backend generates a content-encryption key and + /// protects it with that key. With `recipient`, an RSA-2048 or RSA-4096 + /// public key held elsewhere, the backend supplies the content-encryption + /// key's random bytes, which are protected with RSAES-OAEP over SHA-256. + /// The data itself is not sent to the backend. EncryptData, - /// Decrypt data encrypted by `encrypt_data`. + /// Decrypt data encrypted by `encrypt_data`, or a CMS file sealed + /// elsewhere and supplied as a material. /// - /// The result is a byte artifact available to later steps and erased from + /// The step checks that the container names the decryption key. The + /// result is a byte artifact available to later steps and erased from /// memory when the run ends. DecryptData, /// Split a secret into shares so that `threshold` of them can recover it. @@ -719,7 +724,15 @@ impl ActionType { with_field_requires: &[], lists: &[], }, - ActionType::EncryptData => ReadsContract::required(&["data", "encryption_key"]), + ActionType::EncryptData => ReadsContract { + required: &["data"], + exactly_one_of: &[&["encryption_key", "recipient"]], + optional: &[], + // As at `wrap_key`: only the public-key path has a recipient + // to compare. + with_field_requires: &[("expect_recipient", "recipient")], + lists: &[], + }, ActionType::DecryptData => { ReadsContract::required(&["encrypted_data", "decryption_key"]) } diff --git a/crates/rite-openssl/src/backend.rs b/crates/rite-openssl/src/backend.rs index fcf0d99..369c5d2 100644 --- a/crates/rite-openssl/src/backend.rs +++ b/crates/rite-openssl/src/backend.rs @@ -1184,6 +1184,50 @@ fn rsa_oaep_decrypt( Ok(out) } +/// Pair a content-encryption key with a copy of it only `recipient` opens. +/// +/// The counterpart of [`KeyTransportBackend::generate_data_key`] for a +/// recipient that is someone else's public key. Protecting a data key needs +/// only the public half, so no backend key and no device take part, and the +/// holder of the private key opens it with +/// [`KeyTransportBackend::open_data_key`]. +/// +/// The caller supplies the key, so its randomness comes from whichever source +/// the caller answers for rather than from this function. +/// +/// An RSA-2048 or RSA-4096 recipient takes RSAES-OAEP over SHA-256, which is +/// what a CMS `KeyTransRecipientInfo` declares for it. Those are the RSA sizes +/// every other path in this backend accepts, so a data key is not sealed to a +/// key the backend would refuse to wrap to. +/// +/// # Errors +/// +/// Returns [`BackendError::UnsupportedAlgorithm`] if the recipient is not one +/// of those keys, and [`BackendError::Other`] if the public key cannot be +/// parsed. +pub fn data_key_for_recipient( + recipient: &PublicKeyDer, + plaintext: Zeroizing>, +) -> Result { + let recipient = PKey::public_key_from_der(recipient.as_bytes()) + .map_err(|e| ossl_err("Parse recipient public key", &e))?; + // Refuses an RSA size outside the two by name. + let recipient_algorithm = key_algorithm_of(&recipient)?; + if !WrapScheme::RsaOaepSha256.accepts_recipient(recipient_algorithm) { + return Err(BackendError::UnsupportedAlgorithm(format!( + "a data key is sealed to an RSA public key, and this recipient is \ + {recipient_algorithm}" + ))); + } + + let wrapped = rsa_oaep_encrypt(&recipient, &plaintext)?; + Ok(DataKey::new( + plaintext, + wrapped, + KeyProtection::RsaOaepSha256, + )) +} + /// SHA-256 for both the OAEP digest and MGF1. /// /// Set on every OAEP context, encrypt and decrypt alike. The digest is not @@ -1368,14 +1412,15 @@ fn cms_seal_to_kek( let cek = random_bytes(32)?; let sealed = crate::content::seal_content(&cek, payload)?; - let envelope = cms::KekEnvelope { + let envelope = cms::Envelope { + protection: KeyProtection::AesKeyWrap, key_identifier: key_identifier.to_vec(), wrapped_cek: aes_key_wrap(secret, &cek, KeyWrap::Plain, true)?, nonce: sealed.nonce, ciphertext: sealed.ciphertext, tag: sealed.tag, }; - cms::write_kek_enveloped(&envelope).map_err(|e| BackendError::InvalidData(e.to_string())) + cms::write_auth_enveloped(&envelope).map_err(|e| BackendError::InvalidData(e.to_string())) } /// Undo [`cms_encrypt_kek`]. @@ -1402,8 +1447,9 @@ fn cms_open_from_kek( check_value: &KeyCheckValue, der: &[u8], ) -> Result>, BackendError> { - // The reader insists the blob declare `id-aes256-wrap`, and the cipher - // below follows the local secret's length, so without this a blob naming + // The reader admits a symmetric recipient only under `id-aes256-wrap`, and + // the protection check below refuses every other recipient form. The + // cipher follows the local secret's length, so without this a blob naming // one algorithm could be opened under another and the run would record the // name rather than what it did. The seal side refuses the same size. if secret.len() != 32 { @@ -1414,7 +1460,16 @@ fn cms_open_from_kek( ))); } let envelope = - cms::read_kek_enveloped(der).map_err(|e| BackendError::InvalidData(e.to_string()))?; + cms::read_auth_enveloped(der).map_err(|e| BackendError::InvalidData(e.to_string()))?; + // The reader also admits a key transport recipient, whose content key an + // AES key wrap must not be run over. + if envelope.protection != KeyProtection::AesKeyWrap { + return Err(BackendError::InvalidData(format!( + "this container protects its content key with {}, and the key held here is a \ + symmetric one", + envelope.protection + ))); + } // Not covered by the content tag, which authenticates the content and // nothing around it, so this comparison is what refuses a blob addressed @@ -1761,26 +1816,37 @@ impl KeyTransportBackend for OpenSslBackend { wrapped: &[u8], protection: KeyProtection, ) -> Result>, BackendError> { - // Refused rather than attempted. The bytes of one protection are - // indistinguishable from another's, so opening under the wrong one - // either fails obscurely or, for an unauthenticated mechanism, returns - // something that is not the key. - if protection != KeyProtection::AesKeyWrap { - return Err(BackendError::UnsupportedAlgorithm(format!( - "this backend opens a data key protected with {}, and this one is protected \ - with {protection}", - KeyProtection::AesKeyWrap - ))); - } let kek = self.get_key(kek)?; kek.policy.require(KeyUsages::UNWRAP, "open a data key")?; - let (secret, _) = kek.secret()?; - Ok(Zeroizing::new(aes_key_wrap( - secret, - wrapped, - KeyWrap::Plain, - false, - )?)) + // The protection says which key the opening needs, and a key of the + // other kind is refused by the accessor, which names it. Nothing is + // attempted under an assumption: one protection's bytes are + // indistinguishable from another's. + match protection { + KeyProtection::AesKeyWrap => { + let (secret, _) = kek.secret()?; + Ok(Zeroizing::new(aes_key_wrap( + secret, + wrapped, + KeyWrap::Plain, + false, + )?)) + } + KeyProtection::RsaOaepSha256 => { + let pkey = kek.pkey()?; + if pkey.id() != Id::RSA { + return Err(BackendError::UnsupportedAlgorithm(format!( + "a data key protected with {protection} opens under an RSA key, and \ + '{}' is {}", + kek.label, kek.algorithm + ))); + } + rsa_oaep_decrypt(pkey, wrapped) + } + _ => Err(BackendError::UnsupportedAlgorithm(format!( + "this backend does not open a data key protected with {protection}" + ))), + } } fn wrap_to_public( @@ -1846,6 +1912,98 @@ mod tests { } } + /// A data key sealed to a public key needs no backend key on the way in, + /// and the private key the backend holds opens it on the way out. + #[test] + fn a_data_key_sealed_to_a_public_key_opens_under_its_private_half() { + let mut backend = OpenSslBackend::try_new("software").unwrap(); + let recipient = backend + .generate_key(kek(KeyAlgorithm::Rsa2048, "recipient")) + .unwrap(); + let public = backend.export_public_key(&recipient.key_id).unwrap(); + + let data_key = data_key_for_recipient(&public, random_bytes(32).unwrap()).unwrap(); + assert_eq!(data_key.protection(), KeyProtection::RsaOaepSha256); + assert_eq!(data_key.plaintext().len(), 32); + assert_eq!(data_key.wrapped().len(), 256, "one RSA-2048 block"); + + let opened = backend + .open_data_key( + &recipient.key_id, + data_key.wrapped(), + KeyProtection::RsaOaepSha256, + ) + .unwrap(); + assert_eq!(opened.as_slice(), data_key.plaintext()); + } + + #[test] + fn a_data_key_is_sealed_only_to_an_rsa_public_key() { + let mut backend = OpenSslBackend::try_new("software").unwrap(); + let ec = backend + .generate_key(kek(KeyAlgorithm::EcdsaP256, "ec")) + .unwrap(); + let public = backend.export_public_key(&ec.key_id).unwrap(); + let message = data_key_for_recipient(&public, random_bytes(32).unwrap()) + .unwrap_err() + .to_string(); + assert!(message.contains("ECDSA-P256"), "{message}"); + + // An RSA size the rest of the backend refuses is refused here too, + // though a 32-byte key would fit under its OAEP ceiling. + let weak = PKey::from_rsa(Rsa::generate(1024).unwrap()).unwrap(); + let weak = PublicKeyDer::new(weak.public_key_to_der().unwrap()).unwrap(); + let message = data_key_for_recipient(&weak, random_bytes(32).unwrap()) + .unwrap_err() + .to_string(); + assert!(message.contains("1024"), "{message}"); + } + + /// Opening follows the protection the container names, and a key of the + /// other kind, or one whose policy does not allow it, is refused. + #[test] + fn an_rsa_protected_data_key_opens_only_under_an_rsa_key_allowed_to() { + let mut backend = OpenSslBackend::try_new("software").unwrap(); + let recipient = backend + .generate_key(kek(KeyAlgorithm::Rsa2048, "recipient")) + .unwrap(); + let public = backend.export_public_key(&recipient.key_id).unwrap(); + let data_key = data_key_for_recipient(&public, random_bytes(32).unwrap()).unwrap(); + + let ec = backend + .generate_key(kek(KeyAlgorithm::EcdsaP256, "ec")) + .unwrap(); + let aes = backend + .generate_key(spec(KeyAlgorithm::Aes256, "aes")) + .unwrap(); + let signing_only = backend + .generate_key(spec(KeyAlgorithm::Rsa2048, "signer")) + .unwrap(); + for (key, case) in [ + (&ec.key_id, "an EC key"), + (&aes.key_id, "a symmetric key"), + (&signing_only.key_id, "an RSA key without the unwrap usage"), + ] { + assert!( + backend + .open_data_key(key, data_key.wrapped(), KeyProtection::RsaOaepSha256) + .is_err(), + "{case} must not open it" + ); + } + + // The right key under the wrong protection is refused too. + assert!( + backend + .open_data_key( + &recipient.key_id, + data_key.wrapped(), + KeyProtection::AesKeyWrap + ) + .is_err() + ); + } + /// Check a signature against the key's exported public half. /// /// Verification deliberately does not go back through the key object that @@ -3187,7 +3345,7 @@ mod tests { /// The CMS path refuses the same mismatch. /// - /// `read_kek_enveloped` insists the blob declare id-aes256-wrap, and the + /// A symmetric recipient is admitted only under id-aes256-wrap, and the /// CEK is then unwrapped with a cipher chosen from the local secret, so /// without this guard a container could name one algorithm and be opened /// under another. @@ -3287,7 +3445,7 @@ mod tests { let wrapped = backend .wrap(&target.key_id, &kek.key_id, WrapScheme::CmsAes256Gcm) .unwrap(); - let good = cms::read_kek_enveloped(wrapped.data()).unwrap(); + let good = cms::read_auth_enveloped(wrapped.data()).unwrap(); let flip = |bytes: &[u8]| { let mut bytes = bytes.to_vec(); @@ -3299,35 +3457,35 @@ mod tests { let variants = [ ( "ciphertext", - cms::KekEnvelope { + cms::Envelope { ciphertext: flip(&good.ciphertext), ..good.clone() }, ), ( "tag", - cms::KekEnvelope { + cms::Envelope { tag: flip(&good.tag), ..good.clone() }, ), ( "nonce", - cms::KekEnvelope { + cms::Envelope { nonce: flip(&good.nonce), ..good.clone() }, ), ( "wrapped key", - cms::KekEnvelope { + cms::Envelope { wrapped_cek: flip(&good.wrapped_cek), ..good.clone() }, ), ( "recipient identifier", - cms::KekEnvelope { + cms::Envelope { key_identifier: flip(&good.key_identifier), ..good.clone() }, @@ -3335,7 +3493,7 @@ mod tests { ]; for (region, envelope) in variants { - let der = cms::write_kek_enveloped(&envelope).unwrap(); + let der = cms::write_auth_enveloped(&envelope).unwrap(); let facts = cms::describe(&der).unwrap(); // Each variant is still a well-formed CMS wrap the scheme admits, // so nothing before the decrypt would notice. diff --git a/crates/rite-openssl/src/lib.rs b/crates/rite-openssl/src/lib.rs index a34a2f2..cdacbce 100644 --- a/crates/rite-openssl/src/lib.rs +++ b/crates/rite-openssl/src/lib.rs @@ -35,7 +35,7 @@ mod backend; mod content; -pub use backend::{OpenSslBackend, verify_signature}; +pub use backend::{OpenSslBackend, data_key_for_recipient, verify_signature}; pub use content::{SealedContent, open_content, seal_content}; /// Whether this build can generate ML-KEM keys and wrap to them. diff --git a/crates/rite-openssl/tests/interop.rs b/crates/rite-openssl/tests/interop.rs index 4108296..bd94b8a 100644 --- a/crates/rite-openssl/tests/interop.rs +++ b/crates/rite-openssl/tests/interop.rs @@ -501,7 +501,8 @@ fn the_openssl_cli_opens_content_sealed_to_a_data_key() { .expect("the content pipeline encrypts under the data key"); let kcv = kek.check_value.as_ref().expect("a symmetric key has one"); - let der = rite_sdk::cms::write_kek_enveloped(&rite_sdk::cms::KekEnvelope { + let der = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { + protection: data_key.protection(), key_identifier: kcv.as_bytes().to_vec(), wrapped_cek: data_key.wrapped().to_vec(), nonce: sealed.nonce, @@ -552,8 +553,8 @@ fn the_openssl_cli_opens_content_sealed_to_a_data_key() { assert_eq!(reopened.as_slice(), data_key.plaintext()); assert_eq!(data_key.protection(), rite_sdk::KeyProtection::AesKeyWrap); - // A protection this backend does not implement is refused rather than - // attempted, since one protection's bytes look like another's. + // A protection this key does not take is refused rather than attempted, + // since one protection's bytes look like another's. let refused = backend.open_data_key( &kek.key_id, data_key.wrapped(), @@ -561,8 +562,275 @@ fn the_openssl_cli_opens_content_sealed_to_a_data_key() { ); assert!( refused.is_err(), - "a data key protected another way must not be opened as AES-KW" + "a symmetric KEK must not open a data key declared as RSA-OAEP" ); secret.fill(0); } + +/// Write `bytes` to `name` in `dir` and return the path as a string. +fn write_file(dir: &tempfile::TempDir, name: &str, bytes: &[u8]) -> String { + let path = dir.path().join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_str().unwrap().to_string() +} + +/// The container `encrypt_data` assembles for a public-key recipient, opened by +/// the command line. +/// +/// The CLI is given the recipient's certificate as well as its key, so it +/// selects the `KeyTransRecipientInfo` by the subject key identifier the +/// certificate carries. That is the check that the rid names the key the way a +/// third party computes it, rather than the way Rite happens to. +#[test] +fn the_openssl_cli_opens_content_sealed_to_a_public_key() { + if !openssl_binary_available() { + eprintln!("skipping: openssl binary not available"); + return; + } + + let recipient = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + let spki = recipient.public_key_to_der().unwrap(); + let public = PublicKeyDer::new(spki.clone()).unwrap(); + + let content = b"the custodian's copy of the recovery phrase"; + let mut cek = zeroize::Zeroizing::new(vec![0u8; 32]); + openssl::rand::rand_bytes(&mut cek).unwrap(); + let data_key = rite_openssl::data_key_for_recipient(&public, cek) + .expect("a data key is sealed to an RSA public key"); + let sealed = rite_openssl::seal_content(data_key.plaintext(), content).unwrap(); + let der = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { + protection: data_key.protection(), + key_identifier: openssl::hash::hash(MessageDigest::sha256(), &spki) + .unwrap() + .to_vec(), + wrapped_cek: data_key.wrapped().to_vec(), + nonce: sealed.nonce, + ciphertext: sealed.ciphertext, + tag: sealed.tag, + }) + .expect("the container assembles"); + + let dir = tempfile::tempdir().unwrap(); + let blob = write_file(&dir, "sealed.der", &der); + let key = write_file( + &dir, + "recipient.pem", + &recipient.private_key_to_pem_pkcs8().unwrap(), + ); + let cert = write_file( + &dir, + "recipient.crt", + &build_recipient_cert(&recipient).to_pem().unwrap(), + ); + + let decrypt = Command::new("openssl") + .args([ + "cms", "-decrypt", "-binary", "-inform", "DER", "-in", &blob, "-inkey", &key, "-recip", + &cert, + ]) + .output() + .expect("Failed to spawn openssl cms"); + assert!( + decrypt.status.success(), + "openssl cms -decrypt -recip failed:\nstderr: {}", + String::from_utf8_lossy(&decrypt.stderr), + ); + assert_eq!( + decrypt.stdout, content, + "the command line recovered something other than the content that went in" + ); +} + +/// The other direction: a container the command line sealed to a public key, +/// opened by Rite. +/// +/// The CLI writes RSA key transport as PKCS#1 v1.5 unless told otherwise, so it +/// is told: OAEP, SHA-256 for the digest and for MGF1. The same blob written +/// with the default is refused, by name. +#[test] +fn rite_opens_content_the_openssl_cli_sealed_to_a_public_key() { + if !openssl_binary_available() { + eprintln!("skipping: openssl binary not available"); + return; + } + + let recipient = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + let mut backend = OpenSslBackend::try_new("interop-test").unwrap(); + let held = backend + .import_key( + KeySpec { + algorithm: KeyAlgorithm::Rsa2048, + label: "recipient".to_string(), + policy: KeyPolicy { + usages: rite_sdk::KeyUsages::UNWRAP, + ..KeyPolicy::default() + }, + location_hint: None, + }, + &recipient.private_key_to_pkcs8().unwrap(), + None, + ) + .unwrap(); + + let content = b"a document for someone who is not in the room"; + let dir = tempfile::tempdir().unwrap(); + let plain = write_file(&dir, "content.bin", content); + let cert = write_file( + &dir, + "recipient.crt", + &build_recipient_cert(&recipient).to_pem().unwrap(), + ); + + let seal = |name: &str, keyopts: &[&str]| -> Vec { + let out = dir.path().join(name); + let mut args = vec![ + "cms", + "-encrypt", + "-binary", + "-aes-256-gcm", + "-keyid", + "-outform", + "DER", + "-in", + &plain, + "-out", + out.to_str().unwrap(), + "-recip", + &cert, + ]; + for keyopt in keyopts { + args.extend(["-keyopt", keyopt]); + } + let encrypt = Command::new("openssl") + .args(&args) + .output() + .expect("Failed to spawn openssl cms"); + assert!( + encrypt.status.success(), + "openssl cms -encrypt -recip failed:\nstderr: {}", + String::from_utf8_lossy(&encrypt.stderr), + ); + std::fs::read(&out).unwrap() + }; + + let theirs = seal( + "oaep.der", + &[ + "rsa_padding_mode:oaep", + "rsa_oaep_md:sha256", + "rsa_mgf1_md:sha256", + ], + ); + let envelope = rite_sdk::cms::read_auth_enveloped(&theirs).expect("Rite reads their blob"); + assert_eq!(envelope.protection, rite_sdk::KeyProtection::RsaOaepSha256); + let spki = recipient.public_key_to_der().unwrap(); + assert_eq!( + envelope.key_identifier, + openssl::hash::hash(MessageDigest::sha256(), &spki) + .unwrap() + .to_vec(), + "the CLI takes the rid from the certificate's subject key identifier" + ); + + let data_key = backend + .open_data_key(&held.key_id, &envelope.wrapped_cek, envelope.protection) + .expect("the private key opens the data key the CLI protected"); + let opened = rite_openssl::open_content( + &data_key, + &rite_openssl::SealedContent { + nonce: envelope.nonce, + ciphertext: envelope.ciphertext, + tag: envelope.tag, + }, + ) + .expect("the content authenticates under the recovered key"); + assert_eq!(opened.as_slice(), content); + + let legacy = seal("v15.der", &[]); + let refusal = rite_sdk::cms::read_auth_enveloped(&legacy) + .expect_err("PKCS#1 v1.5 key transport is not opened") + .to_string(); + assert!(refusal.contains("PKCS1-v1_5"), "{refusal}"); +} + +/// A recipient certificate made by the command line with its defaults carries +/// the SHA-1 subject key identifier of RFC 5280 method 1, and a container sealed +/// to it with `-keyid` names the recipient by that. +/// +/// Both the certificate and the container come from the CLI, so this checks +/// Rite's derivation of that identifier against OpenSSL's rather than against +/// itself. +#[test] +fn a_standard_certificate_names_its_key_by_the_rfc_5280_identifier() { + if !openssl_binary_available() { + eprintln!("skipping: openssl binary not available"); + return; + } + + let recipient = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let key = write_file( + &dir, + "recipient.pem", + &recipient.private_key_to_pem_pkcs8().unwrap(), + ); + let cert = dir.path().join("recipient.crt"); + let plain = write_file(&dir, "content.bin", b"a document"); + let blob = dir.path().join("sealed.der"); + + let run = |args: &[&str]| { + let output = Command::new("openssl") + .args(args) + .output() + .expect("Failed to spawn openssl"); + assert!( + output.status.success(), + "openssl {args:?} failed:\nstderr: {}", + String::from_utf8_lossy(&output.stderr), + ); + }; + run(&[ + "req", + "-new", + "-x509", + "-key", + &key, + "-subj", + "/CN=custodian", + "-days", + "1", + "-out", + cert.to_str().unwrap(), + ]); + run(&[ + "cms", + "-encrypt", + "-binary", + "-aes-256-gcm", + "-keyid", + "-outform", + "DER", + "-in", + &plain, + "-out", + blob.to_str().unwrap(), + "-recip", + cert.to_str().unwrap(), + "-keyopt", + "rsa_padding_mode:oaep", + "-keyopt", + "rsa_oaep_md:sha256", + "-keyopt", + "rsa_mgf1_md:sha256", + ]); + + let envelope = rite_sdk::cms::read_auth_enveloped(&std::fs::read(&blob).unwrap()).unwrap(); + let public = PublicKeyDer::new(recipient.public_key_to_der().unwrap()).unwrap(); + assert_eq!( + envelope.key_identifier, + public.rfc5280_key_identifier().unwrap(), + "the rid is the identifier the CLI wrote into the certificate" + ); + assert_eq!(envelope.key_identifier.len(), 20, "a SHA-1 digest"); +} diff --git a/crates/rite-runtime/src/display.rs b/crates/rite-runtime/src/display.rs index 36c83e0..aefca67 100644 --- a/crates/rite-runtime/src/display.rs +++ b/crates/rite-runtime/src/display.rs @@ -95,9 +95,9 @@ pub fn fact_summary(fact: &StepFact) -> Option<(Icon, String)> { StepFact::EntropyDrawn { path, .. } => { Some((Icon::Info, format!("Random value drawn: {path}"))) } - // Who the wrap is addressed to is the operator's last chance to stop a - // wrap they cannot undo, so the fingerprint is shown, not summarised - // away. + // Who a wrap or an encryption is addressed to is the operator's last + // chance to stop one they cannot undo, so the fingerprint is shown, + // not summarised away. StepFact::WrapRecipientRecorded { source, fingerprint, diff --git a/crates/rite-sdk/src/cms.rs b/crates/rite-sdk/src/cms.rs index cdac1c4..846a528 100644 --- a/crates/rite-sdk/src/cms.rs +++ b/crates/rite-sdk/src/cms.rs @@ -12,7 +12,7 @@ //! described by the first of them, and the others are not reported. use crate::der; -use crate::types::{Oid, RecipientInfoKind, WrapDescription}; +use crate::types::{KeyProtection, Oid, RecipientInfoKind, WrapDescription, oid}; /// `id-envelopedData` (RFC 5652 §6). const ENVELOPED_DATA: &str = "1.2.840.113549.1.7.3"; @@ -100,7 +100,21 @@ struct Recipient { fn read_recipient(info: &Tlv<'_>) -> Result { match info.tag { - TAG_SEQUENCE => read_ktri(info.value), + TAG_SEQUENCE => { + let ktri = read_ktri(info.value)?; + let algorithm = algorithm_oid(&ktri.algorithm, "keyEncryptionAlgorithm")?; + // The description names the OAEP identifier and not its + // parameters, so OAEP over SHA-1 would describe exactly as OAEP + // over SHA-256 does, and a record of one would check as the other. + // Only the parameters this crate writes and opens are described. + if algorithm.as_str() == oid::RSAES_OAEP { + require_rsaes_oaep_sha256(&ktri.algorithm)?; + } + Ok(Recipient { + description: WrapDescription::new(RecipientInfoKind::Ktri, algorithm), + key_identifier: ktri.key_identifier.map(<[u8]>::to_vec), + }) + } TAG_CONTEXT_1_CONSTRUCTED => read_kari(info.value), TAG_CONTEXT_2_CONSTRUCTED => { let kekri = read_kekri(info.value)?; @@ -115,24 +129,138 @@ fn read_recipient(info: &Tlv<'_>) -> Result { } } -/// `KeyTransRecipientInfo`: version, rid, keyEncryptionAlgorithm, encryptedKey. -fn read_ktri(body: &[u8]) -> Result { - let (_version, after_version) = read_tlv(body)?; +/// The fields of a `KeyTransRecipientInfo` (RFC 5652 §6.2.1). +struct Ktri<'a> { + /// The subject key identifier, or `None` when the recipient is named by + /// issuer and serial, which identifies a certificate rather than a key. + key_identifier: Option<&'a [u8]>, + algorithm: Tlv<'a>, + encrypted_key: &'a [u8], +} + +/// Walk a `KeyTransRecipientInfo`: version, rid, keyEncryptionAlgorithm, +/// encryptedKey. +/// +/// One walker for describing and opening, for the reason [`read_kekri`] gives. +/// It reads the structure exactly, so a blob `rite verify` describes is one +/// the opener would also read. +fn read_ktri(body: &[u8]) -> Result, CmsReadError> { + let (version, after_version) = read_tlv(body)?; let (rid, after_rid) = read_tlv(after_version)?; - let (algorithm, _) = read_tlv(after_rid)?; + let (algorithm, after_algorithm) = read_tlv(after_rid)?; + let (encrypted_key, after_encrypted_key) = read_tlv(after_algorithm)?; + if !after_encrypted_key.is_empty() { + return Err(err( + "KeyTransRecipientInfo carries bytes after encryptedKey", + )); + } - // rid is either IssuerAndSerialNumber or the implicit [0] subjectKeyIdentifier. - let key_identifier = (rid.tag == TAG_CONTEXT_0_PRIMITIVE).then(|| rid.value.to_vec()); + // rid is either IssuerAndSerialNumber or the implicit [0] + // subjectKeyIdentifier, and RFC 5652 §6.2.1 ties the version to which: + // 0 for the first, 2 for the second. + let key_identifier = (rid.tag == TAG_CONTEXT_0_PRIMITIVE).then_some(rid.value); + let (form, expected) = if key_identifier.is_some() { + ("subject key identifier", 2) + } else { + ("issuer and serial number", 0) + }; + if version.expect_tag(TAG_INTEGER, "KeyTransRecipientInfo.version")? != [expected] { + return Err(err(format!( + "a KeyTransRecipientInfo naming its key by {form} must be version {expected}" + ))); + } - Ok(Recipient { - description: WrapDescription::new( - RecipientInfoKind::Ktri, - algorithm_oid(&algorithm, "keyEncryptionAlgorithm")?, - ), + Ok(Ktri { key_identifier, + algorithm, + encrypted_key: encrypted_key.expect_tag(TAG_OCTET_STRING, "encryptedKey")?, }) } +/// Admit exactly RSAES-OAEP with SHA-256, MGF1 over SHA-256, and the empty +/// label, which is what [`KeyProtection::RsaOaepSha256`] is. +/// +/// Every field of `RSAES-OAEP-params` defaults to SHA-1 (RFC 8017 A.2.1), so +/// an absent field is a different algorithm rather than an unspecified one, +/// and each is checked by what it says rather than by being there. +/// +/// Every element is read exactly, with nothing after it, so two encodings that +/// differ only in trailing bytes are not both admitted as the same algorithm. +fn require_rsaes_oaep_sha256(algorithm: &Tlv<'_>) -> Result<(), CmsReadError> { + let (identifier, parameters) = exact_algorithm_parts(algorithm, "keyEncryptionAlgorithm")?; + match identifier.as_str() { + oid::RSAES_OAEP => {} + oid::RSA_ENCRYPTION => { + // A reader that answers differently to a bad padding and a good + // one is the oracle Bleichenbacher's attack queries, so this one + // does not open the padding at all. + return Err(err( + "the key transport is RSAES-PKCS1-v1_5, which this reader does not open", + )); + } + other => { + return Err(err(format!( + "key transport algorithm {other} is not id-RSAES-OAEP" + ))); + } + } + + let parameters = + parameters.ok_or_else(|| err("RSAES-OAEP without parameters is OAEP over SHA-1"))?; + let fields = parameters.expect_tag(TAG_SEQUENCE, "RSAES-OAEP-params")?; + + let (hash, after_hash) = read_tlv(fields) + .ok() + .filter(|(field, _)| field.tag == TAG_CONTEXT_0_CONSTRUCTED) + .ok_or_else(|| err("RSAES-OAEP-params names no hashFunc, which means SHA-1"))?; + require_sha256(&sole_element(hash.value, "hashFunc")?, "hashFunc")?; + + let (mask, after_mask) = read_tlv(after_hash) + .ok() + .filter(|(field, _)| field.tag == TAG_CONTEXT_1_CONSTRUCTED) + .ok_or_else(|| { + err("RSAES-OAEP-params names no maskGenFunc, which means MGF1 over SHA-1") + })?; + let (mask_function, mask_parameters) = + exact_algorithm_parts(&sole_element(mask.value, "maskGenFunc")?, "maskGenFunc")?; + if mask_function.as_str() != oid::MGF1 { + return Err(err(format!("maskGenFunc {mask_function} is not id-mgf1"))); + } + let mask_hash = + mask_parameters.ok_or_else(|| err("maskGenFunc names MGF1 with no hash function"))?; + require_sha256(&mask_hash, "maskGenFunc")?; + + if !after_mask.is_empty() { + // DER omits a field equal to its DEFAULT, so a pSourceFunc that is + // present names a label other than the empty one. + return Err(err( + "RSAES-OAEP-params carries a pSourceFunc, which names a label this reader does not use", + )); + } + Ok(()) +} + +/// Admit an `AlgorithmIdentifier` naming SHA-256. +/// +/// RFC 4055 §2.1 has a reader accept the parameters absent or NULL, since +/// implementations write both. +fn require_sha256(identifier: &Tlv<'_>, field: &str) -> Result<(), CmsReadError> { + let (digest, parameters) = exact_algorithm_parts(identifier, field)?; + if digest.as_str() != oid::SHA_256 { + return Err(err(format!( + "{field} is {digest}, and this reader opens RSAES-OAEP over SHA-256" + ))); + } + match parameters { + None + | Some(Tlv { + tag: TAG_NULL, + value: [], + }) => Ok(()), + Some(_) => Err(err(format!("{field}: SHA-256 takes no parameters"))), + } +} + /// The fields of a `KEKRecipientInfo` (RFC 5652 §6.2.3). struct Kekri<'a> { key_identifier: &'a [u8], @@ -152,13 +280,24 @@ struct Kekri<'a> { /// algorithm, opening one needs the identifier and the encrypted key, and two /// functions stepping through the same structure would be two places to get /// the field order wrong. +/// +/// Read exactly, as [`read_ktri`] is: version 4, which RFC 5652 §6.2.3 fixes, +/// and nothing after `encryptedKey`. fn read_kekri(body: &[u8]) -> Result, CmsReadError> { - let (_version, after_version) = read_tlv(body)?; + let (version, after_version) = read_tlv(body)?; let (kekid, after_kekid) = read_tlv(after_version)?; let (algorithm, after_algorithm) = read_tlv(after_kekid)?; - let (encrypted_key, _) = read_tlv(after_algorithm)?; + let (encrypted_key, after_encrypted_key) = read_tlv(after_algorithm)?; + if version.expect_tag(TAG_INTEGER, "KEKRecipientInfo.version")? != [4] { + return Err(err("a KEKRecipientInfo must be version 4")); + } + if !after_encrypted_key.is_empty() { + return Err(err("KEKRecipientInfo carries bytes after encryptedKey")); + } - let (key_identifier, _) = read_tlv(kekid.expect_tag(TAG_SEQUENCE, "KEKIdentifier")?)?; + let (key_identifier, after_identifier) = + read_tlv(kekid.expect_tag(TAG_SEQUENCE, "KEKIdentifier")?)?; + kek_identifier_options(after_identifier)?; Ok(Kekri { key_identifier: key_identifier.expect_tag(TAG_OCTET_STRING, "kekid.keyIdentifier")?, algorithm: algorithm_oid(&algorithm, "keyEncryptionAlgorithm")?, @@ -166,6 +305,26 @@ fn read_kekri(body: &[u8]) -> Result, CmsReadError> { }) } +/// What may follow `kekid.keyIdentifier`: a `date` (`GeneralizedTime`), then an +/// `other` (`OtherKeyAttribute`, a SEQUENCE), each optional, and nothing else. +/// +/// Both are legitimate in a container sealed elsewhere and neither changes +/// which key opens it, so they are admitted and not read. +fn kek_identifier_options(mut rest: &[u8]) -> Result<(), CmsReadError> { + for tag in [TAG_GENERALIZED_TIME, TAG_SEQUENCE] { + if let Some(&next) = rest.first() + && next == tag + { + rest = read_tlv(rest)?.1; + } + } + if rest.is_empty() { + Ok(()) + } else { + Err(err("KEKIdentifier carries bytes after its optional fields")) + } +} + /// `KeyAgreeRecipientInfo`: version, [0] originator, [1] ukm OPTIONAL, /// keyEncryptionAlgorithm, recipientEncryptedKeys. /// @@ -263,6 +422,30 @@ fn read_kemri(body: &[u8]) -> Result { }) } +/// The one element `bytes` holds, refusing anything after it. +fn sole_element<'a>(bytes: &'a [u8], field: &str) -> Result, CmsReadError> { + let (element, rest) = read_tlv(bytes)?; + if rest.is_empty() { + Ok(element) + } else { + Err(err(format!("{field} carries bytes after its value"))) + } +} + +/// [`algorithm_parts`], refusing anything after the parameters. +fn exact_algorithm_parts<'a>( + algorithm: &Tlv<'a>, + field: &str, +) -> Result<(Oid, Option>), CmsReadError> { + let body = algorithm.expect_tag(TAG_SEQUENCE, field)?; + let (oid, rest) = read_tlv(body)?; + let oid = oid_of(&oid, field)?; + if rest.is_empty() { + return Ok((oid, None)); + } + Ok((oid, Some(sole_element(rest, field)?))) +} + /// The content cipher, from `EncryptedContentInfo`: contentType, /// contentEncryptionAlgorithm, then the optional encrypted content. fn content_encryption_oid(info: &Tlv<'_>) -> Result { @@ -295,7 +478,9 @@ fn algorithm_parts<'a>( const TAG_INTEGER: u8 = 0x02; const TAG_OCTET_STRING: u8 = 0x04; +const TAG_NULL: u8 = 0x05; const TAG_OID: u8 = 0x06; +const TAG_GENERALIZED_TIME: u8 = 0x18; const TAG_SEQUENCE: u8 = 0x30; const TAG_SET: u8 = 0x31; const TAG_CONTEXT_0_PRIMITIVE: u8 = 0x80; @@ -419,7 +604,13 @@ const GCM_ICV_LENGTHS: std::ops::RangeInclusive = 12..=16; /// blob of ours and a blob of theirs differ in no field. pub const GCM_ICV_LEN: usize = 16; -/// The parts of an `AuthEnvelopedData` addressed to one symmetric key. +/// The parts of an `AuthEnvelopedData` addressed to one recipient. +/// +/// How the content-encryption key was protected decides the `RecipientInfo` +/// form: [`KeyProtection::AesKeyWrap`] writes a `KEKRecipientInfo` for a key +/// both sides hold, and [`KeyProtection::RsaOaepSha256`] a +/// `KeyTransRecipientInfo` for an RSA public key. The content is the same +/// either way. /// /// Carried as a struct rather than assembled in place because the same fields /// are written on the way out and read on the way back, and a field the two @@ -429,11 +620,13 @@ pub const GCM_ICV_LEN: usize = 16; /// them out again, and never holds a key. /// /// ``` -/// use rite_sdk::cms::{self, KekEnvelope}; +/// use rite_sdk::KeyProtection; +/// use rite_sdk::cms::{self, Envelope}; /// /// // Values a backend produces: the CEK wrapped under the KEK, and the /// // content under that CEK. -/// let envelope = KekEnvelope { +/// let envelope = Envelope { +/// protection: KeyProtection::AesKeyWrap, /// key_identifier: vec![0x76, 0x3c, 0xbc], /// wrapped_cek: vec![0x11; 40], /// nonce: vec![0x22; 12], @@ -441,8 +634,8 @@ pub const GCM_ICV_LEN: usize = 16; /// tag: vec![0x44; cms::GCM_ICV_LEN], /// }; /// -/// let der = cms::write_kek_enveloped(&envelope)?; -/// assert_eq!(cms::read_kek_enveloped(&der)?, envelope); +/// let der = cms::write_auth_enveloped(&envelope)?; +/// assert_eq!(cms::read_auth_enveloped(&der)?, envelope); /// /// // And the same bytes describe the wrap that produced them. /// let facts = cms::describe(&der)?; @@ -453,10 +646,15 @@ pub const GCM_ICV_LEN: usize = 16; /// # Ok::<(), rite_sdk::cms::CmsReadError>(()) /// ``` #[derive(Debug, Clone, PartialEq, Eq)] -pub struct KekEnvelope { - /// What names the key-encryption key, going in `kekid.keyIdentifier`. +pub struct Envelope { + /// How `wrapped_cek` was protected, which is also which recipient form + /// carries it. + pub protection: KeyProtection, + /// What names the recipient key. For a symmetric key its check value, in + /// `kekid.keyIdentifier`; for a public key the SHA-256 of its SPKI DER, as + /// the `subjectKeyIdentifier` rid. pub key_identifier: Vec, - /// The content-encryption key, wrapped under the KEK with AES-KW. + /// The content-encryption key, as `protection` left it. pub wrapped_cek: Vec, /// The 12-byte AES-GCM nonce. pub nonce: Vec, @@ -467,48 +665,38 @@ pub struct KekEnvelope { pub tag: Vec, } -/// Write an `AuthEnvelopedData` addressed to one symmetric key. +/// Write an `AuthEnvelopedData` addressed to one recipient. /// -/// RFC 5083 for the structure, RFC 5652 §6.2.3 for the recipient, RFC 5084 for -/// the `GCMParameters` the content algorithm carries. The key-encryption -/// algorithm is `id-aes256-wrap`, matching the 256-bit KEK Rite writes. +/// RFC 5083 for the structure, RFC 5652 §6.2 for the recipient, RFC 5084 for +/// the `GCMParameters` the content algorithm carries. /// /// # Errors /// -/// Returns [`CmsReadError`] if the tag is not a length RFC 5084 admits, or if -/// one of this module's own object identifiers fails to encode, which is -/// unreachable. -pub fn write_kek_enveloped(envelope: &KekEnvelope) -> Result, CmsReadError> { +/// Returns [`CmsReadError`] if the tag is not a length RFC 5084 admits, if the +/// protection is one a `RecipientInfo` cannot name, or if one of this module's +/// own object identifiers fails to encode, which is unreachable. +pub fn write_auth_enveloped(envelope: &Envelope) -> Result, CmsReadError> { if !GCM_ICV_LENGTHS.contains(&envelope.tag.len()) { return Err(err(format!( "a {}-byte authentication tag is not one RFC 5084 admits", envelope.tag.len() ))); } - let oid = |dotted: &str| { - crate::der::object_identifier(dotted) - .ok_or_else(|| err(format!("{dotted} is not an object identifier"))) - }; - - // KEKRecipientInfo, tagged [2] in the RecipientInfo CHOICE. Version is - // always 4 (RFC 5652 §6.2.3). - let kekri = der::tlv( - TAG_CONTEXT_2_CONSTRUCTED, - &[ - der::small_integer(4), - der::sequence(&[der::octet_string(&envelope.key_identifier)]), - der::sequence(&[oid(crate::types::oid::AES_256_WRAP)?]), - der::octet_string(&envelope.wrapped_cek), - ] - .concat(), - ); + assemble_auth_enveloped(envelope, recipient_info(envelope)?) +} +/// The `ContentInfo` around one encoded `RecipientInfo` and the envelope's +/// content. +fn assemble_auth_enveloped( + envelope: &Envelope, + recipient: Vec, +) -> Result, CmsReadError> { // EncryptedContentInfo. The content is implicit [0], not an OCTET STRING, // which is the tag a reader of this structure has to expect. let content = der::sequence(&[ - oid(DATA)?, + encoded_oid(DATA)?, der::sequence(&[ - oid(crate::types::oid::AES_256_GCM)?, + encoded_oid(oid::AES_256_GCM)?, der::sequence(&[ der::octet_string(&envelope.nonce), der::small_integer(u8::try_from(envelope.tag.len()).unwrap_or(0)), @@ -522,22 +710,80 @@ pub fn write_kek_enveloped(envelope: &KekEnvelope) -> Result, CmsReadErr // RFC 5083 requires a higher one for. let auth_enveloped = der::sequence(&[ der::small_integer(0), - der::set_of(&[kekri]), + der::set_of(&[recipient]), content, der::octet_string(&envelope.tag), ]); Ok(der::sequence(&[ - oid(AUTH_ENVELOPED_DATA)?, + encoded_oid(AUTH_ENVELOPED_DATA)?, der::tlv(TAG_CONTEXT_0_CONSTRUCTED, &auth_enveloped), ])) } -/// Read back what [`write_kek_enveloped`] wrote. +/// The one `RecipientInfo` an envelope carries, in the form its protection +/// takes. +fn recipient_info(envelope: &Envelope) -> Result, CmsReadError> { + match envelope.protection { + // KEKRecipientInfo, tagged [2] in the RecipientInfo CHOICE. Version is + // always 4 (RFC 5652 §6.2.3). The algorithm is `id-aes256-wrap`, + // matching the 256-bit KEK Rite writes. + KeyProtection::AesKeyWrap => Ok(der::tlv( + TAG_CONTEXT_2_CONSTRUCTED, + &[ + der::small_integer(4), + der::sequence(&[der::octet_string(&envelope.key_identifier)]), + der::sequence(&[encoded_oid(oid::AES_256_WRAP)?]), + der::octet_string(&envelope.wrapped_cek), + ] + .concat(), + )), + // KeyTransRecipientInfo, the untagged SEQUENCE in the CHOICE. Version + // 2 because the rid is a subjectKeyIdentifier (RFC 5652 §6.2.1), which + // names the key itself, so no certificate is needed to address it. + KeyProtection::RsaOaepSha256 => Ok(der::sequence(&[ + der::small_integer(2), + der::tlv(TAG_CONTEXT_0_PRIMITIVE, &envelope.key_identifier), + rsaes_oaep_sha256()?, + der::octet_string(&envelope.wrapped_cek), + ])), + KeyProtection::ProviderDefined => Err(err( + "a data key in its provider's own envelope names no algorithm a RecipientInfo \ + can declare", + )), + } +} + +/// `id-RSAES-OAEP` with SHA-256 for the digest and for MGF1, and the empty +/// label. /// -/// Accepts an `AuthEnvelopedData` whose single recipient is a -/// `KEKRecipientInfo` under `id-aes256-wrap` with AES-256-GCM content and no -/// `authAttrs`, which is what the OpenSSL CLI writes for the same inputs. +/// RFC 4055 §4.1, encoded as OpenSSL writes it: the SHA-256 identifiers carry +/// no parameters, and `pSourceFunc` is omitted because it equals its DEFAULT. +fn rsaes_oaep_sha256() -> Result, CmsReadError> { + let sha256 = der::sequence(&[encoded_oid(oid::SHA_256)?]); + let mgf1 = der::sequence(&[encoded_oid(oid::MGF1)?, sha256.clone()]); + Ok(der::sequence(&[ + encoded_oid(oid::RSAES_OAEP)?, + der::sequence(&[ + der::tlv(TAG_CONTEXT_0_CONSTRUCTED, &sha256), + der::tlv(TAG_CONTEXT_1_CONSTRUCTED, &mgf1), + ]), + ])) +} + +/// One of this module's object identifiers, encoded. +fn encoded_oid(dotted: &str) -> Result, CmsReadError> { + der::object_identifier(dotted) + .ok_or_else(|| err(format!("{dotted} is not an object identifier"))) +} + +/// Read back what [`write_auth_enveloped`] wrote. +/// +/// Accepts an `AuthEnvelopedData` with AES-256-GCM content, no `authAttrs`, +/// and a single recipient that is either a `KEKRecipientInfo` under +/// `id-aes256-wrap` or a `KeyTransRecipientInfo` under RSAES-OAEP over SHA-256 +/// that names its key by subject key identifier. That is what the OpenSSL CLI +/// writes for the same inputs. /// /// `authAttrs` is refused rather than skipped. RFC 5083 makes it the AEAD's /// additional authenticated data, so a blob carrying it authenticates bytes @@ -550,7 +796,7 @@ pub fn write_kek_enveloped(envelope: &KekEnvelope) -> Result, CmsReadErr /// Returns [`CmsReadError`] if the bytes are not that structure. The /// algorithms are checked here rather than assumed, because the caller is /// about to decrypt with whatever this returns. -pub fn read_kek_enveloped(der_bytes: &[u8]) -> Result { +pub fn read_auth_enveloped(der_bytes: &[u8]) -> Result { let (content_info, rest) = read_tlv(der_bytes)?; if !rest.is_empty() { return Err(err("trailing bytes after ContentInfo")); @@ -584,16 +830,7 @@ pub fn read_kek_enveloped(der_bytes: &[u8]) -> Result // silently taking the first is how a blob opens with the wrong one. return Err(err("more than one recipient")); } - let kekri = read_kekri(recipient.expect_tag(TAG_CONTEXT_2_CONSTRUCTED, "KEKRecipientInfo")?)?; - if kekri.algorithm.as_str() != crate::types::oid::AES_256_WRAP { - // Checked here rather than in the walker, because describing a foreign - // blob should report the algorithm it names, and only opening one has - // to insist on the algorithm this crate can run. - return Err(err(format!( - "key encryption algorithm {} is not id-aes256-wrap", - kekri.algorithm - ))); - } + let (protection, key_identifier, wrapped_cek) = openable_recipient(&recipient)?; let (content_info, after_content) = read_tlv(after_recipients)?; let (nonce, icv_len, ciphertext) = @@ -622,15 +859,71 @@ pub fn read_kek_enveloped(der_bytes: &[u8]) -> Result ))); } - Ok(KekEnvelope { - key_identifier: kekri.key_identifier.to_vec(), - wrapped_cek: kekri.encrypted_key.to_vec(), + Ok(Envelope { + protection, + key_identifier: key_identifier.to_vec(), + wrapped_cek: wrapped_cek.to_vec(), nonce, ciphertext, tag: tag.to_vec(), }) } +/// How a recipient's CEK is protected, what names the recipient, and the +/// protected CEK, for a recipient this crate can open. +/// +/// The algorithms are checked here rather than in the walkers, because +/// describing a foreign blob should report the algorithm it names, and only +/// opening one has to insist on the algorithm this crate can run. The +/// exception is RSAES-OAEP, whose parameters [`describe`] checks too, since +/// the description has no field for them. +fn openable_recipient<'a>( + recipient: &Tlv<'a>, +) -> Result<(KeyProtection, &'a [u8], &'a [u8]), CmsReadError> { + match recipient.tag { + TAG_CONTEXT_2_CONSTRUCTED => { + let kekri = read_kekri(recipient.value)?; + if kekri.algorithm.as_str() != oid::AES_256_WRAP { + return Err(err(format!( + "key encryption algorithm {} is not id-aes256-wrap", + kekri.algorithm + ))); + } + Ok(( + KeyProtection::AesKeyWrap, + kekri.key_identifier, + kekri.encrypted_key, + )) + } + TAG_SEQUENCE => { + let ktri = read_ktri(recipient.value)?; + require_rsaes_oaep_sha256(&ktri.algorithm)?; + // A key is matched by an identifier derived from it, and there is + // no certificate here to match an issuer and serial against. + let key_identifier = ktri.key_identifier.ok_or_else(|| { + err( + "the recipient is named by issuer and serial number, which identifies a \ + certificate rather than a key", + ) + })?; + Ok(( + KeyProtection::RsaOaepSha256, + key_identifier, + ktri.encrypted_key, + )) + } + TAG_CONTEXT_1_CONSTRUCTED => Err(err( + "the recipient is a KeyAgreeRecipientInfo, which this reader does not open", + )), + TAG_CONTEXT_4_CONSTRUCTED => Err(err( + "the recipient is an OtherRecipientInfo, which this reader does not open", + )), + other => Err(err(format!( + "RecipientInfo tag 0x{other:02x} is not a recipient this reader opens" + ))), + } +} + /// The nonce, the declared tag length, and the ciphertext out of an AES-GCM /// `EncryptedContentInfo`. fn read_gcm_content(body: &[u8]) -> Result<(Vec, usize, Vec), CmsReadError> { @@ -638,7 +931,7 @@ fn read_gcm_content(body: &[u8]) -> Result<(Vec, usize, Vec), CmsReadErr let (algorithm, after_algorithm) = read_tlv(after_type)?; let (algorithm_body, parameters) = algorithm_parts(&algorithm, "contentEncryptionAlgorithm")?; - if algorithm_body.as_str() != crate::types::oid::AES_256_GCM { + if algorithm_body.as_str() != oid::AES_256_GCM { return Err(err(format!( "content encryption algorithm {algorithm_body} is not id-aes256-GCM" ))); @@ -741,8 +1034,9 @@ mod tests { "LrY4OxUk660cu1lXeCSFOSOpOJ7FuVyU=", ); - fn sample_envelope() -> KekEnvelope { - KekEnvelope { + fn sample_envelope() -> Envelope { + Envelope { + protection: KeyProtection::AesKeyWrap, key_identifier: vec![0x76, 0x3c, 0xbc], wrapped_cek: vec![0x11; 40], nonce: vec![0x22; 12], @@ -755,15 +1049,15 @@ mod tests { #[test] fn a_kek_envelope_round_trips() { let envelope = sample_envelope(); - let der = write_kek_enveloped(&envelope).unwrap(); - assert_eq!(read_kek_enveloped(&der).unwrap(), envelope); + let der = write_auth_enveloped(&envelope).unwrap(); + assert_eq!(read_auth_enveloped(&der).unwrap(), envelope); } /// And `describe` reads the same bytes as a wrap, which is what puts the /// algorithms in the transcript and in front of `rite verify`. #[test] fn a_kek_envelope_describes_itself() { - let der = write_kek_enveloped(&sample_envelope()).unwrap(); + let der = write_auth_enveloped(&sample_envelope()).unwrap(); let facts = describe(&der).unwrap(); assert_eq!( @@ -793,7 +1087,7 @@ mod tests { /// not key-specific: what it carries is bytes. #[test] fn a_kek_envelope_carries_bytes_under_the_generic_content_type() { - let der = write_kek_enveloped(&sample_envelope()).unwrap(); + let der = write_auth_enveloped(&sample_envelope()).unwrap(); let data_oid = crate::der::object_identifier(DATA).unwrap(); assert!( der.windows(data_oid.len()).any(|w| w == data_oid), @@ -807,13 +1101,13 @@ mod tests { #[test] fn a_kek_envelope_round_trips_across_the_length_boundary() { for size in [0, 1, 127, 128, 129, 255, 256, 1024, 65_536] { - let envelope = KekEnvelope { + let envelope = Envelope { ciphertext: vec![0x5a; size], ..sample_envelope() }; - let der = write_kek_enveloped(&envelope).unwrap(); + let der = write_auth_enveloped(&envelope).unwrap(); assert_eq!( - read_kek_enveloped(&der).unwrap(), + read_auth_enveloped(&der).unwrap(), envelope, "a {size}-byte content did not survive the round trip" ); @@ -825,45 +1119,310 @@ mod tests { #[test] fn a_kek_envelope_carries_the_tag_length_it_declares() { for length in [12usize, 13, 14, 15, 16] { - let envelope = KekEnvelope { + let envelope = Envelope { tag: vec![0x44; length], ..sample_envelope() }; - let der = write_kek_enveloped(&envelope).unwrap(); - assert_eq!(read_kek_enveloped(&der).unwrap().tag.len(), length); + let der = write_auth_enveloped(&envelope).unwrap(); + assert_eq!(read_auth_enveloped(&der).unwrap().tag.len(), length); } for length in [0usize, 8, 11, 17] { - let envelope = KekEnvelope { + let envelope = Envelope { tag: vec![0x44; length], ..sample_envelope() }; assert!( - write_kek_enveloped(&envelope).is_err(), + write_auth_enveloped(&envelope).is_err(), "a {length}-byte tag is not one RFC 5084 admits" ); } } + /// An envelope whose CEK went to an RSA public key, named by the SHA-256 + /// of its SPKI. + fn key_transport_envelope() -> Envelope { + Envelope { + protection: KeyProtection::RsaOaepSha256, + key_identifier: vec![0x89; 32], + wrapped_cek: vec![0x11; 256], + ..sample_envelope() + } + } + + #[test] + fn a_key_transport_envelope_round_trips() { + let envelope = key_transport_envelope(); + let der = write_auth_enveloped(&envelope).unwrap(); + assert_eq!(read_auth_enveloped(&der).unwrap(), envelope); + } + + /// `describe` reports the form and names the key the way `rite verify` + /// matches it, so a ktri is checked on the terms a kekri is. + #[test] + fn a_key_transport_envelope_describes_itself() { + let der = write_auth_enveloped(&key_transport_envelope()).unwrap(); + let facts = describe(&der).unwrap(); + + assert_eq!( + facts.description.recipient_info, + Some(RecipientInfoKind::Ktri) + ); + assert_eq!( + facts.description.key_encryption_oid.as_str(), + oid::RSAES_OAEP + ); + assert_eq!(facts.description.kek_wrap_oid, None); + assert_eq!(facts.recipient_key_identifier, Some(vec![0x89; 32])); + assert!(crate::types::WrapScheme::CmsAes256Gcm.permits(&facts.description)); + } + + /// The `AlgorithmIdentifier` OpenSSL 4.0.3 writes for `openssl cms + /// -encrypt -keyopt rsa_padding_mode:oaep -keyopt rsa_oaep_md:sha256 + /// -keyopt rsa_mgf1_md:sha256`, recorded from its output. + /// + /// The parameters admit more than one encoding, since a SHA-256 identifier + /// may carry NULL or nothing, and writing the same bytes as the most + /// widely deployed reader leaves no room for one that is strict about it. + const OPENSSL_RSAES_OAEP_SHA256: [u8; 58] = [ + // AlgorithmIdentifier { id-RSAES-OAEP, RSAES-OAEP-params { + 0x30, 0x38, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x07, 0x30, 0x2b, + // [0] hashFunc { id-sha256 } + 0xa0, 0x0d, 0x30, 0x0b, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, + // [1] maskGenFunc { id-mgf1, { id-sha256 } } } } + 0xa1, 0x1a, 0x30, 0x18, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x08, + 0x30, 0x0b, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, + ]; + + #[test] + fn writes_the_oaep_parameters_openssl_writes() { + assert_eq!(rsaes_oaep_sha256().unwrap(), OPENSSL_RSAES_OAEP_SHA256); + } + + /// A ktri with `algorithm` in place of the one Rite writes, otherwise + /// well-formed. + fn with_key_transport_algorithm(algorithm: Vec) -> Vec { + let envelope = key_transport_envelope(); + let ktri = der::sequence(&[ + der::small_integer(2), + der::tlv(TAG_CONTEXT_0_PRIMITIVE, &envelope.key_identifier), + algorithm, + der::octet_string(&envelope.wrapped_cek), + ]); + assemble_auth_enveloped(&envelope, ktri).unwrap() + } + + /// Every field of `RSAES-OAEP-params` defaults to SHA-1, so each one is + /// read for what it says, and only OAEP over SHA-256 throughout opens. + #[test] + fn opening_admits_only_oaep_over_sha256() { + let oid = |dotted: &str| der::object_identifier(dotted).unwrap(); + let sha1 = der::sequence(&[oid("1.3.14.3.2.26")]); + let sha256 = der::sequence(&[oid(super::oid::SHA_256)]); + let sha256_null = der::sequence(&[oid(super::oid::SHA_256), vec![TAG_NULL, 0x00]]); + let mgf1 = |hash: &[u8]| { + der::tlv( + TAG_CONTEXT_1_CONSTRUCTED, + &der::sequence(&[oid(super::oid::MGF1), hash.to_vec()]), + ) + }; + let oaep = |fields: &[Vec]| { + der::sequence(&[oid(super::oid::RSAES_OAEP), der::sequence(fields)]) + }; + let hash = |identifier: &[u8]| der::tlv(TAG_CONTEXT_0_CONSTRUCTED, identifier); + + // A NULL parameter on SHA-256 is the other encoding RFC 4055 admits. + let accepted = oaep(&[hash(&sha256_null), mgf1(&sha256_null)]); + assert_eq!( + read_auth_enveloped(&with_key_transport_algorithm(accepted)) + .unwrap() + .protection, + KeyProtection::RsaOaepSha256 + ); + + let refused = [ + ( + "PKCS#1 v1.5", + der::sequence(&[oid(super::oid::RSA_ENCRYPTION), vec![TAG_NULL, 0x00]]), + "PKCS1-v1_5", + ), + ( + "OAEP with no parameters", + der::sequence(&[oid(super::oid::RSAES_OAEP)]), + "SHA-1", + ), + ( + "OAEP over SHA-1", + oaep(&[hash(&sha1), mgf1(&sha256)]), + "hashFunc", + ), + ("no hashFunc", oaep(&[mgf1(&sha256)]), "hashFunc"), + ( + "MGF1 over SHA-1", + oaep(&[hash(&sha256), mgf1(&sha1)]), + "maskGenFunc", + ), + ("no maskGenFunc", oaep(&[hash(&sha256)]), "maskGenFunc"), + ( + "a label", + oaep(&[ + hash(&sha256), + mgf1(&sha256), + der::tlv( + TAG_CONTEXT_2_CONSTRUCTED, + &der::sequence(&[oid("1.2.840.113549.1.1.9"), der::octet_string(b"label")]), + ), + ]), + "pSourceFunc", + ), + ]; + for (case, algorithm, named) in refused { + let der = with_key_transport_algorithm(algorithm); + let error = read_auth_enveloped(&der).unwrap_err().to_string(); + assert!(error.contains(named), "{case}: {error}"); + // Describing reports the algorithm a foreign blob names, so + // PKCS#1 v1.5 still describes. OAEP with other parameters does + // not, because its description would be the same as OAEP over + // SHA-256 and a verifier comparing the two would pass it. + let described = describe(&der); + if case == "PKCS#1 v1.5" { + assert!(described.is_ok(), "{case}: describe should still read it"); + } else { + let error = described.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(error.contains(named), "{case}: describe gave {error:?}"); + } + } + } + + /// A `KEKRecipientInfo` is read as exactly as a `KeyTransRecipientInfo`: + /// version 4, nothing after `encryptedKey`, and in `kekid` only the two + /// optional fields RFC 5652 defines. + #[test] + fn a_kek_recipient_is_read_exactly() { + let envelope = sample_envelope(); + let kekri = |version: u8, kekid_extra: &[Vec], trailing: &[u8]| { + let mut kekid = vec![der::octet_string(&envelope.key_identifier)]; + kekid.extend_from_slice(kekid_extra); + let mut body = [ + der::small_integer(version), + der::sequence(&kekid), + der::sequence(&[der::object_identifier(super::oid::AES_256_WRAP).unwrap()]), + der::octet_string(&envelope.wrapped_cek), + ] + .concat(); + body.extend_from_slice(trailing); + assemble_auth_enveloped(&envelope, der::tlv(TAG_CONTEXT_2_CONSTRUCTED, &body)).unwrap() + }; + let date = der::tlv(TAG_GENERALIZED_TIME, b"20261026120000Z"); + + assert!(read_auth_enveloped(&kekri(4, &[], &[])).is_ok()); + // A date in kekid names when the key was made, not which key it is. + assert!(read_auth_enveloped(&kekri(4, &[date], &[])).is_ok()); + + for (der, named) in [ + (kekri(0, &[], &[]), "version 4"), + (kekri(4, &[], &[0x05, 0x00]), "after encryptedKey"), + ( + kekri(4, &[vec![0x05, 0x00]], &[]), + "after its optional fields", + ), + ] { + let error = read_auth_enveloped(&der).unwrap_err().to_string(); + assert!(error.contains(named), "{error}"); + let error = describe(&der) + .err() + .map(|e| e.to_string()) + .unwrap_or_default(); + assert!(error.contains(named), "describe gave {error:?}"); + } + } + + /// Opening reads the structure exactly: two encodings that differ only in + /// a field DER fixes are not both admitted as the same container. + #[test] + fn opening_refuses_a_key_transport_recipient_that_is_not_der() { + let envelope = key_transport_envelope(); + let ktri = |version: u8, algorithm: Vec, trailing: &[u8]| { + let mut body = [ + der::small_integer(version), + der::tlv(TAG_CONTEXT_0_PRIMITIVE, &envelope.key_identifier), + algorithm, + der::octet_string(&envelope.wrapped_cek), + ] + .concat(); + body.extend_from_slice(trailing); + assemble_auth_enveloped(&envelope, der::tlv(TAG_SEQUENCE, &body)).unwrap() + }; + let oaep = rsaes_oaep_sha256().unwrap(); + + assert!(read_auth_enveloped(&ktri(2, oaep.clone(), &[])).is_ok()); + + // The structure is checked by the walker both readers share, so + // describing refuses what opening refuses. + for (der, named) in [ + (ktri(0, oaep.clone(), &[]), "version 2"), + (ktri(2, oaep.clone(), &[0x05, 0x00]), "after encryptedKey"), + ] { + let error = read_auth_enveloped(&der).unwrap_err().to_string(); + assert!(error.contains(named), "{error}"); + let error = describe(&der) + .err() + .map(|e| e.to_string()) + .unwrap_or_default(); + assert!(error.contains(named), "describe gave {error:?}"); + } + + // A NULL slipped in after the SHA-256 identifier inside hashFunc. + let oid = |dotted: &str| der::object_identifier(dotted).unwrap(); + let sha256 = der::sequence(&[oid(super::oid::SHA_256)]); + let padded_hash = der::tlv( + TAG_CONTEXT_0_CONSTRUCTED, + &[sha256.clone(), vec![TAG_NULL, 0x00]].concat(), + ); + let mgf1 = der::tlv( + TAG_CONTEXT_1_CONSTRUCTED, + &der::sequence(&[oid(super::oid::MGF1), sha256]), + ); + let algorithm = der::sequence(&[ + oid(super::oid::RSAES_OAEP), + der::sequence(&[padded_hash, mgf1]), + ]); + let error = read_auth_enveloped(&ktri(2, algorithm, &[])) + .unwrap_err() + .to_string(); + assert!(error.contains("hashFunc"), "{error}"); + } + + /// A data key in its provider's own envelope names no algorithm, so no + /// `RecipientInfo` can carry it. + #[test] + fn writing_refuses_a_provider_defined_protection() { + let envelope = Envelope { + protection: KeyProtection::ProviderDefined, + ..sample_envelope() + }; + assert!(write_auth_enveloped(&envelope).is_err()); + } + /// Reading is the half that takes untrusted bytes, so it refuses what it /// cannot vouch for rather than guessing. #[test] fn reading_refuses_what_is_not_this_structure() { - let der = write_kek_enveloped(&sample_envelope()).unwrap(); + let der = write_auth_enveloped(&sample_envelope()).unwrap(); - assert!(read_kek_enveloped(&[]).is_err(), "empty input"); + assert!(read_auth_enveloped(&[]).is_err(), "empty input"); assert!( - read_kek_enveloped(&der[..der.len() - 1]).is_err(), + read_auth_enveloped(&der[..der.len() - 1]).is_err(), "truncated input" ); let mut trailing = der.clone(); trailing.push(0x00); - assert!(read_kek_enveloped(&trailing).is_err(), "trailing bytes"); + assert!(read_auth_enveloped(&trailing).is_err(), "trailing bytes"); // The RFC 4134 example is a real CMS artifact of a different shape. let other = base64ct::Base64::decode_vec(RFC4134_ENVELOPED_DATA).unwrap(); assert!( - read_kek_enveloped(&other).is_err(), - "an EnvelopedData with a key transport recipient is not this" + read_auth_enveloped(&other).is_err(), + "an EnvelopedData under CBC with a PKCS#1 v1.5 recipient is not this" ); } @@ -980,7 +1539,7 @@ mod tests { )) .unwrap(); - let error = read_kek_enveloped(&der).unwrap_err(); + let error = read_auth_enveloped(&der).unwrap_err(); assert!( error.to_string().contains("authAttrs"), "the refusal should name the field: {error}" diff --git a/crates/rite-sdk/src/key_material.rs b/crates/rite-sdk/src/key_material.rs index f51f66f..3e88371 100644 --- a/crates/rite-sdk/src/key_material.rs +++ b/crates/rite-sdk/src/key_material.rs @@ -24,6 +24,7 @@ use x509_cert::der::oid::db::rfc5912::{ }; use x509_cert::der::oid::db::rfc8410::ID_ED_25519; use x509_cert::der::{Decode, Encode, Reader, SliceReader}; +use x509_cert::ext::pkix::SubjectKeyIdentifier; use x509_cert::spki::SubjectPublicKeyInfoRef; use crate::backend::BackendError; @@ -104,6 +105,27 @@ impl PublicKeyDer { &self.0 } + /// The key identifier X.509 tooling derives for this key: the SHA-1 of the + /// `subjectPublicKey` bits, RFC 5280 §4.2.1.2 method 1. + /// + /// What a certificate's subject key identifier usually holds, and so what + /// `openssl cms -encrypt -keyid` names a recipient by. Rite's own name for a + /// key is the SHA-256 of the whole SPKI. This one is for recognising a key + /// in a structure someone else wrote. + /// + /// # Errors + /// + /// Returns [`BackendError::InvalidKeyFormat`] if the SPKI cannot be read, + /// which every constructor already rules out. + pub fn rfc5280_key_identifier(&self) -> Result, BackendError> { + let spki = SubjectPublicKeyInfoRef::from_der(&self.0) + .map_err(|e| BackendError::InvalidKeyFormat(format!("SPKI is unreadable: {e}")))?; + let identifier = SubjectKeyIdentifier::try_from(spki).map_err(|e| { + BackendError::InvalidKeyFormat(format!("key identifier cannot be derived: {e}")) + })?; + Ok(identifier.0.as_bytes().to_vec()) + } + /// The key algorithm, read from the key's own structure. /// /// Key bytes carry no ceremony metadata, so a caller that must know what it @@ -328,6 +350,26 @@ mod tests { pub const CERTIFICATE: &[u8] = include_bytes!("../tests/vectors/p256.cert.der"); } + /// The identifier OpenSSL wrote into the vector certificate's subject key + /// identifier extension, read back with `openssl x509 -ext + /// subjectKeyIdentifier`, is the one derived from the key it certifies. + #[test] + fn derives_the_key_identifier_openssl_writes() { + let certificate = CertificateDer::new(vectors::CERTIFICATE.to_vec()).unwrap(); + let identifier = certificate + .public_key() + .unwrap() + .rfc5280_key_identifier() + .unwrap(); + assert_eq!( + identifier, + [ + 0x6f, 0x60, 0xee, 0x1f, 0xc5, 0x1e, 0x6e, 0xce, 0xad, 0xba, 0x96, 0xa2, 0x3d, 0xa0, + 0x40, 0xaf, 0x22, 0x0a, 0x09, 0xa6 + ] + ); + } + #[test] fn reads_the_algorithm_out_of_each_key_type() { let cases = [ diff --git a/crates/rite-sdk/src/types.rs b/crates/rite-sdk/src/types.rs index 83cd89f..2bc6949 100644 --- a/crates/rite-sdk/src/types.rs +++ b/crates/rite-sdk/src/types.rs @@ -716,6 +716,10 @@ pub mod oid { pub const RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.1"; /// `id-RSAES-OAEP` (RFC 8017). pub const RSAES_OAEP: &str = "1.2.840.113549.1.1.7"; + /// `id-mgf1`, the mask generation function OAEP parameters name (RFC 8017). + pub const MGF1: &str = "1.2.840.113549.1.1.8"; + /// `id-sha256` (RFC 5754). + pub const SHA_256: &str = "2.16.840.1.101.3.4.2.1"; /// `dhSinglePass-stdDH-sha1kdf-scheme`, one-pass ECDH with the X9.63 KDF /// over SHA-1 (RFC 5753). pub const DH_SINGLE_PASS_STDDH_SHA1KDF: &str = "1.3.133.16.840.63.0.2"; diff --git a/crates/rite-stdlib/src/crypto/content.rs b/crates/rite-stdlib/src/crypto/content.rs index 595c5c0..83884f1 100644 --- a/crates/rite-stdlib/src/crypto/content.rs +++ b/crates/rite-stdlib/src/crypto/content.rs @@ -9,7 +9,7 @@ //! Actions call [`seal`] and [`open`] rather than `rite_openssl`, so swapping //! the provider is a change to this file and nothing else. -use rite_sdk::BackendError; +use rite_sdk::{BackendError, DataKey, PublicKeyDer}; use zeroize::Zeroizing; /// Content and the values a container carries beside it. @@ -18,6 +18,10 @@ use zeroize::Zeroizing; /// claim above holds: an action names this module and never `rite_openssl`. pub use rite_openssl::SealedContent; +/// The length of a content-encryption key: AES-256-GCM, the one content +/// cipher. +pub const KEY_BYTES: usize = 32; + /// Encrypt `payload` under a content-encryption key, with a fresh nonce. /// /// # Errors @@ -36,3 +40,20 @@ pub fn seal(cek: &[u8], payload: &[u8]) -> Result { pub fn open(cek: &[u8], sealed: &SealedContent) -> Result>, BackendError> { rite_openssl::open_content(cek, sealed) } + +/// Protect a content-encryption key for the holder of `recipient`. +/// +/// No backend takes part: encrypting to a public key uses no secret, so the +/// step's backend has nothing to contribute beyond the key's random bytes, +/// which the caller draws from it. +/// +/// # Errors +/// +/// Returns [`BackendError`] if the recipient is not a key a container can +/// address. +pub fn protect_for( + recipient: &PublicKeyDer, + cek: Zeroizing>, +) -> Result { + rite_openssl::data_key_for_recipient(recipient, cek) +} diff --git a/crates/rite-stdlib/src/crypto/decrypt_data.rs b/crates/rite-stdlib/src/crypto/decrypt_data.rs index 67f0768..49e4dbe 100644 --- a/crates/rite-stdlib/src/crypto/decrypt_data.rs +++ b/crates/rite-stdlib/src/crypto/decrypt_data.rs @@ -2,16 +2,18 @@ use rite_model::ActionType; use rite_runtime::{ - Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, - compute_fingerprint, resolve_backend_key, + Action, ActionError, ArtifactValue, BackendKeyMeta, HandlerContext, Icon, Reporter, StepInfo, + StepResult, compute_fingerprint, resolve_backend_key, }; -use rite_sdk::{Backend, KeyCheckValue, KeyProtection, cms}; +use rite_sdk::{Backend, EncryptedData, KeyCheckValue, KeyProtection, WrapScheme, cms}; use secrecy::SecretBox; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; use crate::crypto::content::{self, SealedContent}; -/// Open a container `encrypt_data` produced, back to bytes. +/// Open a container `encrypt_data` produced, or one supplied as a material, +/// back to bytes. /// /// The container is undone in the order it was built: the backend opens the /// data key it protected, and the content is decrypted under that. What comes @@ -43,12 +45,27 @@ impl Action for DecryptDataAction { let artifact = ctx.artifacts.get(&data_id).ok_or_else(|| { ActionError::Failed(format!("Encrypted data artifact '{data_id}' not found")) })?; - let ArtifactValue::EncryptedData(encrypted) = artifact else { - return Err(ActionError::Failed(format!( - "Artifact '{data_id}' is not encrypted content. A wrapped key is opened with \ - 'unwrap_key', which installs what comes out rather than handing it over as \ - bytes." - ))); + let from_material; + let encrypted = match artifact { + ArtifactValue::EncryptedData(encrypted) => encrypted, + // A container sealed outside the ceremony, with `openssl cms + // -encrypt` for instance, arrives as a material's bytes. It is + // described from those bytes, as one this ceremony produced was. + ArtifactValue::Bytes(bytes) => { + from_material = container_from_material(&data_id, bytes)?; + &from_material + } + ArtifactValue::WrappedKey(_) => { + return Err(ActionError::Failed(format!( + "Artifact '{data_id}' is a wrapped key, which is opened with 'unwrap_key': \ + it installs what comes out rather than handing it over as bytes." + ))); + } + _ => { + return Err(ActionError::Failed(format!( + "Artifact '{data_id}' is not encrypted content" + ))); + } }; let scheme = encrypted.scheme(); let data_fingerprint = compute_fingerprint(encrypted.data()); @@ -60,30 +77,21 @@ impl Action for DecryptDataAction { key_ref.display_name() )) })?; - let check_value = key.check_value.cloned().ok_or_else(|| { - ActionError::Failed(format!( - "'{}' is a {} key, and this container is addressed to a symmetric one", - key_ref.display_name(), - key.algorithm - )) - })?; let key_backend = key.backend_name.to_string(); let backend_key_id = key.key_id.clone(); - let envelope = cms::read_kek_enveloped(encrypted.data()) + let envelope = cms::read_auth_enveloped(encrypted.data()) .map_err(|e| ActionError::Failed(format!("Cannot read the container: {e}")))?; + let own = KeyName::of(&key, envelope.protection, &key_ref.display_name())?; // Checked before any decrypt, because the content tag authenticates the // content and nothing around it. A blob addressed elsewhere would // otherwise fail as an unauthenticated tag, which says nothing about // why. - if envelope.key_identifier != check_value.as_bytes() { - return Err(ActionError::Failed(format!( - "This artifact is addressed to the key with check value {}, and '{}' has {}", - named_recipient(&envelope.key_identifier), - key_ref.display_name(), - check_value - ))); + if !own.answers_to(&envelope.key_identifier) { + return Err(ActionError::Failed( + own.refusal(&envelope.key_identifier, &key_ref.display_name()), + )); } reporter.log( @@ -94,16 +102,24 @@ impl Action for DecryptDataAction { let (transport, _) = crate::crypto::transport_backend(backend, &key_backend, "open a data key")?; - // Taken from the container rather than assumed. The reader admits only - // `id-aes256-wrap`, so today this is the one value it can be, and - // passing it is what makes a container that names something else the - // reader's problem rather than a silent decrypt under the wrong - // mechanism. - let data_key = transport.open_data_key( - &backend_key_id, - &envelope.wrapped_cek, - KeyProtection::AesKeyWrap, - )?; + // Taken from the container rather than assumed, so a backend that does + // not implement the protection the container names refuses it rather + // than decrypting under the wrong mechanism. + // + // A failure here and a failure of the content tag below are reported + // differently, which tells whoever reads the message whether the + // padding of the protected key was valid. Using that as a padding + // oracle takes many adaptive attempts, and a ceremony step runs once, + // in front of its witnesses. + let data_key = + transport.open_data_key(&backend_key_id, &envelope.wrapped_cek, envelope.protection)?; + if data_key.len() != content::KEY_BYTES { + return Err(ActionError::Failed(format!( + "The content key in this container is {} bytes, and AES-256-GCM takes {}", + data_key.len(), + content::KEY_BYTES + ))); + } let mut plaintext = content::open( &data_key, &SealedContent { @@ -122,7 +138,7 @@ impl Action for DecryptDataAction { "encrypted_data": data_ref.display_name(), "encrypted_data_fingerprint": data_fingerprint, "decryption_key": key_ref.display_name(), - "decryption_key_check_value": check_value.to_string(), + own.field(): own.recorded(), }), produced(plaintext.len()), // The artifact that was opened, which is what links this step to @@ -165,12 +181,162 @@ fn produced(content_bytes: usize) -> Value { }) } -/// Render a recipient identifier the way the transcript names that key. -/// -/// A check value where the bytes are one, so the message invites a reader to -/// compare two values that are alike. Anything else is shown as hex rather than -/// claimed to be a check value it is not. -fn named_recipient(identifier: &[u8]) -> String { +/// Describe a material's bytes as a container this step opens. +fn container_from_material( + data_id: &impl std::fmt::Display, + bytes: &[u8], +) -> Result { + let facts = cms::describe(bytes).map_err(|e| { + ActionError::Failed(format!( + "Artifact '{data_id}' is neither encrypted content nor a CMS container: {e}" + )) + })?; + EncryptedData::new(WrapScheme::CmsAes256Gcm, facts.description, bytes.to_vec()).map_err(|e| { + // The algorithms the container names, so the refusal says which of + // them the scheme does not take. + let named = serde_json::to_string(&e.description) + .unwrap_or_else(|_| "an unreadable description".to_string()); + ActionError::Failed(format!( + "Artifact '{data_id}' is a CMS container that {} does not admit: it names {named}", + e.scheme + )) + }) +} + +/// The decryption key, by the names a container may give its recipient. +enum KeyName { + /// A symmetric key, named by its check value. + CheckValue(KeyCheckValue), + /// A public key. Rite names it by the SHA-256 of its SPKI, which is what + /// the transcript records and what `encrypt_data` writes. X.509 tooling + /// names it by the SHA-1 of RFC 5280 method 1, which is what a certificate's + /// subject key identifier usually holds, so a container sealed to a + /// certificate with `openssl cms -keyid` names it that way. + PublicKey { + fingerprint: String, + spki_digest: Vec, + rfc5280: Vec, + }, +} + +impl KeyName { + /// The names `key` answers to, for a container whose content key is + /// protected with `protection`. + fn of( + key: &BackendKeyMeta<'_>, + protection: KeyProtection, + display_name: &str, + ) -> Result { + match protection { + KeyProtection::AesKeyWrap => { + let check_value = key.check_value.ok_or_else(|| { + ActionError::Failed(format!( + "'{display_name}' is a {} key, and this container is addressed to a \ + symmetric one", + key.algorithm + )) + })?; + Ok(Self::CheckValue(check_value.clone())) + } + KeyProtection::RsaOaepSha256 => { + // A key of another kind is the wrong kind of key, not a + // different key of the right kind, and the refusal says so. + if !WrapScheme::RsaOaepSha256.accepts_recipient(key.algorithm) { + return Err(ActionError::Failed(format!( + "'{display_name}' is {}, and this container is addressed to an \ + RSA-2048 or RSA-4096 key", + key.algorithm + ))); + } + let public_key = key.public_key.ok_or_else(|| { + ActionError::Failed(format!( + "'{display_name}' is a {} key with no public half, and this container is \ + addressed to an RSA public key", + key.algorithm + )) + })?; + Ok(Self::PublicKey { + fingerprint: compute_fingerprint(public_key.as_bytes()), + spki_digest: Sha256::digest(public_key.as_bytes()).to_vec(), + rfc5280: public_key + .rfc5280_key_identifier() + .map_err(|e| ActionError::Failed(e.to_string()))?, + }) + } + other => Err(ActionError::Failed(format!( + "This container protects its content key with {other}, which decrypt_data does \ + not open" + ))), + } + } + + /// Whether the container's recipient identifier names this key. + fn answers_to(&self, identifier: &[u8]) -> bool { + match self { + Self::CheckValue(check_value) => identifier == check_value.as_bytes(), + Self::PublicKey { + spki_digest, + rfc5280, + .. + } => identifier == spki_digest.as_slice() || identifier == rfc5280.as_slice(), + } + } + + /// Why a container naming `identifier` is not this key's, with both sides + /// rendered alike. + fn refusal(&self, identifier: &[u8], display_name: &str) -> String { + match self { + Self::CheckValue(check_value) => format!( + "This artifact is addressed to the key with check value {}, and \ + '{display_name}' has {check_value}", + named_check_value(identifier) + ), + Self::PublicKey { + fingerprint, + rfc5280, + .. + } => format!( + "This artifact is addressed to the key with subject key identifier {}, and \ + '{display_name}' is {fingerprint}, or {} by RFC 5280", + named_key_identifier(identifier), + base16ct::lower::encode_string(rfc5280) + ), + } + } + + /// The transcript field this key is recorded under. + fn field(&self) -> &'static str { + match self { + Self::CheckValue(_) => "decryption_key_check_value", + Self::PublicKey { .. } => "decryption_key_fingerprint", + } + } + + /// The key as the transcript names it. + fn recorded(&self) -> String { + match self { + Self::CheckValue(check_value) => check_value.to_string(), + Self::PublicKey { fingerprint, .. } => fingerprint.clone(), + } + } +} + +/// A subject key identifier, as a `sha256:` fingerprint where it is the length +/// of one and as hex otherwise, so a SHA-1 identifier is not presented as a +/// fingerprint the transcript could hold. +fn named_key_identifier(identifier: &[u8]) -> String { + let hex = base16ct::lower::encode_string(identifier); + if identifier.len() == 32 { + format!("sha256:{hex}") + } else { + hex + } +} + +/// A `kekid.keyIdentifier`, as a check value where the bytes are one, so the +/// message invites a reader to compare two values that are alike, and hex +/// otherwise rather than a claim to be a check value it is not. +fn named_check_value(identifier: &[u8]) -> String { // The length is the whole of what distinguishes the two, since // `from_cmac` keeps the leftmost three bytes of anything at least that // long. Without this a twenty-byte subject key identifier would be diff --git a/crates/rite-stdlib/src/crypto/encrypt_data.rs b/crates/rite-stdlib/src/crypto/encrypt_data.rs index 3209393..a337a35 100644 --- a/crates/rite-stdlib/src/crypto/encrypt_data.rs +++ b/crates/rite-stdlib/src/crypto/encrypt_data.rs @@ -1,12 +1,18 @@ -//! `encrypt_data` action, encrypt content for a key the backend holds. +//! `encrypt_data` action, encrypt content for a key the backend holds or for a +//! public key. -use rite_model::ActionType; +use rite_model::{ActionType, ArtifactRef}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, resolve_backend_key, }; -use rite_sdk::{Backend, DataKey, EncryptedData, KeyAlgorithm, KeyCheckValue, WrapScheme, cms}; +use rite_sdk::{ + Backend, DataKey, EncryptedData, KeyAlgorithm, KeyCheckValue, KeyProtection, PublicKeyDer, + RandomBackend, WrapScheme, cms, +}; use serde_json::json; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; use crate::crypto::content; use rite_model::params::EncryptDataParams; @@ -14,10 +20,14 @@ use rite_model::params::EncryptDataParams; /// Encrypt bytes into a container only the named key opens. /// /// Envelope encryption, which is the shape every key-protection device offers: -/// the backend produces a fresh content-encryption key and a copy of it that -/// only the key-encryption key can open, the content is encrypted under the -/// first, and the container carries the second. The content itself never -/// reaches the backend. +/// a fresh content-encryption key, and a copy of it that only the recipient can +/// open. The content is encrypted under the first, and the container carries +/// the second. The content itself never reaches the backend. +/// +/// The input the step names selects the recipient. `encryption_key:` names a +/// key the backend holds, which produces both halves. `recipient:` names a +/// public key held elsewhere: the backend supplies the content key's random +/// bytes, and protecting it takes only the public key. /// /// `wrap_key` for bytes that are not a key, and deliberately a second verb. A /// wrap says a key left a backend under protection, and the trust-boundary @@ -49,7 +59,7 @@ impl Action for EncryptDataAction { } let data_ref = step.required_named_input("data", "encrypt_data")?; - let key_ref = step.required_named_input("encryption_key", "encrypt_data")?; + let (path, key_ref) = recipient_input(step)?; let data_id = data_ref.artifact_id(); let payload = resolve_artifact_bytes(ctx.artifacts, &data_id, data_ref.property()) @@ -60,17 +70,6 @@ impl Action for EncryptDataAction { )) })?; - let key_id = key_ref.artifact_id(); - let key = resolve_backend_key(ctx.artifacts, &key_id).map_err(|e| { - ActionError::Failed(format!( - "encryption_key '{}' must be a key held by this backend: {e}", - key_ref.display_name() - )) - })?; - let check_value = recipient_check_value(&key, &key_ref.display_name())?; - let key_backend = key.backend_name.to_string(); - let backend_key_id = key.key_id.clone(); - reporter.log( Icon::Spinner, format!( @@ -79,31 +78,52 @@ impl Action for EncryptDataAction { key_ref.display_name() ), )?; - - let (transport, _) = - crate::crypto::transport_backend(backend, &key_backend, "protect a data key")?; - - // The backend makes the content-encryption key and the copy only the - // key-encryption key opens. Both halves come from the device that holds - // the KEK, so nothing here has to see the KEK itself. - let data_key = transport.generate_data_key(&backend_key_id, KeyAlgorithm::Aes256)?; - let encrypted = seal_into_container(&data_key, &check_value, payload)?; + let (data_key, addressee) = match path { + Path::Backend => backend_data_key(ctx, key_ref, backend)?, + Path::Recipient => { + // Both refusals come before the recipient is recorded, so a + // step that could not have encrypted anything does not record + // a key as having received content. + let random = random_source(backend)?; + let recipient = crate::crypto::ExternalRecipient { + key_ref, + expected: typed.expect_recipient.as_deref(), + consequence: "Content encrypted to it would be readable by that key's \ + holder and not by the one intended, so this step will not run.", + } + .resolve(step, ctx, reporter, admits_recipient)?; + recipient_data_key(&recipient, random)? + } + }; + let encrypted = seal_into_container(&data_key, &addressee, payload)?; let fingerprint = compute_fingerprint(encrypted.data()); reporter.log(Icon::Checkmark, "Content encrypted")?; - reporter.backend_operation( - "encrypt_data", - json!({ + let inputs = match &addressee { + Addressee::CheckValue(check_value) => json!({ "scheme": scheme.to_string(), "data": data_ref.display_name(), // How much was encrypted, and deliberately no digest of it. - // Content is what a ceremony encrypts because it is secret, and - // a hash of a secret is the shape rite#126 removed. + // Content is what a ceremony encrypts because it is secret, + // and a hash of a secret is the shape rite#126 removed. "content_bytes": payload.len(), "encryption_key": key_ref.display_name(), "encryption_key_check_value": check_value.to_string(), }), + // The fingerprint is in its own fact, recorded before the + // encryption, so the method can be published while the recipient + // stays withheld. + Addressee::PublicKey(_) => json!({ + "scheme": scheme.to_string(), + "data": data_ref.display_name(), + "content_bytes": payload.len(), + "recipient": key_ref.display_name(), + }), + }; + reporter.backend_operation( + "encrypt_data", + inputs, produced(&fingerprint, &encrypted), Some(fingerprint.clone()), )?; @@ -127,6 +147,135 @@ impl Action for EncryptDataAction { } } +/// Which input names the recipient. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Path { + /// `encryption_key:`, a symmetric key this backend holds. + Backend, + /// `recipient:`, a public key whose private half is held elsewhere. + Recipient, +} + +/// Select the path from the input the step names. +/// +/// The resolver rejects a step that names neither or both, so reaching the +/// error here means the step was built without going through resolution. +fn recipient_input(step: &StepInfo) -> Result<(Path, &ArtifactRef), ActionError> { + match ( + step.named_input("encryption_key"), + step.named_input("recipient"), + ) { + (Some(key), None) => Ok((Path::Backend, key)), + (None, Some(key)) => Ok((Path::Recipient, key)), + (Some(_), Some(_)) => Err(ActionError::Failed( + "encrypt_data reads both 'encryption_key' and 'recipient'; name one".to_string(), + )), + (None, None) => Err(ActionError::Failed( + "encrypt_data: missing required input 'encryption_key' or 'recipient'".to_string(), + )), + } +} + +/// What the container names its recipient by. +enum Addressee { + /// A symmetric key, by its check value, in a `KEKRecipientInfo`. + CheckValue(KeyCheckValue), + /// A public key, by the SHA-256 of its SPKI, in a `KeyTransRecipientInfo`. + /// The same digest the transcript writes as the key's `sha256:` + /// fingerprint, so a blob and a record compare without holding the key. + PublicKey(Vec), +} + +impl Addressee { + /// The key protection this recipient form is written with. + fn protection(&self) -> KeyProtection { + match self { + Self::CheckValue(_) => KeyProtection::AesKeyWrap, + Self::PublicKey(_) => KeyProtection::RsaOaepSha256, + } + } + + fn key_identifier(&self) -> Vec { + match self { + Self::CheckValue(check_value) => check_value.as_bytes().to_vec(), + Self::PublicKey(spki_digest) => spki_digest.clone(), + } + } +} + +/// Both halves of the data key from the backend that holds `key_ref`. +/// +/// Both come from the device that holds the key-encryption key, so nothing +/// here has to see that key. +fn backend_data_key( + ctx: &HandlerContext, + key_ref: &ArtifactRef, + backend: Option<&mut dyn Backend>, +) -> Result<(DataKey, Addressee), ActionError> { + let key = resolve_backend_key(ctx.artifacts, &key_ref.artifact_id()).map_err(|e| { + ActionError::Failed(format!( + "encryption_key '{}' must be a key held by this backend: {e}. Read it as \ + 'recipient:' to encrypt to a public key instead.", + key_ref.display_name() + )) + })?; + let check_value = recipient_check_value(&key, &key_ref.display_name())?; + let (transport, _) = + crate::crypto::transport_backend(backend, key.backend_name, "protect a data key")?; + let data_key = transport.generate_data_key(key.key_id, KeyAlgorithm::Aes256)?; + Ok((data_key, Addressee::CheckValue(check_value))) +} + +/// The step's backend, as the source of the content-encryption key's random +/// bytes. +/// +/// The content key is key material, so its randomness comes from the backend +/// the ceremony named, as a generated key's would. +fn random_source(backend: Option<&mut dyn Backend>) -> Result<&mut dyn RandomBackend, ActionError> { + let backend = backend.ok_or_else(|| { + ActionError::Failed( + "Backend required to draw the content-encryption key's random bytes".to_string(), + ) + })?; + let backend_name = backend.name().to_string(); + backend.as_random_mut().ok_or_else(|| { + ActionError::Failed(format!( + "Backend '{backend_name}' cannot generate random bytes, which the \ + content-encryption key is made from" + )) + }) +} + +/// Whether the container can address `recipient`: an RSA key of a size every +/// other path accepts, which is what a `KeyTransRecipientInfo` under +/// RSAES-OAEP takes. +fn admits_recipient(recipient: &PublicKeyDer) -> Result<(), ActionError> { + let algorithm = recipient + .algorithm() + .map_err(|e| ActionError::Failed(e.to_string()))?; + if WrapScheme::RsaOaepSha256.accepts_recipient(algorithm) { + Ok(()) + } else { + Err(ActionError::Failed(format!( + "{} encrypts to an RSA-2048 or RSA-4096 public key, and the recipient is {algorithm}", + WrapScheme::CmsAes256Gcm + ))) + } +} + +/// A data key protected for `recipient`, its bytes drawn from `random`. +/// +/// Protecting it needs only the public key and happens in software. +fn recipient_data_key( + recipient: &PublicKeyDer, + random: &mut dyn RandomBackend, +) -> Result<(DataKey, Addressee), ActionError> { + let cek = Zeroizing::new(random.generate_random(content::KEY_BYTES)?); + let data_key = content::protect_for(recipient, cek)?; + let spki_digest = Sha256::digest(recipient.as_bytes()).to_vec(); + Ok((data_key, Addressee::PublicKey(spki_digest))) +} + /// Encrypt the content under the data key and assemble the container around /// both halves. /// @@ -135,7 +284,7 @@ impl Action for EncryptDataAction { /// this path exists to make impossible. fn seal_into_container( data_key: &DataKey, - check_value: &KeyCheckValue, + addressee: &Addressee, payload: &[u8], ) -> Result { // The container has to declare the key-encryption algorithm, so a data key @@ -150,9 +299,21 @@ fn seal_into_container( data_key.protection() ))); } + // The protection decides the recipient form, and each form takes its own + // kind of identifier. Any other pairing would put a check value where the + // container expects a key identifier, or the reverse. + if data_key.protection() != addressee.protection() { + return Err(ActionError::Failed(format!( + "this data key is protected with {}, and a container addressed to this recipient \ + takes {}", + data_key.protection(), + addressee.protection() + ))); + } let sealed = content::seal(data_key.plaintext(), payload)?; - let der = cms::write_kek_enveloped(&cms::KekEnvelope { - key_identifier: check_value.as_bytes().to_vec(), + let der = cms::write_auth_enveloped(&cms::Envelope { + protection: data_key.protection(), + key_identifier: addressee.key_identifier(), wrapped_cek: data_key.wrapped().to_vec(), nonce: sealed.nonce, ciphertext: sealed.ciphertext, @@ -180,7 +341,7 @@ fn produced(fingerprint: &str, encrypted: &EncryptedData) -> serde_json::Value { }) } -/// What the container names the recipient by. +/// What the container names a symmetric recipient by. /// /// `CMS-AES-256-GCM` addresses a symmetric recipient by its check value, which /// is also what the transcript records and what a token displays, so a blob and @@ -193,7 +354,8 @@ fn recipient_check_value( ) -> Result { if key.algorithm != KeyAlgorithm::Aes256 { return Err(ActionError::Failed(format!( - "{} encrypts to a 256-bit symmetric key, and '{display_name}' is {}", + "{} encrypts to a 256-bit symmetric key, and '{display_name}' is {}. Read it as \ + 'recipient:' to encrypt to its public key instead.", WrapScheme::CmsAes256Gcm, key.algorithm ))); diff --git a/crates/rite-stdlib/src/crypto/mod.rs b/crates/rite-stdlib/src/crypto/mod.rs index dfd1f04..bcd0559 100644 --- a/crates/rite-stdlib/src/crypto/mod.rs +++ b/crates/rite-stdlib/src/crypto/mod.rs @@ -21,8 +21,9 @@ pub use unwrap_key::UnwrapKeyAction; pub use verify_signature::VerifySignatureAction; pub use wrap_key::WrapKeyAction; -use rite_runtime::ActionError; -use rite_sdk::{Backend, KeyTransportBackend}; +use rite_model::{ArtifactRef, StepFact}; +use rite_runtime::{ActionError, HandlerContext, Reporter, StepInfo, compute_fingerprint}; +use rite_sdk::{Backend, KeyTransportBackend, PublicKeyDer}; /// The backend a step names, as the capability the step needs, once it is known /// to be the backend that holds the key. @@ -57,3 +58,67 @@ pub(crate) fn transport_backend<'a>( .ok_or_else(|| ActionError::Failed(format!("Backend '{name}' cannot {operation}")))?; Ok((transport, name)) } + +/// A public key held outside the ceremony that a step addresses its output to. +pub(crate) struct ExternalRecipient<'a> { + /// The input naming the key. + pub key_ref: &'a ArtifactRef, + /// The fingerprint the ceremony declared for it, if any. + pub expected: Option<&'a str>, + /// What addressing the wrong key would do, completing the sentence "..., + /// but the ceremony declares X." in the refusal. + pub consequence: &'a str, +} + +impl ExternalRecipient<'_> { + /// Resolve the key, check it against the declared fingerprint, and record + /// it. + /// + /// `admits` is the step's own check that it can address this key at all, + /// run before the fact. A recipient the step was going to refuse is never + /// recorded as having received anything. The fact is recorded before the + /// operation itself, so an aborted run still says which key was presented. + /// + /// # Errors + /// + /// Returns [`ActionError::Failed`] if the input is not a public key, if it + /// is not the declared one, or if `admits` refuses it. + pub fn resolve( + &self, + step: &StepInfo, + ctx: &HandlerContext, + reporter: &mut Reporter<'_>, + admits: impl FnOnce(&PublicKeyDer) -> Result<(), ActionError>, + ) -> Result { + let name = self.key_ref.display_name(); + let recipient = crate::signatures::resolve_public_key( + ctx.artifacts, + &self.key_ref.artifact_id(), + self.key_ref.property(), + ) + .map_err(|e| { + ActionError::Failed(format!( + "Cannot resolve recipient '{name}' as a public key: {e}" + )) + })?; + + let fingerprint = compute_fingerprint(recipient.as_bytes()); + if let Some(declared) = self.expected + && declared != fingerprint + { + return Err(ActionError::Failed(format!( + "Recipient '{name}' is {fingerprint}, but the ceremony declares {declared}. {}", + self.consequence + ))); + } + admits(&recipient)?; + + reporter.fact(StepFact::WrapRecipientRecorded { + step: step.id.clone(), + source: name, + fingerprint, + declared: self.expected.is_some(), + })?; + Ok(recipient) + } +} diff --git a/crates/rite-stdlib/src/crypto/wrap_key.rs b/crates/rite-stdlib/src/crypto/wrap_key.rs index 9a234e8..2fb11b4 100644 --- a/crates/rite-stdlib/src/crypto/wrap_key.rs +++ b/crates/rite-stdlib/src/crypto/wrap_key.rs @@ -1,6 +1,6 @@ //! `wrap_key` action, wrap a backend-resident key for transport. -use rite_model::{ActionType, ArtifactRef, StepFact}; +use rite_model::{ActionType, ArtifactRef}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_backend_key, @@ -91,36 +91,15 @@ impl Action for WrapKeyAction { transport.wrap(key_to_wrap.key_id, wrapping_key.key_id, scheme)? } Custody::External => { - let recipient = crate::signatures::resolve_public_key( - ctx.artifacts, - &wrapping_key_id, - wrapping_key_ref.property(), - ) - .map_err(|e| { - ActionError::Failed(format!( - "Cannot resolve recipient '{}' as a public key: {e}", - wrapping_key_ref.display_name() - )) - })?; - - // Who the ceremony is trusting, recorded before the wrap so an - // aborted run still says which key was presented. - let recipient_fingerprint = compute_fingerprint(recipient.as_bytes()); - if let Some(declared) = &typed.expect_recipient - && declared != &recipient_fingerprint - { - return Err(ActionError::Failed(format!( - "Recipient '{}' is {recipient_fingerprint}, but the ceremony declares {declared}. \ - Wrapping to an unintended recipient cannot be undone, so this step will not run.", - wrapping_key_ref.display_name() - ))); + // Who the ceremony is trusting. Which recipients a scheme + // takes is the backend's to say, at the wrap. + let recipient = crate::crypto::ExternalRecipient { + key_ref: wrapping_key_ref, + expected: typed.expect_recipient.as_deref(), + consequence: "Wrapping to an unintended recipient cannot be undone, so this \ + step will not run.", } - reporter.fact(StepFact::WrapRecipientRecorded { - step: step.id.clone(), - source: wrapping_key_ref.display_name(), - fingerprint: recipient_fingerprint, - declared: typed.expect_recipient.is_some(), - })?; + .resolve(step, ctx, reporter, |_| Ok(()))?; let (transport, _) = crate::crypto::transport_backend(backend, key_backend, "wrap a key")?; diff --git a/crates/rite-stdlib/tests/actions.rs b/crates/rite-stdlib/tests/actions.rs index a081514..225a25b 100644 --- a/crates/rite-stdlib/tests/actions.rs +++ b/crates/rite-stdlib/tests/actions.rs @@ -21,6 +21,7 @@ use rite_stdlib::{ OralReadbackAction, RevealAction, SplitSecretAction, UnwrapKeyAction, WrapKeyAction, }; use secrecy::{ExposeSecret, SecretBox, SecretString}; +use zeroize::Zeroizing; fn make_state() -> ExecutionState { ExecutionState::new(HashMap::new(), HashMap::new(), HashMap::new(), false) @@ -1383,7 +1384,7 @@ fn encrypt_data_produces_a_container_addressed_to_the_key() { // The container names its recipient, so a holder of the key can tell // whether the blob is theirs without decrypting anything. let envelope = - rite_sdk::cms::read_kek_enveloped(encrypted.data()).expect("the container reads back"); + rite_sdk::cms::read_auth_enveloped(encrypted.data()).expect("the container reads back"); assert_eq!(envelope.key_identifier, expected.as_bytes()); assert_eq!(encrypted.scheme(), rite_sdk::WrapScheme::CmsAes256Gcm); @@ -1481,6 +1482,263 @@ fn decrypt_data_refuses_a_container_addressed_to_another_key() { ); } +/// An RSA key the backend holds, allowed to open what is sealed to it. +fn rsa_recipient(backend: &mut MockBackend, id: &str) -> (ArtifactId, ArtifactValue) { + backend_key_with( + backend, + id, + KeyAlgorithm::Rsa2048, + KeyPolicy { + usages: rite_sdk::KeyUsages::UNWRAP, + ..KeyPolicy::default() + }, + ) +} + +/// The public half of an RSA key artifact. +fn public_half(key: &ArtifactValue) -> &rite_sdk::PublicKeyDer { + let ArtifactValue::BackendKey { + public_key: Some(public_key), + .. + } = key + else { + panic!("an RSA key has a public half") + }; + public_key +} + +/// Rite's name for a public key: the SHA-256 of its SPKI. +fn spki_digest(public_key: &rite_sdk::PublicKeyDer) -> Vec { + openssl::hash::hash( + openssl::hash::MessageDigest::sha256(), + public_key.as_bytes(), + ) + .unwrap() + .to_vec() +} + +/// The name X.509 tooling gives an RSA key, RFC 5280 method 1: the SHA-1 of +/// the `subjectPublicKey` bits, which for RSA are the PKCS#1 `RSAPublicKey`. +/// Computed here from OpenSSL rather than through Rite. +fn rfc5280_identifier(public_key: &rite_sdk::PublicKeyDer) -> Vec { + let rsa = openssl::rsa::Rsa::public_key_from_der(public_key.as_bytes()).unwrap(); + let bits = rsa.public_key_to_der_pkcs1().unwrap(); + openssl::hash::hash(openssl::hash::MessageDigest::sha1(), &bits) + .unwrap() + .to_vec() +} + +/// Content sealed to `recipient`'s public key the way a third party holding +/// only that key would seal it, as an `EncryptedData` artifact. +fn sealed_to_public_key(recipient: &ArtifactValue, payload: &[u8]) -> ArtifactValue { + let der = sealed_der(recipient, payload, spki_digest); + let facts = rite_sdk::cms::describe(&der).unwrap(); + ArtifactValue::EncryptedData( + rite_sdk::EncryptedData::new(rite_sdk::WrapScheme::CmsAes256Gcm, facts.description, der) + .unwrap(), + ) +} + +/// The container's bytes, naming the recipient by `identifier`. +fn sealed_der( + recipient: &ArtifactValue, + payload: &[u8], + identifier: fn(&rite_sdk::PublicKeyDer) -> Vec, +) -> Vec { + let public_key = public_half(recipient); + let data_key = + rite_openssl::data_key_for_recipient(public_key, Zeroizing::new(vec![0x5a; 32])).unwrap(); + let sealed = rite_openssl::seal_content(data_key.plaintext(), payload).unwrap(); + rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { + protection: data_key.protection(), + key_identifier: identifier(public_key), + wrapped_cek: data_key.wrapped().to_vec(), + nonce: sealed.nonce, + ciphertext: sealed.ciphertext, + tag: sealed.tag, + }) + .unwrap() +} + +/// Run `decrypt_data` on `sealed` with `key`, returning the result and the +/// facts it recorded. +fn decrypt_with( + backend: &mut MockBackend, + sealed: ArtifactValue, + key: (ArtifactId, ArtifactValue), +) -> ( + Result, + Vec, +) { + let sealed_id = ArtifactId::new("sealed"); + let (key_id, key) = key; + let state = make_state() + .with_material(sealed_id.clone(), sealed) + .with_material(key_id.clone(), key); + let step = step_named( + "decrypt", + "recovered", + &[("encrypted_data", sealed_id), ("decryption_key", key_id)], + ); + + let mut harness = ReporterHarness::new(); + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + DecryptDataAction.execute( + &step, + &ctx, + &serde_json::json!({}), + &mut reporter, + Some(backend), + ) + }; + (result, harness.facts().to_vec()) +} + +/// A container whose content key went to an RSA public key opens under the +/// private half the backend holds, and the record names that key by the +/// fingerprint the container's recipient identifier carries. +#[test] +fn decrypt_data_opens_content_sealed_to_a_public_key() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let recipient = rsa_recipient(&mut backend, "custodian_key"); + let sealed = sealed_to_public_key(&recipient.1, b"the recovery phrase"); + let fingerprint = rite_runtime::compute_fingerprint(public_half(&recipient.1).as_bytes()); + + let (result, facts) = decrypt_with(&mut backend, sealed, recipient); + let result = result.expect("decrypt_data completes"); + match produced(&result.artifacts, "recovered") { + ArtifactValue::Secret(bytes) => { + assert_eq!(bytes.expose_secret().as_slice(), b"the recovery phrase"); + } + other => panic!("decrypt_data must produce Secret, got {other:?}"), + } + + let recorded = facts.iter().find_map(|fact| match fact { + StepFact::BackendOperation { kind, inputs, .. } if kind == "decrypt_data" => { + inputs.get("decryption_key_fingerprint").cloned() + } + _ => None, + }); + assert_eq!(recorded, Some(serde_json::json!(fingerprint))); +} + +/// Addressed to another public key, the container is refused before any +/// decrypt, and the refusal names both keys the way the transcript does. +#[test] +fn decrypt_data_refuses_content_sealed_to_another_public_key() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let intended = rsa_recipient(&mut backend, "intended"); + let sealed = sealed_to_public_key(&intended.1, b"the recovery phrase"); + let other = rsa_recipient(&mut backend, "other"); + + let (result, _) = decrypt_with(&mut backend, sealed, other); + let message = result + .expect_err("a container addressed elsewhere does not open") + .to_string(); + assert!( + message.contains("addressed to the key with subject key identifier sha256:"), + "the refusal must name the recipient, got: {message}" + ); +} + +/// A container sealed outside the ceremony arrives as a material's bytes, and +/// names its recipient the way a standard certificate does: by the SHA-1 +/// identifier of RFC 5280. It opens under the key it names, and the record +/// still names that key by Rite's own fingerprint. +#[test] +fn decrypt_data_opens_a_material_addressed_by_the_rfc_5280_identifier() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let recipient = rsa_recipient(&mut backend, "custodian_key"); + let der = sealed_der(&recipient.1, b"a document", rfc5280_identifier); + let fingerprint = rite_runtime::compute_fingerprint(public_half(&recipient.1).as_bytes()); + + let (result, facts) = decrypt_with(&mut backend, ArtifactValue::Bytes(der), recipient); + let result = result.expect("decrypt_data opens a material container"); + match produced(&result.artifacts, "recovered") { + ArtifactValue::Secret(bytes) => assert_eq!(bytes.expose_secret().as_slice(), b"a document"), + other => panic!("decrypt_data must produce Secret, got {other:?}"), + } + let recorded = facts.iter().find_map(|fact| match fact { + StepFact::BackendOperation { kind, inputs, .. } if kind == "decrypt_data" => { + inputs.get("decryption_key_fingerprint").cloned() + } + _ => None, + }); + assert_eq!(recorded, Some(serde_json::json!(fingerprint))); +} + +/// A SHA-1 identifier for another key is shown as what it is, not as a +/// `sha256:` fingerprint the transcript could hold. +#[test] +fn decrypt_data_names_a_foreign_identifier_as_plain_hex() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let intended = rsa_recipient(&mut backend, "intended"); + let der = sealed_der(&intended.1, b"a document", rfc5280_identifier); + let other = rsa_recipient(&mut backend, "other"); + + let (result, _) = decrypt_with(&mut backend, ArtifactValue::Bytes(der), other); + let message = result.expect_err("addressed elsewhere").to_string(); + let named = base16ct::lower::encode_string(&rfc5280_identifier(public_half(&intended.1))); + assert!( + message.contains(&format!("subject key identifier {named},")), + "{message}" + ); +} + +/// A container whose protected content key opens to a length AES-256-GCM does +/// not take is refused by name, before the cipher sees it. +#[test] +fn decrypt_data_refuses_a_content_key_of_the_wrong_length() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let recipient = rsa_recipient(&mut backend, "custodian_key"); + let public_key = public_half(&recipient.1); + let short = + rite_openssl::data_key_for_recipient(public_key, Zeroizing::new(vec![0x5a; 16])).unwrap(); + let der = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { + protection: short.protection(), + key_identifier: spki_digest(public_key), + wrapped_cek: short.wrapped().to_vec(), + nonce: vec![0; 12], + ciphertext: vec![0; 16], + tag: vec![0; 16], + }) + .unwrap(); + + let (result, _) = decrypt_with(&mut backend, ArtifactValue::Bytes(der), recipient); + let message = result.expect_err("a 16-byte content key").to_string(); + assert!(message.contains("16 bytes"), "{message}"); +} + +/// Bytes that are not a CMS container are refused as such. +#[test] +fn decrypt_data_refuses_a_material_that_is_not_a_container() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let recipient = rsa_recipient(&mut backend, "custodian_key"); + + let (result, _) = decrypt_with( + &mut backend, + ArtifactValue::Bytes(b"not a container".to_vec()), + recipient, + ); + let message = result.expect_err("plain bytes do not open").to_string(); + assert!(message.contains("CMS container"), "{message}"); +} + +/// A symmetric key has no public half to match a key transport recipient. +#[test] +fn decrypt_data_refuses_a_symmetric_key_for_a_public_key_recipient() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let intended = rsa_recipient(&mut backend, "intended"); + let sealed = sealed_to_public_key(&intended.1, b"the recovery phrase"); + let (kek_id, kek_meta) = kek(&mut backend, "a_kek"); + + let (result, _) = decrypt_with(&mut backend, sealed, (kek_id, key_artifact(&kek_meta))); + let message = result.expect_err("an AES key does not open it").to_string(); + assert!(message.contains("RSA-2048 or RSA-4096"), "{message}"); +} + #[test] fn encrypt_data_refuses_a_key_that_is_not_a_256_bit_secret() { let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); @@ -1518,13 +1776,224 @@ fn encrypt_data_refuses_a_key_that_is_not_a_256_bit_secret() { ); } +/// Run `encrypt_data` on `payload` to the public key in `recipient`, returning +/// the result and the facts it recorded. +fn encrypt_to_recipient( + backend: &mut MockBackend, + payload: &[u8], + recipient: ArtifactValue, + params: &serde_json::Value, +) -> ( + Result, + Vec, +) { + let content_id = ArtifactId::new("archive"); + let recipient_id = ArtifactId::new("custodian_public"); + let state = make_state() + .with_material(content_id.clone(), ArtifactValue::Bytes(payload.to_vec())) + .with_material(recipient_id.clone(), recipient); + let step = step_named( + "encrypt", + "sealed", + &[("data", content_id), ("recipient", recipient_id)], + ); + + let mut harness = ReporterHarness::new(); + let result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(step.id.clone()); + EncryptDataAction.execute(&step, &ctx, params, &mut reporter, Some(backend)) + }; + (result, harness.facts().to_vec()) +} + +/// Content encrypted to a public key read off a disk is addressed to that key +/// by the SHA-256 of its SPKI, and the holder of the private half opens it. +/// The recipient is recorded in a fact of its own, as a wrap's is, and the +/// operation names it only by the input. +#[test] +fn encrypt_data_seals_content_to_a_public_key_its_holder_opens() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let custodian = rsa_recipient(&mut backend, "custodian_key"); + let public_key = public_half(&custodian.1).clone(); + let fingerprint = rite_runtime::compute_fingerprint(public_key.as_bytes()); + + let (result, facts) = encrypt_to_recipient( + &mut backend, + b"the recovery phrase", + ArtifactValue::Bytes(public_key.as_bytes().to_vec()), + &serde_json::json!({}), + ); + let mut result = result.expect("encrypt_data completes"); + let sealed = result.artifacts.pop().expect("one artifact").1; + let ArtifactValue::EncryptedData(encrypted) = &sealed else { + panic!("encrypt_data must produce EncryptedData, got {sealed:?}") + }; + let envelope = + rite_sdk::cms::read_auth_enveloped(encrypted.data()).expect("the container reads back"); + assert_eq!(envelope.protection, rite_sdk::KeyProtection::RsaOaepSha256); + assert_eq!(envelope.key_identifier, spki_digest(&public_key)); + + let recorded = facts.iter().find_map(|fact| match fact { + StepFact::WrapRecipientRecorded { + fingerprint, + declared, + source, + .. + } => Some((fingerprint.clone(), *declared, source.clone())), + _ => None, + }); + assert_eq!( + recorded, + Some((fingerprint.clone(), false, "custodian_public".to_string())) + ); + let inputs = facts + .iter() + .find_map(|fact| match fact { + StepFact::BackendOperation { kind, inputs, .. } if kind == "encrypt_data" => { + Some(inputs.clone()) + } + _ => None, + }) + .expect("the operation is recorded"); + assert_eq!( + inputs.get("recipient"), + Some(&serde_json::json!("custodian_public")) + ); + assert!( + !inputs.to_string().contains(&fingerprint), + "the recipient's fingerprint belongs to its own fact: {inputs}" + ); + + let (opened, _) = decrypt_with(&mut backend, sealed, custodian); + match produced( + &opened.expect("decrypt_data completes").artifacts, + "recovered", + ) { + ArtifactValue::Secret(bytes) => { + assert_eq!(bytes.expose_secret().as_slice(), b"the recovery phrase"); + } + other => panic!("decrypt_data must produce Secret, got {other:?}"), + } +} + +/// A recipient other than the one the ceremony declared is refused before +/// anything is encrypted or recorded. +#[test] +fn encrypt_data_refuses_a_recipient_other_than_the_declared_one() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let presented = rsa_recipient(&mut backend, "presented"); + let declared = format!("sha256:{}", "ab".repeat(32)); + + let (result, facts) = encrypt_to_recipient( + &mut backend, + b"the recovery phrase", + presented.1, + &serde_json::json!({ "expect_recipient": declared }), + ); + let message = result + .expect_err("a recipient the ceremony did not declare is refused") + .to_string(); + assert!(message.contains(&declared), "{message}"); + assert!( + facts.iter().all(|fact| !matches!( + fact, + StepFact::BackendOperation { .. } | StepFact::WrapRecipientRecorded { .. } + )), + "nothing is recorded for a refused recipient: {facts:?}" + ); +} + +/// Declared in advance, the recipient's fact says so. +#[test] +fn encrypt_data_records_a_declared_recipient_as_declared() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let custodian = rsa_recipient(&mut backend, "custodian_key"); + let fingerprint = rite_runtime::compute_fingerprint(public_half(&custodian.1).as_bytes()); + + let (result, facts) = encrypt_to_recipient( + &mut backend, + b"the recovery phrase", + custodian.1, + &serde_json::json!({ "expect_recipient": fingerprint }), + ); + result.expect("encrypt_data completes"); + assert!( + facts + .iter() + .any(|fact| matches!(fact, StepFact::WrapRecipientRecorded { declared: true, .. })) + ); +} + +/// A public key the container cannot address is refused by name. +#[test] +fn encrypt_data_refuses_a_recipient_that_is_not_an_rsa_key() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let (_, ec) = backend_key(&mut backend, "signing_key", KeyAlgorithm::EcdsaP256); + + let (result, facts) = encrypt_to_recipient( + &mut backend, + b"the recovery phrase", + ec, + &serde_json::json!({}), + ); + let message = result + .expect_err("an EC recipient has no key transport here") + .to_string(); + assert!(message.contains("ECDSA-P256"), "{message}"); + // Refused before the recipient is recorded, so the transcript does not + // say the key received content it never did. + assert!( + !facts + .iter() + .any(|fact| matches!(fact, StepFact::WrapRecipientRecorded { .. })), + "{facts:?}" + ); +} + +/// A key of another kind than the container is addressed to is refused as +/// the wrong kind of key, not as a different key of the right kind. +#[test] +fn decrypt_data_names_a_key_of_the_wrong_kind_for_a_public_key_recipient() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let custodian = rsa_recipient(&mut backend, "custodian_key"); + let sealed = sealed_to_public_key(&custodian.1, b"the recovery phrase"); + let ec = backend_key(&mut backend, "signing_key", KeyAlgorithm::EcdsaP256); + + let (result, _) = decrypt_with(&mut backend, sealed, ec); + let message = result + .expect_err("an EC key does not open an RSA recipient") + .to_string(); + assert!( + message.contains("ECDSA-P256") && message.contains("RSA-2048 or RSA-4096"), + "{message}" + ); +} + #[test] fn decrypt_data_refuses_a_wrapped_key() { let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); let (kek_id, meta) = kek(&mut backend, "transport_kek"); + let target = backend + .generate_key(key_spec_with( + "target", + KeyAlgorithm::Aes256, + KeyPolicy { + extractable: true, + ..KeyPolicy::default_for(KeyAlgorithm::Aes256) + }, + )) + .unwrap(); + let wrapped = rite_sdk::KeyTransportBackend::wrap( + &mut backend, + &target.key_id, + &meta.key_id, + rite_sdk::WrapScheme::CmsAes256Gcm, + ) + .unwrap(); let wrapped_id = ArtifactId::new("wrapped"); let state = make_state() - .with_material(wrapped_id.clone(), ArtifactValue::Bytes(vec![0u8; 16])) + .with_material(wrapped_id.clone(), ArtifactValue::WrappedKey(wrapped)) .with_material(kek_id.clone(), key_artifact(&meta)); let step = step_named( "decrypt", diff --git a/crates/rite/src/container_checks.rs b/crates/rite/src/container_checks.rs index 81350ae..e974086 100644 --- a/crates/rite/src/container_checks.rs +++ b/crates/rite/src/container_checks.rs @@ -635,7 +635,8 @@ mod tests { .generate_data_key(&kek.key_id, KeyAlgorithm::Aes256) .unwrap(); let content = rite_openssl::seal_content(data_key.plaintext(), b"an archive").unwrap(); - let blob = rite_sdk::cms::write_kek_enveloped(&rite_sdk::cms::KekEnvelope { + let blob = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { + protection: data_key.protection(), key_identifier: check_value.as_bytes().to_vec(), wrapped_cek: data_key.wrapped().to_vec(), nonce: content.nonce, @@ -709,6 +710,99 @@ mod tests { ); } + /// Content sealed to a public key names it by the SHA-256 of its SPKI, + /// which is the fingerprint the recipient fact records, so the two compare + /// as a wrap's do: a match is the declared recipient, any other key is a + /// mismatch. + #[test] + fn an_encrypt_to_a_public_key_is_checked_against_its_recipient_fact() { + let tmp = tempfile::tempdir().unwrap(); + let mut backend = rite_openssl::OpenSslBackend::try_new("test").unwrap(); + let recipient = backend + .generate_key(KeySpec { + algorithm: KeyAlgorithm::Rsa2048, + label: "custodian".to_string(), + policy: KeyPolicy::default(), + location_hint: None, + }) + .unwrap(); + let public = backend.export_public_key(&recipient.key_id).unwrap(); + let fingerprint = rite_runtime::compute_fingerprint(public.as_bytes()); + let spki_digest = fingerprint + .strip_prefix("sha256:") + .and_then(|hex| base16ct::lower::decode_vec(hex).ok()) + .unwrap(); + let data_key = + rite_openssl::data_key_for_recipient(&public, zeroize::Zeroizing::new(vec![0x5a; 32])) + .unwrap(); + let content = rite_openssl::seal_content(data_key.plaintext(), b"an archive").unwrap(); + let blob = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { + protection: data_key.protection(), + key_identifier: spki_digest, + wrapped_cek: data_key.wrapped().to_vec(), + nonce: content.nonce, + ciphertext: content.ciphertext, + tag: content.tag, + }) + .unwrap(); + std::fs::create_dir_all(tmp.path().join("artifacts")).unwrap(); + std::fs::write(tmp.path().join("artifacts/sealed.p7c"), &blob).unwrap(); + + let facts = |fingerprint: String| { + vec![ + StepFact::WrapRecipientRecorded { + step: StepId::new("encrypt"), + source: "custodian_public".to_string(), + fingerprint, + declared: true, + }, + StepFact::BackendOperation { + step: StepId::new("encrypt"), + kind: "encrypt_data".to_string(), + backend: None, + inputs: json!({ + "scheme": "CMS-AES-256-GCM", + "recipient": "custodian_public", + }), + outputs: json!({ + "encrypted_data_fingerprint": rite_runtime::compute_fingerprint(&blob), + "encryption": rite_sdk::cms::describe(&blob).unwrap().description, + }), + fingerprint: None, + }, + StepFact::ArtifactWritten { + step: StepId::new("encrypt"), + name: "sealed".to_string(), + file: "sealed.p7c".to_string(), + digest: Some(rite_model::Sha256Digest::of(&blob)), + }, + ] + }; + + let recorded = facts(fingerprint); + let checks = check_containers(Some(tmp.path()), &borrow(&recorded)); + let [check] = checks.as_slice() else { + panic!("expected one container check, got {checks:?}"); + }; + assert_eq!( + check.status, + ContainerStatus::Checked { + algorithms: AlgorithmEvidence::Derived, + recipient: RecipientEvidence::Declared, + origin: OriginEvidence::None, + }, + "{}", + check.describe() + ); + + let another = facts(format!("sha256:{}", "ab".repeat(32))); + let checks = check_containers(Some(tmp.path()), &borrow(&another)); + assert!( + checks.iter().all(ContainerCheck::failed), + "a record naming another recipient is a mismatch: {checks:?}" + ); + } + #[test] fn a_wrap_whose_artifact_agrees_with_the_record_is_checked() { let tmp = tempfile::tempdir().unwrap(); diff --git a/docs/development/cryptographic-dependencies.md b/docs/development/cryptographic-dependencies.md index ef0f681..5674bc5 100644 --- a/docs/development/cryptographic-dependencies.md +++ b/docs/development/cryptographic-dependencies.md @@ -20,7 +20,10 @@ primitive. A new algorithm needs work on both sides: an OID and identifier in `CertificateDer` parse their own encoding with `x509-cert` so a build with no crypto provider can still tell a key from a certificate and read a key's algorithm. A new key algorithm also needs its OID in -`rite-sdk/src/key_material.rs`. +`rite-sdk/src/key_material.rs`. `PublicKeyDer::rfc5280_key_identifier` derives +the SHA-1 name X.509 tooling gives a key through `x509-cert`, as +`issue_certificate` does for the certificates it writes: it is a name read off +public bits, not an operation under a key. ## Why one provider for primitives diff --git a/docs/encrypting-content.md b/docs/encrypting-content.md index 3b850d5..e47b16c 100644 --- a/docs/encrypting-content.md +++ b/docs/encrypting-content.md @@ -61,20 +61,62 @@ generate_the_archive_key: `wrap` and `unwrap` are the usages, because protecting a content-encryption key is a wrap. A key without them is refused by name. +## Encrypting to a public key + +`recipient` in place of `encryption_key` addresses the content to a public key +whose private half is held elsewhere, the way `wrap_key` does: + +```yaml +seal_the_runbook_for_the_custodian: + action: encrypt_data + backend: openssl + reads: + data: ${artifact.runbook} + recipient: ${artifact.custodian_public_key} + with: + expect_recipient: "sha256:3b9c…" + creates: sealed_runbook +``` + +The recipient is an RSA-2048 or RSA-4096 key, in any shape `wrap_key` accepts +for a recipient: a key or certificate an earlier step produced, or DER or PEM +bytes carried in as a material. The step's backend supplies the random bytes of +the content-encryption key, and the public key protects it with RSAES-OAEP over +SHA-256. No key of the backend's is involved. + +`expect_recipient` is the recipient's `sha256:` fingerprint, written into the +definition before the ceremony. The step compares the key it receives against +it and refuses to run on a mismatch, since content sealed to the wrong key is +readable by that key's holder and by nobody the ceremony meant. Without it, the +transcript records whichever key arrived. + +The holder opens the result with `decrypt_data` and the private key in a +backend, or with `openssl cms -decrypt -inkey`. Nothing in the ceremony shows +they can. + ## The container `scheme` names the container. It defaults to `CMS-AES-256-GCM`, which is the only value `encrypt_data` accepts, and it is written out only where a ceremony wants the choice visible in the printed script. -The artifact is a CMS `AuthEnvelopedData` (RFC 5083) with a `KEKRecipientInfo` -(RFC 5652 §6.2.3), the content-encryption key under `id-aes256-wrap` and the -content under AES-256-GCM. `openssl cms -decrypt -secretkey -secretkeyid -` opens it, which is what makes the archive readable in a decade with no -Rite binary present. +The artifact is a CMS `AuthEnvelopedData` (RFC 5083) with the content under +AES-256-GCM and one recipient: + +- for `encryption_key`, a `KEKRecipientInfo` (RFC 5652 §6.2.3), the + content-encryption key under `id-aes256-wrap`. `openssl cms -decrypt + -secretkey -secretkeyid ` opens it. +- for `recipient`, a `KeyTransRecipientInfo` (RFC 5652 §6.2.1), the + content-encryption key under RSAES-OAEP with SHA-256 for the digest and for + MGF1. `openssl cms -decrypt -inkey ` opens it. -The recipient identifier is the key's check value, so the blob says which key -opens it without anyone holding a key. Write that value on the envelope: +Either is what makes the archive readable in a decade with no Rite binary +present. + +The recipient identifier names the key, so the blob says which key opens it +without anyone holding a key. A symmetric key is named by its check value, a +public key by the SHA-256 of its SPKI, which is its `sha256:` fingerprint. For +a symmetric key, write the check value on the envelope: ```yaml read_back_the_archive_key: @@ -87,6 +129,59 @@ read_back_the_archive_key: fails by name rather than as an authentication error that says nothing about why. +## A container sealed elsewhere + +`decrypt_data` also opens a CMS file brought into the room as a material, such +as a document someone sealed with `openssl cms` to a key the ceremony holds. + +```yaml +materials: + sealed_document: + type: digital + path: "media/document.p7m" + description: "Document sealed to the custodian's key." + +# ... + +open_the_sealed_document: + action: decrypt_data + backend: openssl + reads: + encrypted_data: ${artifact.sealed_document} + decryption_key: ${artifact.custodian_key} + creates: document +``` + +The file has to be an `AuthEnvelopedData` with AES-256-GCM content and one +recipient, in one of two forms: + +- a `KEKRecipientInfo` under `id-aes256-wrap`, for a 256-bit key the backend + holds, named by its check value; +- a `KeyTransRecipientInfo` under RSAES-OAEP with SHA-256 for the digest and for + MGF1, for an RSA-2048 or RSA-4096 key the backend holds with the `unwrap` + usage. The recipient is named by subject key identifier: either the SHA-256 of + the key's SPKI, which is how Rite names a key, or the SHA-1 of RFC 5280 + method 1, which is what a certificate's subject key identifier usually holds. + +The `openssl` command line writes the second form for a certificate when told to +use OAEP and to name the recipient by key identifier: + +```sh +openssl cms -encrypt -binary -aes-256-gcm -keyid -outform DER \ + -recip custodian.crt \ + -keyopt rsa_padding_mode:oaep -keyopt rsa_oaep_md:sha256 -keyopt rsa_mgf1_md:sha256 \ + -in document.pdf -out document.p7m +``` + +Without those options it writes forms `decrypt_data` refuses, by name: + +- RSA key transport under PKCS#1 v1.5, the default without `-keyopt`. Its + padding check is what Bleichenbacher's attack queries, so it is not opened. +- A recipient named by issuer and serial number, the default without `-keyid`. + That names a certificate, and the ceremony holds a key. +- `EnvelopedData` with CBC content, the default without `-aes-256-gcm`. Its + content is not authenticated. + ## What comes out `decrypt_data` declares nothing. Which container the bytes are in and how the @@ -115,13 +210,21 @@ either. An encrypted artifact is its own type, so it can be given to `decrypt_data` and not to `unwrap_key`. The reverse holds too. The bytes of the two are the same -shape and the claims are not, and the type is what keeps them apart. +shape and the claims are not, and the type is what keeps them apart. A +material carries no such claim, which is why `decrypt_data` reads one as a +container sealed elsewhere. ## What the transcript records -The scheme, the artifact both steps name, the check value of the key, the size -of the content, and, read back out of the DER, every algorithm identifier the -container names. +The scheme, the artifact both steps name, the key (its check value for a +symmetric key, the `sha256:` fingerprint of its public key for an RSA one), the +size of the content, and, read back out of the DER, every algorithm identifier +the container names. + +For `recipient`, the fingerprint is in a `wrap_recipient_recorded` fact of its +own, as for `wrap_key`, recorded before anything is encrypted and saying +whether the ceremony declared it. The fact is restricted, so a disclosure can +publish how the content was encrypted and withhold to whom. Deliberately no digest of the content, in either direction. Content is encrypted because it is secret, and a hash of a secret in a record is a thing an @@ -140,15 +243,11 @@ Containers: - the recorded algorithms against the ones in the DER; - the key the blob is addressed to against a key this ceremony generated or - imported. + imported, or, for `recipient`, against the recorded recipient, reported as + declared where the definition named it. An artifact that is not in the run directory is reported as unchecked, not as verified. That the content is what the ceremony meant to encrypt is outside the bundle. A recovery drill in the room, opening the archive again and comparing what comes out, is what covers that, and an attestation naming the drill is what records it happened in front of someone. - -## Not yet - -Encrypting to a public key, the way `wrap_key` does with `recipient`. Only a -symmetric key the backend holds can be an `encryption_key` today. diff --git a/docs/schema/ceremony.schema.json b/docs/schema/ceremony.schema.json index f911589..3baa37e 100644 --- a/docs/schema/ceremony.schema.json +++ b/docs/schema/ceremony.schema.json @@ -211,12 +211,12 @@ "const": "unwrap_key" }, { - "description": "Encrypt data using a key held by a backend.\n\nThe backend generates a content-encryption key, encrypts the data with it, and protects it with the step's encryption key. The data itself is not sent to the backend.", + "description": "Encrypt data for a key held by a backend or for a public key.\n\nReads `data` and exactly one of `encryption_key` or `recipient`. With `encryption_key`, the backend generates a content-encryption key and protects it with that key. With `recipient`, an RSA-2048 or RSA-4096 public key held elsewhere, the backend supplies the content-encryption key's random bytes, which are protected with RSAES-OAEP over SHA-256. The data itself is not sent to the backend.", "type": "string", "const": "encrypt_data" }, { - "description": "Decrypt data encrypted by `encrypt_data`.\n\nThe result is a byte artifact available to later steps and erased from memory when the run ends.", + "description": "Decrypt data encrypted by `encrypt_data`, or a CMS file sealed elsewhere and supplied as a material.\n\nThe step checks that the container names the decryption key. The result is a byte artifact available to later steps and erased from memory when the run ends.", "type": "string", "const": "decrypt_data" }, @@ -547,6 +547,21 @@ { "type": "object", "properties": { + "expect_recipient": { + "description": "The expected fingerprint of the recipient's public key, in the form `sha256:<64 lowercase hex digits>`, for a step that reads `recipient`. The step fails if the recipient key does not match.", + "anyOf": [ + { + "type": "string", + "errorMessage": "'expect_recipient' must be a fingerprint of the form 'sha256:<64 lowercase hex digits>'", + "pattern": "^sha256:[0-9a-f]{64}$", + "patternErrorMessage": "'expect_recipient' must be a fingerprint of the form 'sha256:<64 lowercase hex digits>'", + "x-message": "'expect_recipient' must be a fingerprint of the form 'sha256:<64 lowercase hex digits>'" + }, + { + "$ref": "#/$defs/Expression" + } + ] + }, "scheme": { "description": "The scheme used to encrypt the data. Defaults to `CMS-AES-256-GCM`.", "anyOf": [ @@ -2712,12 +2727,32 @@ }, "encryption_key": { "type": "string" + }, + "recipient": { + "type": "string" } }, "additionalProperties": false, + "allOf": [ + { + "errorMessage": "'reads' must name exactly one of 'encryption_key', 'recipient'", + "oneOf": [ + { + "required": [ + "encryption_key" + ] + }, + { + "required": [ + "recipient" + ] + } + ], + "x-message": "'reads' must name exactly one of 'encryption_key', 'recipient'" + } + ], "required": [ - "data", - "encryption_key" + "data" ] } }, @@ -2725,6 +2760,32 @@ "reads" ] }, + { + "if": { + "properties": { + "with": { + "type": "object", + "required": [ + "expect_recipient" + ] + } + }, + "required": [ + "with" + ] + }, + "then": { + "properties": { + "reads": { + "errorMessage": "'with.expect_recipient' applies only when 'encrypt_data' reads 'recipient', which this step does not", + "required": [ + "recipient" + ], + "x-message": "'with.expect_recipient' applies only when 'encrypt_data' reads 'recipient', which this step does not" + } + } + } + }, { "properties": { "with": { diff --git a/docs/schema/transcript.schema.json b/docs/schema/transcript.schema.json index f6db25d..cc7d903 100644 --- a/docs/schema/transcript.schema.json +++ b/docs/schema/transcript.schema.json @@ -803,7 +803,7 @@ ] }, { - "description": "A key held outside the ceremony received a wrapped key. A separate fact from the operation, so the method of a wrap can be published while the recipient stays withheld.", + "description": "A key held outside the ceremony received a wrapped key or encrypted content. A separate fact from the operation, so the method can be published while the recipient stays withheld.", "type": "object", "properties": { "declared": { @@ -819,7 +819,7 @@ "type": "string" }, "step": { - "description": "The step that wrapped the key.", + "description": "The step that wrapped the key or encrypted the content.", "$ref": "#/$defs/StepId" }, "type": { From d6db0b7ee4019b0929929f99d377a27baf9ae82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Sat, 31 Oct 2026 11:16:14 +0100 Subject: [PATCH 2/3] docs(openssl): name the content-encryption key and the OAEP seed's source --- crates/rite-openssl/src/backend.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/rite-openssl/src/backend.rs b/crates/rite-openssl/src/backend.rs index 369c5d2..1725ec2 100644 --- a/crates/rite-openssl/src/backend.rs +++ b/crates/rite-openssl/src/backend.rs @@ -1192,8 +1192,9 @@ fn rsa_oaep_decrypt( /// holder of the private key opens it with /// [`KeyTransportBackend::open_data_key`]. /// -/// The caller supplies the key, so its randomness comes from whichever source -/// the caller answers for rather than from this function. +/// The caller supplies the key's bytes, so the caller chooses their source. +/// The padding seed RSA-OAEP needs comes from OpenSSL's generator, as the +/// content nonce does. /// /// An RSA-2048 or RSA-4096 recipient takes RSAES-OAEP over SHA-256, which is /// what a CMS `KeyTransRecipientInfo` declares for it. Those are the RSA sizes @@ -1207,7 +1208,7 @@ fn rsa_oaep_decrypt( /// parsed. pub fn data_key_for_recipient( recipient: &PublicKeyDer, - plaintext: Zeroizing>, + cek: Zeroizing>, ) -> Result { let recipient = PKey::public_key_from_der(recipient.as_bytes()) .map_err(|e| ossl_err("Parse recipient public key", &e))?; @@ -1220,12 +1221,8 @@ pub fn data_key_for_recipient( ))); } - let wrapped = rsa_oaep_encrypt(&recipient, &plaintext)?; - Ok(DataKey::new( - plaintext, - wrapped, - KeyProtection::RsaOaepSha256, - )) + let wrapped = rsa_oaep_encrypt(&recipient, &cek)?; + Ok(DataKey::new(cek, wrapped, KeyProtection::RsaOaepSha256)) } /// SHA-256 for both the OAEP digest and MGF1. From eaf10f8678249dc93d91f1a65af54b1c4bd7caca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Sat, 31 Oct 2026 11:57:31 +0100 Subject: [PATCH 3/3] fix(sdk): one fingerprint per public key, and types for the names a key 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. --- Cargo.lock | 4 +- crates/rite-model/src/transcript.rs | 11 +- crates/rite-openssl/src/backend.rs | 67 ++--- crates/rite-openssl/tests/interop.rs | 25 +- crates/rite-runtime/src/display.rs | 4 +- crates/rite-sdk/Cargo.toml | 2 + crates/rite-sdk/src/cms.rs | 205 +++++++++++++--- crates/rite-sdk/src/key_material.rs | 99 ++++++-- crates/rite-sdk/src/lib.rs | 10 +- crates/rite-sdk/src/types.rs | 230 +++++++++++++++-- crates/rite-stdlib/Cargo.toml | 3 +- crates/rite-stdlib/src/crypto/decrypt_data.rs | 113 +++------ crates/rite-stdlib/src/crypto/encrypt_data.rs | 26 +- .../rite-stdlib/src/crypto/export_public.rs | 4 +- crates/rite-stdlib/src/crypto/generate_key.rs | 4 +- crates/rite-stdlib/src/crypto/import_key.rs | 4 +- crates/rite-stdlib/src/crypto/mod.rs | 29 ++- crates/rite-stdlib/src/crypto/unwrap_key.rs | 2 +- .../src/crypto/verify_signature.rs | 2 +- crates/rite-stdlib/src/crypto/wrap_key.rs | 6 +- crates/rite-stdlib/tests/actions.rs | 81 +++++- crates/rite/Cargo.toml | 1 - crates/rite/src/container_checks.rs | 231 ++++++++++++------ 23 files changed, 837 insertions(+), 326 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b557733..2de1ed2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2122,7 +2122,6 @@ name = "rite" version = "0.8.0" dependencies = [ "assert_cmd", - "base16ct 1.0.0", "chrono", "clap", "clap_complete", @@ -2260,11 +2259,13 @@ dependencies = [ name = "rite-sdk" version = "0.8.0" dependencies = [ + "base16ct 1.0.0", "base64ct", "bitflags", "schemars", "serde", "serde_json", + "sha2 0.11.1", "thiserror", "x509-cert 0.3.0", "zeroize", @@ -2286,7 +2287,6 @@ dependencies = [ "secrecy 0.10.3", "serde", "serde_json", - "sha2 0.11.1", "signature 3.0.0", "subtle", "sysinfo", diff --git a/crates/rite-model/src/transcript.rs b/crates/rite-model/src/transcript.rs index 6a30179..1f2c4b0 100644 --- a/crates/rite-model/src/transcript.rs +++ b/crates/rite-model/src/transcript.rs @@ -1289,7 +1289,7 @@ pub enum StepFact { ) )] source: String, - /// `sha256:` over the recipient's SPKI DER. + /// The recipient's fingerprint. #[cfg_attr( test, schemars( @@ -1298,7 +1298,7 @@ pub enum StepFact { with = "Sha256Digest" ) )] - fingerprint: String, + fingerprint: rite_sdk::KeyFingerprint, /// Whether the ceremony declared this fingerprint in advance, so the /// runtime checked the key it received against the definition rather /// than recording whatever arrived. @@ -2223,19 +2223,20 @@ mod schema_snapshot_tests { #[test] fn wrap_recipient_recorded() { - let fingerprint = Sha256Digest::of(b"escrow spki").to_string(); + let fingerprint: rite_sdk::KeyFingerprint = + Sha256Digest::of(b"escrow spki").as_str().parse().unwrap(); assert_json( &StepFact::WrapRecipientRecorded { step: StepId::new("s1"), source: "escrow_pubkey".to_string(), - fingerprint: fingerprint.clone(), + fingerprint, declared: true, }, &json!({ "type": "wrap_recipient_recorded", "step": "s1", "source": "escrow_pubkey", - "fingerprint": fingerprint, + "fingerprint": fingerprint.to_string(), "declared": true, }), ); diff --git a/crates/rite-openssl/src/backend.rs b/crates/rite-openssl/src/backend.rs index 1725ec2..07dbf2b 100644 --- a/crates/rite-openssl/src/backend.rs +++ b/crates/rite-openssl/src/backend.rs @@ -23,9 +23,10 @@ use openssl::symm::Cipher; use openssl::x509::{X509Builder, X509Extension, X509NameBuilder}; use rite_sdk::cms; use rite_sdk::{ - Backend, BackendError, DataKey, KeyAlgorithm, KeyCheckValue, KeyId, KeyMetadata, KeyPolicy, - KeyProtection, KeySpec, KeyStoreBackend, KeyTransportBackend, KeyUsages, PublicKeyDer, - RandomBackend, SignAlgorithm, SignBackend, VerifyBackend, WrapScheme, WrappedKey, + Backend, BackendError, DataKey, KeyAlgorithm, KeyCheckValue, KeyFingerprint, KeyId, + KeyMetadata, KeyPolicy, KeyProtection, KeySpec, KeyStoreBackend, KeyTransportBackend, + KeyUsages, PublicKeyDer, RandomBackend, SignAlgorithm, SignBackend, VerifyBackend, WrapScheme, + WrappedKey, }; use std::collections::HashMap; use zeroize::Zeroizing; @@ -978,21 +979,18 @@ fn throwaway_signer() -> Result, BackendError> { PKey::from_ec_key(key).map_err(|e| ossl_err("Wrap throwaway signer", &e)) } -/// The SHA-256 digest of a public key's SPKI DER. +/// A public key's fingerprint, taken the one way every fingerprint is. /// /// Used as the certificate's subject key identifier, which CMS then carries as -/// the recipient identifier. It is the same digest `compute_fingerprint` -/// records for a public key, so a transcript's recipient fingerprint and a +/// the recipient identifier, so a transcript's recipient fingerprint and a /// blob's `rid` are the same value and can be compared. -fn spki_digest( +fn fingerprint_of( subject_pub: &PKeyRef, -) -> Result, BackendError> { +) -> Result { let spki = subject_pub .public_key_to_der() .map_err(|e| ossl_err("Export subject public key", &e))?; - let digest = openssl::hash::hash(MessageDigest::sha256(), &spki) - .map_err(|e| ossl_err("Digest subject public key", &e))?; - Ok(digest.to_vec()) + Ok(PublicKeyDer::new(spki)?.fingerprint()) } /// Build an X.509 cert with the given subject public key, signed by the given signing key. @@ -1046,11 +1044,11 @@ fn build_cert( // extnValue for subjectKeyIdentifier is a DER OCTET STRING wrapping the // identifier, so the digest is prefixed with its own tag and length. - let key_identifier = spki_digest(subject_pub)?; - let length = u8::try_from(key_identifier.len()) + let key_identifier = fingerprint_of(subject_pub)?; + let length = u8::try_from(KeyFingerprint::LEN) .map_err(|_| BackendError::Other("subject key identifier is too long".to_string()))?; let mut extn_value = vec![0x04, length]; - extn_value.extend_from_slice(&key_identifier); + extn_value.extend_from_slice(key_identifier.as_bytes()); let extn_value = Asn1OctetString::new_from_bytes(&extn_value) .map_err(|e| ossl_err("Encode subject key identifier", &e))?; let ski_oid = Asn1Object::from_str(SUBJECT_KEY_IDENTIFIER_OID) @@ -1381,7 +1379,7 @@ fn cms_encrypt_kek(kek: &StoredKey, payload: &[u8]) -> Result Result Result, BackendError> { // A fresh content-encryption key per message, as RFC 5652 requires: the @@ -1411,7 +1409,7 @@ fn cms_seal_to_kek( let sealed = crate::content::seal_content(&cek, payload)?; let envelope = cms::Envelope { protection: KeyProtection::AesKeyWrap, - key_identifier: key_identifier.to_vec(), + key_identifier: check_value.into(), wrapped_cek: aes_key_wrap(secret, &cek, KeyWrap::Plain, true)?, nonce: sealed.nonce, ciphertext: sealed.ciphertext, @@ -1470,15 +1468,13 @@ fn cms_open_from_kek( // Not covered by the content tag, which authenticates the content and // nothing around it, so this comparison is what refuses a blob addressed - // elsewhere. Both values render the same way, or the message invites the - // reader to compare two things that are not alike. - if envelope.key_identifier != check_value.as_bytes() { - let named = KeyCheckValue::from_cmac(&envelope.key_identifier).map_or_else( - |_| base16ct::lower::encode_string(&envelope.key_identifier), - |kcv| kcv.to_string(), - ); + // elsewhere. The identifier is shown as the bytes it is: read as a check + // value, any identifier would look like one. + if !envelope.key_identifier.is_check_value(check_value) { return Err(BackendError::InvalidData(format!( - "this blob is addressed to the key with check value {named}" + "this blob is addressed to the key identified by {}, and the key held here has \ + check value {check_value}", + envelope.key_identifier ))); } @@ -2488,7 +2484,10 @@ mod tests { let kek_public = backend.export_public_key(&kek.key_id).unwrap(); let expected = openssl::hash::hash(MessageDigest::sha256(), kek_public.as_bytes()).unwrap(); assert_eq!( - facts.recipient_key_identifier.as_deref(), + facts + .recipient_key_identifier + .as_ref() + .map(cms::RecipientIdentifier::as_bytes), Some(&expected[..]) ); } @@ -3196,9 +3195,10 @@ mod tests { // The blob names the KEK by its check value, so a transcript and an // artifact compare without either side holding the key. - assert_eq!( - facts.recipient_key_identifier.as_deref(), - Some(kek.check_value.as_ref().unwrap().as_bytes().as_slice()) + assert!( + facts + .recipient_key_identifier + .is_some_and(|named| named.is_check_value(kek.check_value.as_ref().unwrap())) ); let restored = backend @@ -3483,7 +3483,9 @@ mod tests { ( "recipient identifier", cms::Envelope { - key_identifier: flip(&good.key_identifier), + key_identifier: cms::RecipientIdentifier::new(flip( + good.key_identifier.as_bytes(), + )), ..good.clone() }, ), @@ -4030,7 +4032,10 @@ mod tests { let kek_public = backend.export_public_key(&kek.key_id).unwrap(); let expected = openssl::hash::hash(MessageDigest::sha256(), kek_public.as_bytes()).unwrap(); assert_eq!( - facts.recipient_key_identifier.as_deref(), + facts + .recipient_key_identifier + .as_ref() + .map(cms::RecipientIdentifier::as_bytes), Some(&expected[..]) ); } diff --git a/crates/rite-openssl/tests/interop.rs b/crates/rite-openssl/tests/interop.rs index bd94b8a..58ace1a 100644 --- a/crates/rite-openssl/tests/interop.rs +++ b/crates/rite-openssl/tests/interop.rs @@ -503,7 +503,7 @@ fn the_openssl_cli_opens_content_sealed_to_a_data_key() { let kcv = kek.check_value.as_ref().expect("a symmetric key has one"); let der = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { protection: data_key.protection(), - key_identifier: kcv.as_bytes().to_vec(), + key_identifier: kcv.into(), wrapped_cek: data_key.wrapped().to_vec(), nonce: sealed.nonce, ciphertext: sealed.ciphertext, @@ -601,9 +601,11 @@ fn the_openssl_cli_opens_content_sealed_to_a_public_key() { let sealed = rite_openssl::seal_content(data_key.plaintext(), content).unwrap(); let der = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { protection: data_key.protection(), - key_identifier: openssl::hash::hash(MessageDigest::sha256(), &spki) - .unwrap() - .to_vec(), + key_identifier: rite_sdk::cms::RecipientIdentifier::new( + openssl::hash::hash(MessageDigest::sha256(), &spki) + .unwrap() + .to_vec(), + ), wrapped_cek: data_key.wrapped().to_vec(), nonce: sealed.nonce, ciphertext: sealed.ciphertext, @@ -726,10 +728,8 @@ fn rite_opens_content_the_openssl_cli_sealed_to_a_public_key() { assert_eq!(envelope.protection, rite_sdk::KeyProtection::RsaOaepSha256); let spki = recipient.public_key_to_der().unwrap(); assert_eq!( - envelope.key_identifier, - openssl::hash::hash(MessageDigest::sha256(), &spki) - .unwrap() - .to_vec(), + envelope.key_identifier.as_bytes(), + &openssl::hash::hash(MessageDigest::sha256(), &spki).unwrap()[..], "the CLI takes the rid from the certificate's subject key identifier" ); @@ -828,9 +828,12 @@ fn a_standard_certificate_names_its_key_by_the_rfc_5280_identifier() { let envelope = rite_sdk::cms::read_auth_enveloped(&std::fs::read(&blob).unwrap()).unwrap(); let public = PublicKeyDer::new(recipient.public_key_to_der().unwrap()).unwrap(); assert_eq!( - envelope.key_identifier, - public.rfc5280_key_identifier().unwrap(), + envelope.key_identifier.as_bytes(), + public.rfc5280_key_identifier().unwrap().as_bytes(), "the rid is the identifier the CLI wrote into the certificate" ); - assert_eq!(envelope.key_identifier.len(), 20, "a SHA-1 digest"); + assert!( + envelope.key_identifier.names_key(&public), + "and Rite recognises the key by it" + ); } diff --git a/crates/rite-runtime/src/display.rs b/crates/rite-runtime/src/display.rs index aefca67..a0dcef9 100644 --- a/crates/rite-runtime/src/display.rs +++ b/crates/rite-runtime/src/display.rs @@ -168,13 +168,13 @@ mod tests { let (_, line) = fact_summary(&StepFact::WrapRecipientRecorded { step: StepId::new("wrap"), source: "escrow_pubkey".to_string(), - fingerprint: "sha256:abcd".to_string(), + fingerprint: format!("sha256:{}", "ab".repeat(32)).parse().unwrap(), declared: false, }) .expect("the live feed surfaces the recipient"); assert_ne!(line, UNSUMMARISED); assert!(line.contains("escrow_pubkey"), "{line}"); - assert!(line.contains("sha256:abcd"), "{line}"); + assert!(line.contains("sha256:abab"), "{line}"); } #[test] diff --git a/crates/rite-sdk/Cargo.toml b/crates/rite-sdk/Cargo.toml index 4c91650..a604dac 100644 --- a/crates/rite-sdk/Cargo.toml +++ b/crates/rite-sdk/Cargo.toml @@ -17,6 +17,8 @@ serde_json = { workspace = true } thiserror = { workspace = true } bitflags = { workspace = true } x509-cert = { workspace = true } +sha2 = { workspace = true } +base16ct = { workspace = true } zeroize = { workspace = true } schemars = { workspace = true, optional = true } diff --git a/crates/rite-sdk/src/cms.rs b/crates/rite-sdk/src/cms.rs index 846a528..89d38fe 100644 --- a/crates/rite-sdk/src/cms.rs +++ b/crates/rite-sdk/src/cms.rs @@ -11,8 +11,13 @@ //! its first member. A foreign blob addressed to several recipients is //! described by the first of them, and the others are not reported. +use std::fmt; + use crate::der; -use crate::types::{KeyProtection, Oid, RecipientInfoKind, WrapDescription, oid}; +use crate::key_material::PublicKeyDer; +use crate::types::{ + KeyCheckValue, KeyFingerprint, KeyProtection, Oid, RecipientInfoKind, WrapDescription, oid, +}; /// `id-envelopedData` (RFC 5652 §6). const ENVELOPED_DATA: &str = "1.2.840.113549.1.7.3"; @@ -31,10 +36,95 @@ pub struct CmsReadError(String); pub struct CmsFacts { /// The algorithm identifiers, as recorded on the wrap. pub description: WrapDescription, - /// The recipient's subject key identifier, when the artifact names the - /// recipient that way. `None` when the recipient is named by issuer and - /// serial, which identifies a certificate rather than a key. - pub recipient_key_identifier: Option>, + /// What the artifact names its recipient by. `None` when the recipient is + /// named by issuer and serial, which identifies a certificate rather than + /// a key. + pub recipient_key_identifier: Option, +} + +/// The bytes a CMS container names its recipient by: `kekid.keyIdentifier` in +/// a `KEKRecipientInfo`, the `subjectKeyIdentifier` rid in a +/// `KeyTransRecipientInfo`. +/// +/// Opaque. A container Rite writes carries a [`KeyCheckValue`] or a +/// [`KeyFingerprint`] here, and one written elsewhere carries whatever its +/// writer chose, an [`Rfc5280KeyId`](crate::Rfc5280KeyId) more often than not. +/// Nothing in the bytes says which, their length included: 32 bytes need not +/// be a SHA-256, and a SHA-256 need not be over the SPKI. So the bytes are +/// never read as a name. They are compared with a name a key has: +/// [`names_key`](Self::names_key) with the key at hand, or +/// [`is_fingerprint`](Self::is_fingerprint) and +/// [`is_check_value`](Self::is_check_value) with a name the transcript +/// recorded. +/// +/// Rendered as bare hex, since a `sha256:` or `cmac-aes:` prefix would claim +/// to know what the bytes are. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecipientIdentifier(Vec); + +impl RecipientIdentifier { + /// Bytes a writer chose to name a recipient by. + /// + /// Nothing is checked, since nothing is claimed: the bytes name a key only + /// once they match one. + #[must_use] + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// The bytes as the container carries them. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Whether these bytes name `key`, by either name it goes by in a + /// container: its [`KeyFingerprint`], which Rite writes, or its + /// [`Rfc5280KeyId`](crate::Rfc5280KeyId), which a container sealed to a + /// certificate usually carries. + #[must_use] + pub fn names_key(&self, key: &PublicKeyDer) -> bool { + // The SPKI parsed when `key` was built, so the identifier derives; a + // key that somehow did not would not be named by it either. + self.is_fingerprint(&key.fingerprint()) + || key + .rfc5280_key_identifier() + .is_ok_and(|id| self.0 == id.as_bytes()) + } + + /// Whether these bytes are `fingerprint`. + /// + /// For comparing a container with a recorded name, where no key is at + /// hand. A container naming the same key by its RFC 5280 identifier does + /// not match, because that name cannot be derived from a fingerprint. + #[must_use] + pub fn is_fingerprint(&self, fingerprint: &KeyFingerprint) -> bool { + self.0 == fingerprint.as_bytes() + } + + /// Whether these bytes are `check_value`. + #[must_use] + pub fn is_check_value(&self, check_value: &KeyCheckValue) -> bool { + self.0 == check_value.as_bytes() + } +} + +impl From for RecipientIdentifier { + fn from(fingerprint: KeyFingerprint) -> Self { + Self(fingerprint.as_bytes().to_vec()) + } +} + +impl From<&KeyCheckValue> for RecipientIdentifier { + fn from(check_value: &KeyCheckValue) -> Self { + Self(check_value.as_bytes().to_vec()) + } +} + +impl fmt::Display for RecipientIdentifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&base16ct::lower::encode_string(&self.0)) + } } /// Read a CMS `ContentInfo` DER into the facts of its wrap. @@ -95,7 +185,7 @@ pub fn describe(der: &[u8]) -> Result { /// What one `RecipientInfo` contributes to the description. struct Recipient { description: WrapDescription, - key_identifier: Option>, + key_identifier: Option, } fn read_recipient(info: &Tlv<'_>) -> Result { @@ -112,7 +202,9 @@ fn read_recipient(info: &Tlv<'_>) -> Result { } Ok(Recipient { description: WrapDescription::new(RecipientInfoKind::Ktri, algorithm), - key_identifier: ktri.key_identifier.map(<[u8]>::to_vec), + key_identifier: ktri + .key_identifier + .map(|bytes| RecipientIdentifier(bytes.to_vec())), }) } TAG_CONTEXT_1_CONSTRUCTED => read_kari(info.value), @@ -120,7 +212,7 @@ fn read_recipient(info: &Tlv<'_>) -> Result { let kekri = read_kekri(info.value)?; Ok(Recipient { description: WrapDescription::new(RecipientInfoKind::Kekri, kekri.algorithm), - key_identifier: Some(kekri.key_identifier.to_vec()), + key_identifier: Some(RecipientIdentifier(kekri.key_identifier.to_vec())), }) } TAG_CONTEXT_3_CONSTRUCTED => Err(err("PasswordRecipientInfo is not a key wrap")), @@ -359,18 +451,18 @@ fn read_kari(body: &[u8]) -> Result { /// `RecipientEncryptedKeys`: a SEQUENCE OF `RecipientEncryptedKey`, whose rid /// is either an `IssuerAndSerialNumber` or `[0] RecipientKeyIdentifier`. -fn agreed_key_identifier(body: &[u8]) -> Result>, CmsReadError> { +fn agreed_key_identifier(body: &[u8]) -> Result, CmsReadError> { let (first, _) = read_tlv(body)?; let (rid, _) = read_tlv(first.expect_tag(TAG_SEQUENCE, "RecipientEncryptedKey")?)?; if rid.tag != TAG_CONTEXT_0_CONSTRUCTED { return Ok(None); } let (key_id, _) = read_tlv(rid.value)?; - Ok(Some( + Ok(Some(RecipientIdentifier( key_id .expect_tag(TAG_OCTET_STRING, "subjectKeyIdentifier")? .to_vec(), - )) + ))) } /// `OtherRecipientInfo`: an `oriType` OID and a value read according to it. @@ -418,7 +510,8 @@ fn read_kemri(body: &[u8]) -> Result { Ok(Recipient { description, - key_identifier: (rid.tag == TAG_CONTEXT_0_PRIMITIVE).then(|| rid.value.to_vec()), + key_identifier: (rid.tag == TAG_CONTEXT_0_PRIMITIVE) + .then(|| RecipientIdentifier(rid.value.to_vec())), }) } @@ -620,14 +713,15 @@ pub const GCM_ICV_LEN: usize = 16; /// them out again, and never holds a key. /// /// ``` -/// use rite_sdk::KeyProtection; /// use rite_sdk::cms::{self, Envelope}; +/// use rite_sdk::{KeyCheckValue, KeyProtection}; /// -/// // Values a backend produces: the CEK wrapped under the KEK, and the -/// // content under that CEK. +/// // Values a backend produces: the KEK's check value, the CEK wrapped under +/// // the KEK, and the content under that CEK. +/// let check_value: KeyCheckValue = "cmac-aes:763cbc".parse()?; /// let envelope = Envelope { /// protection: KeyProtection::AesKeyWrap, -/// key_identifier: vec![0x76, 0x3c, 0xbc], +/// key_identifier: (&check_value).into(), /// wrapped_cek: vec![0x11; 40], /// nonce: vec![0x22; 12], /// ciphertext: vec![0x33; 64], @@ -639,21 +733,23 @@ pub const GCM_ICV_LEN: usize = 16; /// /// // And the same bytes describe the wrap that produced them. /// let facts = cms::describe(&der)?; -/// assert_eq!( -/// facts.recipient_key_identifier.as_deref(), -/// Some(&[0x76, 0x3c, 0xbc][..]) +/// assert!( +/// facts +/// .recipient_key_identifier +/// .is_some_and(|named| named.is_check_value(&check_value)) /// ); -/// # Ok::<(), rite_sdk::cms::CmsReadError>(()) +/// # Ok::<(), Box>(()) /// ``` #[derive(Debug, Clone, PartialEq, Eq)] pub struct Envelope { /// How `wrapped_cek` was protected, which is also which recipient form /// carries it. pub protection: KeyProtection, - /// What names the recipient key. For a symmetric key its check value, in - /// `kekid.keyIdentifier`; for a public key the SHA-256 of its SPKI DER, as - /// the `subjectKeyIdentifier` rid. - pub key_identifier: Vec, + /// What names the recipient key. Rite writes a symmetric key's check value, + /// in `kekid.keyIdentifier`, and a public key's fingerprint, as the + /// `subjectKeyIdentifier` rid. A container read back carries what its + /// writer put there. + pub key_identifier: RecipientIdentifier, /// The content-encryption key, as `protection` left it. pub wrapped_cek: Vec, /// The 12-byte AES-GCM nonce. @@ -732,7 +828,7 @@ fn recipient_info(envelope: &Envelope) -> Result, CmsReadError> { TAG_CONTEXT_2_CONSTRUCTED, &[ der::small_integer(4), - der::sequence(&[der::octet_string(&envelope.key_identifier)]), + der::sequence(&[der::octet_string(envelope.key_identifier.as_bytes())]), der::sequence(&[encoded_oid(oid::AES_256_WRAP)?]), der::octet_string(&envelope.wrapped_cek), ] @@ -743,7 +839,7 @@ fn recipient_info(envelope: &Envelope) -> Result, CmsReadError> { // names the key itself, so no certificate is needed to address it. KeyProtection::RsaOaepSha256 => Ok(der::sequence(&[ der::small_integer(2), - der::tlv(TAG_CONTEXT_0_PRIMITIVE, &envelope.key_identifier), + der::tlv(TAG_CONTEXT_0_PRIMITIVE, envelope.key_identifier.as_bytes()), rsaes_oaep_sha256()?, der::octet_string(&envelope.wrapped_cek), ])), @@ -861,7 +957,7 @@ pub fn read_auth_enveloped(der_bytes: &[u8]) -> Result { Ok(Envelope { protection, - key_identifier: key_identifier.to_vec(), + key_identifier: RecipientIdentifier(key_identifier.to_vec()), wrapped_cek: wrapped_cek.to_vec(), nonce, ciphertext, @@ -1037,7 +1133,7 @@ mod tests { fn sample_envelope() -> Envelope { Envelope { protection: KeyProtection::AesKeyWrap, - key_identifier: vec![0x76, 0x3c, 0xbc], + key_identifier: RecipientIdentifier(vec![0x76, 0x3c, 0xbc]), wrapped_cek: vec![0x11; 40], nonce: vec![0x22; 12], ciphertext: vec![0x33; 64], @@ -1078,7 +1174,7 @@ mod tests { ); assert_eq!( facts.recipient_key_identifier, - Some(vec![0x76, 0x3c, 0xbc]), + Some(RecipientIdentifier(vec![0x76, 0x3c, 0xbc])), "the recipient is named by the key's check value" ); } @@ -1143,7 +1239,7 @@ mod tests { fn key_transport_envelope() -> Envelope { Envelope { protection: KeyProtection::RsaOaepSha256, - key_identifier: vec![0x89; 32], + key_identifier: RecipientIdentifier(vec![0x89; 32]), wrapped_cek: vec![0x11; 256], ..sample_envelope() } @@ -1172,7 +1268,10 @@ mod tests { oid::RSAES_OAEP ); assert_eq!(facts.description.kek_wrap_oid, None); - assert_eq!(facts.recipient_key_identifier, Some(vec![0x89; 32])); + assert_eq!( + facts.recipient_key_identifier, + Some(RecipientIdentifier(vec![0x89; 32])) + ); assert!(crate::types::WrapScheme::CmsAes256Gcm.permits(&facts.description)); } @@ -1204,7 +1303,7 @@ mod tests { let envelope = key_transport_envelope(); let ktri = der::sequence(&[ der::small_integer(2), - der::tlv(TAG_CONTEXT_0_PRIMITIVE, &envelope.key_identifier), + der::tlv(TAG_CONTEXT_0_PRIMITIVE, envelope.key_identifier.as_bytes()), algorithm, der::octet_string(&envelope.wrapped_cek), ]); @@ -1293,6 +1392,44 @@ mod tests { } } + /// A container's identifier names a key by its fingerprint or its RFC 5280 + /// identifier, and by no other digest of it. + #[test] + fn a_recipient_identifier_names_a_key_by_either_of_its_names_only() { + use sha2::Digest as _; + use x509_cert::der::Decode as _; + + let key = + crate::CertificateDer::new(include_bytes!("../tests/vectors/p256.cert.der").to_vec()) + .unwrap() + .public_key() + .unwrap(); + let other = + PublicKeyDer::new(include_bytes!("../tests/vectors/p384.spki.der").to_vec()).unwrap(); + + let by_fingerprint = RecipientIdentifier::from(key.fingerprint()); + let by_rfc5280 = + RecipientIdentifier(key.rfc5280_key_identifier().unwrap().as_bytes().to_vec()); + assert!(by_fingerprint.names_key(&key)); + assert!(by_rfc5280.names_key(&key)); + assert!(!by_fingerprint.names_key(&other)); + assert!(!by_rfc5280.names_key(&other)); + + // 32 bytes derived from the key are not its fingerprint unless they + // are that digest: here the SHA-256 of the key bits alone. + let bits = x509_cert::spki::SubjectPublicKeyInfoRef::from_der(key.as_bytes()) + .unwrap() + .subject_public_key + .raw_bytes() + .to_vec(); + let lookalike = RecipientIdentifier(sha2::Sha256::digest(&bits).to_vec()); + assert!(!lookalike.names_key(&key)); + + // Without the key, only the fingerprint can be compared. + assert!(by_fingerprint.is_fingerprint(&key.fingerprint())); + assert!(!by_rfc5280.is_fingerprint(&key.fingerprint())); + } + /// A `KEKRecipientInfo` is read as exactly as a `KeyTransRecipientInfo`: /// version 4, nothing after `encryptedKey`, and in `kekid` only the two /// optional fields RFC 5652 defines. @@ -1300,7 +1437,7 @@ mod tests { fn a_kek_recipient_is_read_exactly() { let envelope = sample_envelope(); let kekri = |version: u8, kekid_extra: &[Vec], trailing: &[u8]| { - let mut kekid = vec![der::octet_string(&envelope.key_identifier)]; + let mut kekid = vec![der::octet_string(envelope.key_identifier.as_bytes())]; kekid.extend_from_slice(kekid_extra); let mut body = [ der::small_integer(version), @@ -1344,7 +1481,7 @@ mod tests { let ktri = |version: u8, algorithm: Vec, trailing: &[u8]| { let mut body = [ der::small_integer(version), - der::tlv(TAG_CONTEXT_0_PRIMITIVE, &envelope.key_identifier), + der::tlv(TAG_CONTEXT_0_PRIMITIVE, envelope.key_identifier.as_bytes()), algorithm, der::octet_string(&envelope.wrapped_cek), ] diff --git a/crates/rite-sdk/src/key_material.rs b/crates/rite-sdk/src/key_material.rs index 3e88371..934e517 100644 --- a/crates/rite-sdk/src/key_material.rs +++ b/crates/rite-sdk/src/key_material.rs @@ -23,12 +23,12 @@ use x509_cert::der::oid::db::rfc5912::{ ID_EC_PUBLIC_KEY, RSA_ENCRYPTION, SECP_256_R_1, SECP_384_R_1, }; use x509_cert::der::oid::db::rfc8410::ID_ED_25519; -use x509_cert::der::{Decode, Encode, Reader, SliceReader}; +use x509_cert::der::{AnyRef, Decode, Encode, Reader, SliceReader}; use x509_cert::ext::pkix::SubjectKeyIdentifier; -use x509_cert::spki::SubjectPublicKeyInfoRef; +use x509_cert::spki::{AlgorithmIdentifierRef, SubjectPublicKeyInfoRef}; use crate::backend::BackendError; -use crate::types::KeyAlgorithm; +use crate::types::{KeyAlgorithm, KeyFingerprint, Rfc5280KeyId}; /// A public key in `SubjectPublicKeyInfo` (SPKI) DER form. /// @@ -39,20 +39,42 @@ use crate::types::KeyAlgorithm; pub struct PublicKeyDer(Vec); impl PublicKeyDer { - /// Wrap SPKI DER bytes after checking they parse. + /// Wrap SPKI DER bytes after checking they parse, in canonical form. /// /// Takes ownership because callers hold the bytes a backend or a parser /// just produced, and the type keeps them. /// + /// DER gives one structure one encoding, but an RSA SPKI has two + /// structures: RFC 3279 §2.3.1 gives `rsaEncryption` NULL parameters, and + /// readers, OpenSSL among them, also accept the parameters absent. Both are + /// one key, and a [`KeyFingerprint`] of each would differ, so absent + /// parameters are written as NULL here. The result is the encoding + /// OpenSSL exports, which is what a backend holding the private half + /// reports. + /// /// # Errors /// /// Returns [`BackendError::InvalidKeyFormat`] when the bytes are not a /// `SubjectPublicKeyInfo`, which is what a certificate or a PEM armouring /// arriving here looks like. pub fn new(der: Vec) -> Result { - SubjectPublicKeyInfoRef::from_der(&der).map_err(|e| { + let spki = SubjectPublicKeyInfoRef::from_der(&der).map_err(|e| { BackendError::InvalidKeyFormat(format!("not a SubjectPublicKeyInfo: {e}")) })?; + if spki.algorithm.oid == RSA_ENCRYPTION && spki.algorithm.parameters.is_none() { + let canonical = SubjectPublicKeyInfoRef { + algorithm: AlgorithmIdentifierRef { + oid: RSA_ENCRYPTION, + parameters: Some(AnyRef::NULL), + }, + subject_public_key: spki.subject_public_key, + } + .to_der() + .map_err(|e| { + BackendError::InvalidKeyFormat(format!("RSA public key cannot be re-encoded: {e}")) + })?; + return Ok(Self(canonical)); + } Ok(Self(der)) } @@ -105,25 +127,29 @@ impl PublicKeyDer { &self.0 } - /// The key identifier X.509 tooling derives for this key: the SHA-1 of the - /// `subjectPublicKey` bits, RFC 5280 §4.2.1.2 method 1. - /// - /// What a certificate's subject key identifier usually holds, and so what - /// `openssl cms -encrypt -keyid` names a recipient by. Rite's own name for a - /// key is the SHA-256 of the whole SPKI. This one is for recognising a key - /// in a structure someone else wrote. + /// Rite's name for this key. + #[must_use] + pub fn fingerprint(&self) -> KeyFingerprint { + KeyFingerprint::of_spki(&self.0) + } + + /// The name X.509 tooling derives for this key, for recognising it in a + /// structure someone else wrote. /// /// # Errors /// /// Returns [`BackendError::InvalidKeyFormat`] if the SPKI cannot be read, /// which every constructor already rules out. - pub fn rfc5280_key_identifier(&self) -> Result, BackendError> { + pub fn rfc5280_key_identifier(&self) -> Result { let spki = SubjectPublicKeyInfoRef::from_der(&self.0) .map_err(|e| BackendError::InvalidKeyFormat(format!("SPKI is unreadable: {e}")))?; let identifier = SubjectKeyIdentifier::try_from(spki).map_err(|e| { BackendError::InvalidKeyFormat(format!("key identifier cannot be derived: {e}")) })?; - Ok(identifier.0.as_bytes().to_vec()) + let bytes = identifier.0.as_bytes().try_into().map_err(|_| { + BackendError::InvalidKeyFormat("key identifier is not a SHA-1 digest".to_string()) + })?; + Ok(Rfc5280KeyId::new(bytes)) } /// The key algorithm, read from the key's own structure. @@ -362,8 +388,8 @@ mod tests { .rfc5280_key_identifier() .unwrap(); assert_eq!( - identifier, - [ + identifier.as_bytes(), + &[ 0x6f, 0x60, 0xee, 0x1f, 0xc5, 0x1e, 0x6e, 0xce, 0xad, 0xba, 0x96, 0xa2, 0x3d, 0xa0, 0x40, 0xaf, 0x22, 0x0a, 0x09, 0xa6 ] @@ -508,6 +534,47 @@ mod tests { assert!(err.to_string().contains("PUBLIC KEY"), "{err}"); } + /// The vector with its NULL parameters left out, which OpenSSL also reads. + fn rsa_without_parameters() -> Vec { + let spki = SubjectPublicKeyInfoRef::from_der(vectors::RSA_2048).unwrap(); + SubjectPublicKeyInfoRef { + algorithm: AlgorithmIdentifierRef { + oid: RSA_ENCRYPTION, + parameters: None, + }, + subject_public_key: spki.subject_public_key, + } + .to_der() + .unwrap() + } + + #[test] + fn an_rsa_key_without_parameters_is_kept_as_openssl_exports_it() { + let supplied = rsa_without_parameters(); + assert_ne!(supplied, vectors::RSA_2048); + + let key = PublicKeyDer::new(supplied).unwrap(); + assert_eq!(key.as_bytes(), vectors::RSA_2048); + assert_eq!( + key.fingerprint(), + PublicKeyDer::new(vectors::RSA_2048.to_vec()) + .unwrap() + .fingerprint() + ); + } + + #[test] + fn a_fingerprint_is_the_sha256_of_the_spki() { + use sha2::Digest as _; + let key = PublicKeyDer::new(vectors::P256.to_vec()).unwrap(); + let digest: [u8; 32] = sha2::Sha256::digest(vectors::P256).into(); + assert_eq!(key.fingerprint().as_bytes(), &digest); + assert_eq!( + key.fingerprint().to_string(), + format!("sha256:{}", base16ct::lower::encode_string(&digest)) + ); + } + #[test] fn bit_length_counts_from_the_top_set_bit() { assert_eq!(bit_length(&[]), 0); diff --git a/crates/rite-sdk/src/lib.rs b/crates/rite-sdk/src/lib.rs index 3f63f9d..523d35e 100644 --- a/crates/rite-sdk/src/lib.rs +++ b/crates/rite-sdk/src/lib.rs @@ -41,9 +41,9 @@ pub use backend::{ pub use types::{ Attestation, AttestationKind, BackendConfig, CertRef, DataKey, DeviceInfo, EncryptedData, - IncoherentContainer, KeyAlgorithm, KeyCheckValue, KeyId, KeyMetadata, KeyPolicy, KeyProtection, - KeySecurityAttributes, KeySpec, KeyUsages, Oid, ParseError, PcrValue, PivDeviceInfo, - PivKeyOrigin, PivPinPolicy, PivSlot, PivSlotInfo, PivTouchPolicy, Pkcs11Mechanism, - Pkcs11TokenFlags, Pkcs11TokenInfo, RecipientInfoKind, SignAlgorithm, TpmInfo, WrapDescription, - WrapScheme, WrappedKey, YubikeySlotMetadata, oid, + IncoherentContainer, KeyAlgorithm, KeyCheckValue, KeyFingerprint, KeyId, KeyMetadata, + KeyPolicy, KeyProtection, KeySecurityAttributes, KeySpec, KeyUsages, Oid, ParseError, PcrValue, + PivDeviceInfo, PivKeyOrigin, PivPinPolicy, PivSlot, PivSlotInfo, PivTouchPolicy, + Pkcs11Mechanism, Pkcs11TokenFlags, Pkcs11TokenInfo, RecipientInfoKind, Rfc5280KeyId, + SignAlgorithm, TpmInfo, WrapDescription, WrapScheme, WrappedKey, YubikeySlotMetadata, oid, }; diff --git a/crates/rite-sdk/src/types.rs b/crates/rite-sdk/src/types.rs index 2bc6949..a372a65 100644 --- a/crates/rite-sdk/src/types.rs +++ b/crates/rite-sdk/src/types.rs @@ -4,6 +4,7 @@ //! algorithm identifiers, attestation evidence, and hardware-specific metadata. use serde::{Deserialize, Serialize}; +use sha2::Digest as _; use std::fmt; use crate::backend::BackendError; @@ -144,11 +145,12 @@ impl KeyCheckValue { impl fmt::Display for KeyCheckValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}:", Self::METHOD)?; - for byte in self.0 { - write!(f, "{byte:02x}")?; - } - Ok(()) + write!( + f, + "{}:{}", + Self::METHOD, + base16ct::lower::encode_string(&self.0) + ) } } @@ -156,24 +158,26 @@ impl std::str::FromStr for KeyCheckValue { type Err = ParseError; fn from_str(s: &str) -> Result { - let hex = s - .strip_prefix(Self::METHOD) - .and_then(|rest| rest.strip_prefix(':')) - // Lowercase only, so the string a `KeyCheckValue` parses from is - // the string it renders back to and two records of one key - // compare as text. - .filter(|hex| { - hex.len() == 2 * Self::LEN - && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) - }) - .ok_or_else(|| ParseError(s.to_owned()))?; - let (pairs, _) = hex.as_bytes().as_chunks::<2>(); - let mut bytes = [0u8; Self::LEN]; - for (byte, pair) in bytes.iter_mut().zip(pairs) { - let pair = std::str::from_utf8(pair).map_err(|_| ParseError(s.to_owned()))?; - *byte = u8::from_str_radix(pair, 16).map_err(|_| ParseError(s.to_owned()))?; - } - Ok(Self(bytes)) + parse_prefixed(s, Self::METHOD).map(Self) + } +} + +/// The bytes of `:`, exactly `N` of them. +/// +/// Lowercase only, so the string a value parses from is the string it renders +/// back to, and two records of one key compare as text. +fn parse_prefixed(s: &str, method: &str) -> Result<[u8; N], ParseError> { + let mut bytes = [0u8; N]; + // `decode` refuses uppercase, an odd length and more than `N` bytes; fewer + // come back as a shorter slice. + let decoded = s + .strip_prefix(method) + .and_then(|rest| rest.strip_prefix(':')) + .and_then(|hex| base16ct::lower::decode(hex, &mut bytes).ok()) + .map(<[u8]>::len); + match decoded { + Some(len) if len == N => Ok(bytes), + _ => Err(ParseError(s.to_owned())), } } @@ -191,6 +195,149 @@ impl TryFrom for KeyCheckValue { } } +/// The name Rite gives a public key: the SHA-256 of its SPKI DER. +/// +/// One value serves three readers. A person compares it, in +/// `expect_recipient:` or on screen. The transcript records it, as +/// `public_key_fingerprint` or in `wrap_recipient_recorded`. And a CMS +/// container Rite writes carries its bytes as the recipient's subject key +/// identifier, so `rite verify` matches a container to a recorded key without +/// holding the key. +/// +/// Only [`PublicKeyDer::fingerprint`] computes one, over the encoding +/// [`PublicKeyDer::new`] keeps. Parsing accepts the lowercase form only, so +/// the string a fingerprint parses from is the string it renders, and two +/// records of one key compare as text. +/// +/// What it does not do: +/// +/// - It names an encoding of a key. `PublicKeyDer` settles the one choice an +/// RSA SPKI leaves open, but an EC point may be compressed or not, and the +/// two encodings of one EC key have two fingerprints. +/// - It is not the name X.509 tooling uses for a key; that is +/// [`Rfc5280KeyId`]. +/// - 32 bytes in a container written elsewhere are not a fingerprint until +/// they match a key. They are a +/// [`RecipientIdentifier`](crate::cms::RecipientIdentifier) until then. +/// +/// ``` +/// use rite_sdk::KeyFingerprint; +/// +/// let text = format!("sha256:{}", "ab".repeat(32)); +/// let fingerprint: KeyFingerprint = text.parse()?; +/// assert_eq!(fingerprint.to_string(), text); +/// assert_eq!(fingerprint.as_bytes(), &[0xab; 32]); +/// +/// // Uppercase hex is a different string for the same bytes, so it is not one. +/// let upper = format!("sha256:{}", "AB".repeat(32)); +/// assert!(upper.parse::().is_err()); +/// # Ok::<(), rite_sdk::ParseError>(()) +/// ``` +#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(into = "String", try_from = "String")] +pub struct KeyFingerprint([u8; Self::LEN]); + +impl KeyFingerprint { + /// Bytes in a SHA-256 digest. + pub const LEN: usize = 32; + + /// The hash, and the string's prefix. + pub const METHOD: &'static str = "sha256"; + + /// The fingerprint of SPKI DER bytes that [`PublicKeyDer`] has + /// already put in its canonical form. + pub(crate) fn of_spki(der: &[u8]) -> Self { + Self(sha2::Sha256::digest(der).into()) + } + + /// The digest bytes, which is also the subject key identifier a container + /// Rite writes names the key by. + #[must_use] + pub fn as_bytes(&self) -> &[u8; Self::LEN] { + &self.0 + } +} + +impl fmt::Display for KeyFingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{}", + Self::METHOD, + base16ct::lower::encode_string(&self.0) + ) + } +} + +/// The text form, so a failed comparison shows the value a reader would look +/// up in a transcript. +impl fmt::Debug for KeyFingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "KeyFingerprint({self})") + } +} + +impl std::str::FromStr for KeyFingerprint { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + parse_prefixed(s, Self::METHOD).map(Self) + } +} + +impl From for String { + fn from(fingerprint: KeyFingerprint) -> String { + fingerprint.to_string() + } +} + +impl TryFrom for KeyFingerprint { + type Error = ParseError; + + fn try_from(s: String) -> Result { + s.parse() + } +} + +/// The name X.509 tooling gives a public key: the SHA-1 of its +/// `subjectPublicKey` bits, RFC 5280 §4.2.1.2 method 1. +/// +/// What a certificate's subject key identifier usually holds, and so what +/// `openssl cms -encrypt -keyid` names a recipient by. Rite reads this name and +/// never writes it. It hashes the key without its algorithm identifier, with +/// SHA-1, so it is not a [`KeyFingerprint`] and the transcript records none. +/// +/// Method 1 is a convention, not a rule. RFC 5280 also describes a truncated +/// method 2, and a CA may put any value in a subject key identifier, so a +/// container naming its recipient another way does not match this name. +/// +/// Rendered as bare hex. It has no prefix of its own in Rite, because no +/// record holds one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rfc5280KeyId([u8; Self::LEN]); + +impl Rfc5280KeyId { + /// Bytes in a SHA-1 digest. + pub const LEN: usize = 20; + + /// Keep a method 1 identifier [`PublicKeyDer`] has derived. + pub(crate) fn new(bytes: [u8; Self::LEN]) -> Self { + Self(bytes) + } + + /// The identifier bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8; Self::LEN] { + &self.0 + } +} + +impl fmt::Display for Rfc5280KeyId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&base16ct::lower::encode_string(&self.0)) + } +} + /// Key algorithm. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(into = "String", try_from = "String")] @@ -2033,4 +2180,41 @@ mod tests { "signing-only default must not permit wrapping" ); } + + /// Each name parses from exactly the string it renders, and from nothing + /// that differs from it in case, length or prefix. + #[test] + fn a_key_name_parses_only_from_the_string_it_renders() { + fn refuses(text: &str) -> bool { + text.parse::().is_err() + } + + let fingerprint = format!("sha256:{}", "0a".repeat(32)); + let parsed: KeyFingerprint = fingerprint.parse().unwrap(); + assert_eq!(parsed.to_string(), fingerprint); + for bad in [ + format!("sha256:{}", "0A".repeat(32)), + format!("sha256:{}", "0a".repeat(31)), + format!("sha256:{}", "0a".repeat(33)), + format!("sha256:{}0", "0a".repeat(31)), + format!("sha256{}", "0a".repeat(32)), + format!("SHA256:{}", "0a".repeat(32)), + "0a".repeat(32), + ] { + assert!(refuses::(&bad), "{bad}"); + } + + let check_value: KeyCheckValue = "cmac-aes:0a1b2c".parse().unwrap(); + assert_eq!(check_value.to_string(), "cmac-aes:0a1b2c"); + for bad in [ + "cmac-aes:0A1B2C", + "cmac-aes:0a1b", + "cmac-aes:0a1b2c3d", + "cmac-aes:0a1b2", + "cmac-aes0a1b2c", + "0a1b2c", + ] { + assert!(refuses::(bad), "{bad}"); + } + } } diff --git a/crates/rite-stdlib/Cargo.toml b/crates/rite-stdlib/Cargo.toml index cfde65f..7ddc1e9 100644 --- a/crates/rite-stdlib/Cargo.toml +++ b/crates/rite-stdlib/Cargo.toml @@ -40,8 +40,6 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } chrono = { workspace = true } -base16ct = { workspace = true } -sha2 = { workspace = true } zeroize = { workspace = true } secrecy = { workspace = true } @@ -53,6 +51,7 @@ signature = { workspace = true, optional = true } [dev-dependencies] rite-openssl = { workspace = true } openssl = { workspace = true } +base16ct = { workspace = true } [lints] workspace = true diff --git a/crates/rite-stdlib/src/crypto/decrypt_data.rs b/crates/rite-stdlib/src/crypto/decrypt_data.rs index 49e4dbe..9abe499 100644 --- a/crates/rite-stdlib/src/crypto/decrypt_data.rs +++ b/crates/rite-stdlib/src/crypto/decrypt_data.rs @@ -5,10 +5,11 @@ use rite_runtime::{ Action, ActionError, ArtifactValue, BackendKeyMeta, HandlerContext, Icon, Reporter, StepInfo, StepResult, compute_fingerprint, resolve_backend_key, }; -use rite_sdk::{Backend, EncryptedData, KeyCheckValue, KeyProtection, WrapScheme, cms}; +use rite_sdk::{ + Backend, EncryptedData, KeyCheckValue, KeyProtection, PublicKeyDer, WrapScheme, cms, +}; use secrecy::SecretBox; use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; use crate::crypto::content::{self, SealedContent}; @@ -203,27 +204,21 @@ fn container_from_material( }) } -/// The decryption key, by the names a container may give its recipient. -enum KeyName { +/// The decryption key, in the form a container's recipient identifier is +/// matched against. +enum KeyName<'a> { /// A symmetric key, named by its check value. CheckValue(KeyCheckValue), - /// A public key. Rite names it by the SHA-256 of its SPKI, which is what - /// the transcript records and what `encrypt_data` writes. X.509 tooling - /// names it by the SHA-1 of RFC 5280 method 1, which is what a certificate's - /// subject key identifier usually holds, so a container sealed to a - /// certificate with `openssl cms -keyid` names it that way. - PublicKey { - fingerprint: String, - spki_digest: Vec, - rfc5280: Vec, - }, + /// A public key, which a container may name by its fingerprint or by its + /// RFC 5280 identifier. + PublicKey(&'a PublicKeyDer), } -impl KeyName { +impl<'a> KeyName<'a> { /// The names `key` answers to, for a container whose content key is /// protected with `protection`. fn of( - key: &BackendKeyMeta<'_>, + key: &BackendKeyMeta<'a>, protection: KeyProtection, display_name: &str, ) -> Result { @@ -255,13 +250,7 @@ impl KeyName { key.algorithm )) })?; - Ok(Self::PublicKey { - fingerprint: compute_fingerprint(public_key.as_bytes()), - spki_digest: Sha256::digest(public_key.as_bytes()).to_vec(), - rfc5280: public_key - .rfc5280_key_identifier() - .map_err(|e| ActionError::Failed(e.to_string()))?, - }) + Ok(Self::PublicKey(public_key)) } other => Err(ActionError::Failed(format!( "This container protects its content key with {other}, which decrypt_data does \ @@ -271,36 +260,36 @@ impl KeyName { } /// Whether the container's recipient identifier names this key. - fn answers_to(&self, identifier: &[u8]) -> bool { + fn answers_to(&self, identifier: &cms::RecipientIdentifier) -> bool { match self { - Self::CheckValue(check_value) => identifier == check_value.as_bytes(), - Self::PublicKey { - spki_digest, - rfc5280, - .. - } => identifier == spki_digest.as_slice() || identifier == rfc5280.as_slice(), + Self::CheckValue(check_value) => identifier.is_check_value(check_value), + Self::PublicKey(public_key) => identifier.names_key(public_key), } } - /// Why a container naming `identifier` is not this key's, with both sides - /// rendered alike. - fn refusal(&self, identifier: &[u8], display_name: &str) -> String { + /// Why a container naming `identifier` is not this key's. + /// + /// The identifier is shown as the bytes it is, and the key by the names a + /// container could give it, in the same hex, so the reader compares like + /// with like. Read as a check value or a fingerprint, any identifier would + /// look like one. + fn refusal(&self, identifier: &cms::RecipientIdentifier, display_name: &str) -> String { match self { Self::CheckValue(check_value) => format!( - "This artifact is addressed to the key with check value {}, and \ - '{display_name}' has {check_value}", - named_check_value(identifier) - ), - Self::PublicKey { - fingerprint, - rfc5280, - .. - } => format!( - "This artifact is addressed to the key with subject key identifier {}, and \ - '{display_name}' is {fingerprint}, or {} by RFC 5280", - named_key_identifier(identifier), - base16ct::lower::encode_string(rfc5280) + "This artifact is addressed to the key identified by {identifier}, and \ + '{display_name}' has check value {check_value}" ), + Self::PublicKey(public_key) => { + let rfc5280 = public_key.rfc5280_key_identifier().map_or_else( + |_| String::new(), + |id| format!(", or by {id}, its RFC 5280 identifier"), + ); + format!( + "This artifact is addressed to the key identified by {identifier}, and \ + '{display_name}' is identified by {}, its fingerprint{rfc5280}", + cms::RecipientIdentifier::from(public_key.fingerprint()) + ) + } } } @@ -308,7 +297,7 @@ impl KeyName { fn field(&self) -> &'static str { match self { Self::CheckValue(_) => "decryption_key_check_value", - Self::PublicKey { .. } => "decryption_key_fingerprint", + Self::PublicKey(_) => "decryption_key_fingerprint", } } @@ -316,35 +305,7 @@ impl KeyName { fn recorded(&self) -> String { match self { Self::CheckValue(check_value) => check_value.to_string(), - Self::PublicKey { fingerprint, .. } => fingerprint.clone(), + Self::PublicKey(public_key) => public_key.fingerprint().to_string(), } } } - -/// A subject key identifier, as a `sha256:` fingerprint where it is the length -/// of one and as hex otherwise, so a SHA-1 identifier is not presented as a -/// fingerprint the transcript could hold. -fn named_key_identifier(identifier: &[u8]) -> String { - let hex = base16ct::lower::encode_string(identifier); - if identifier.len() == 32 { - format!("sha256:{hex}") - } else { - hex - } -} - -/// A `kekid.keyIdentifier`, as a check value where the bytes are one, so the -/// message invites a reader to compare two values that are alike, and hex -/// otherwise rather than a claim to be a check value it is not. -fn named_check_value(identifier: &[u8]) -> String { - // The length is the whole of what distinguishes the two, since - // `from_cmac` keeps the leftmost three bytes of anything at least that - // long. Without this a twenty-byte subject key identifier would be - // announced as a check value it is not. - if identifier.len() == KeyCheckValue::LEN - && let Ok(kcv) = KeyCheckValue::from_cmac(identifier) - { - return kcv.to_string(); - } - base16ct::lower::encode_string(identifier) -} diff --git a/crates/rite-stdlib/src/crypto/encrypt_data.rs b/crates/rite-stdlib/src/crypto/encrypt_data.rs index a337a35..1b42a98 100644 --- a/crates/rite-stdlib/src/crypto/encrypt_data.rs +++ b/crates/rite-stdlib/src/crypto/encrypt_data.rs @@ -7,11 +7,10 @@ use rite_runtime::{ compute_fingerprint, parse_params, resolve_artifact_bytes, resolve_backend_key, }; use rite_sdk::{ - Backend, DataKey, EncryptedData, KeyAlgorithm, KeyCheckValue, KeyProtection, PublicKeyDer, - RandomBackend, WrapScheme, cms, + Backend, DataKey, EncryptedData, KeyAlgorithm, KeyCheckValue, KeyFingerprint, KeyProtection, + PublicKeyDer, RandomBackend, WrapScheme, cms, }; use serde_json::json; -use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use crate::crypto::content; @@ -87,7 +86,9 @@ impl Action for EncryptDataAction { let random = random_source(backend)?; let recipient = crate::crypto::ExternalRecipient { key_ref, - expected: typed.expect_recipient.as_deref(), + expected: crate::crypto::declared_fingerprint( + typed.expect_recipient.as_deref(), + )?, consequence: "Content encrypted to it would be readable by that key's \ holder and not by the one intended, so this step will not run.", } @@ -180,10 +181,10 @@ fn recipient_input(step: &StepInfo) -> Result<(Path, &ArtifactRef), ActionError> enum Addressee { /// A symmetric key, by its check value, in a `KEKRecipientInfo`. CheckValue(KeyCheckValue), - /// A public key, by the SHA-256 of its SPKI, in a `KeyTransRecipientInfo`. - /// The same digest the transcript writes as the key's `sha256:` - /// fingerprint, so a blob and a record compare without holding the key. - PublicKey(Vec), + /// A public key, by its fingerprint, in a `KeyTransRecipientInfo`. The + /// value the transcript records for the key, so a blob and a record + /// compare without holding the key. + PublicKey(KeyFingerprint), } impl Addressee { @@ -195,10 +196,10 @@ impl Addressee { } } - fn key_identifier(&self) -> Vec { + fn key_identifier(&self) -> cms::RecipientIdentifier { match self { - Self::CheckValue(check_value) => check_value.as_bytes().to_vec(), - Self::PublicKey(spki_digest) => spki_digest.clone(), + Self::CheckValue(check_value) => check_value.into(), + Self::PublicKey(fingerprint) => (*fingerprint).into(), } } } @@ -272,8 +273,7 @@ fn recipient_data_key( ) -> Result<(DataKey, Addressee), ActionError> { let cek = Zeroizing::new(random.generate_random(content::KEY_BYTES)?); let data_key = content::protect_for(recipient, cek)?; - let spki_digest = Sha256::digest(recipient.as_bytes()).to_vec(); - Ok((data_key, Addressee::PublicKey(spki_digest))) + Ok((data_key, Addressee::PublicKey(recipient.fingerprint()))) } /// Encrypt the content under the data key and assemble the container around diff --git a/crates/rite-stdlib/src/crypto/export_public.rs b/crates/rite-stdlib/src/crypto/export_public.rs index 73a597f..a2b3098 100644 --- a/crates/rite-stdlib/src/crypto/export_public.rs +++ b/crates/rite-stdlib/src/crypto/export_public.rs @@ -3,7 +3,7 @@ use rite_model::{ActionType, StepInputs}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, - compute_fingerprint, resolve_backend_key, + resolve_backend_key, }; use rite_sdk::Backend; use serde_json::json; @@ -59,7 +59,7 @@ impl Action for ExportPublicAction { keystore.export_public_key(source_key.key_id)? }; - let fingerprint = compute_fingerprint(exported.as_bytes()); + let fingerprint = exported.fingerprint().to_string(); let public_key = ArtifactValue::PublicKey(exported); reporter.backend_operation( diff --git a/crates/rite-stdlib/src/crypto/generate_key.rs b/crates/rite-stdlib/src/crypto/generate_key.rs index c456823..50f8c5f 100644 --- a/crates/rite-stdlib/src/crypto/generate_key.rs +++ b/crates/rite-stdlib/src/crypto/generate_key.rs @@ -3,7 +3,7 @@ use rite_model::ActionType; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, - compute_fingerprint, parse_params, + parse_params, }; use rite_sdk::{Backend, KeyAlgorithm, KeyMetadata, KeyPolicy, KeySpec}; use serde_json::json; @@ -88,7 +88,7 @@ impl Action for GenerateKeyAction { let public_key_fingerprint = metadata .public_key .as_ref() - .map(|key| compute_fingerprint(key.as_bytes())); + .map(|key| key.fingerprint().to_string()); let key = ArtifactValue::BackendKey { backend_name: backend_name.clone(), diff --git a/crates/rite-stdlib/src/crypto/import_key.rs b/crates/rite-stdlib/src/crypto/import_key.rs index a8fab75..5c425da 100644 --- a/crates/rite-stdlib/src/crypto/import_key.rs +++ b/crates/rite-stdlib/src/crypto/import_key.rs @@ -3,7 +3,7 @@ use rite_model::{ActionType, ArtifactRef}; use rite_runtime::{ Action, ActionError, ArtifactValue, HandlerContext, Icon, Reporter, StepInfo, StepResult, - compute_fingerprint, parse_params, resolve_artifact_bytes, + parse_params, resolve_artifact_bytes, }; use rite_sdk::{Backend, KeyAlgorithm, KeySpec}; use secrecy::ExposeSecret; @@ -95,7 +95,7 @@ impl Action for ImportKeyAction { let imported_fingerprint = metadata .public_key .as_ref() - .map(|key| compute_fingerprint(key.as_bytes())); + .map(|key| key.fingerprint().to_string()); // A keypair answers to its public half and a symmetric key to its // check value. One `expect_key` covers both, discriminated by the diff --git a/crates/rite-stdlib/src/crypto/mod.rs b/crates/rite-stdlib/src/crypto/mod.rs index bcd0559..817f975 100644 --- a/crates/rite-stdlib/src/crypto/mod.rs +++ b/crates/rite-stdlib/src/crypto/mod.rs @@ -22,8 +22,8 @@ pub use verify_signature::VerifySignatureAction; pub use wrap_key::WrapKeyAction; use rite_model::{ArtifactRef, StepFact}; -use rite_runtime::{ActionError, HandlerContext, Reporter, StepInfo, compute_fingerprint}; -use rite_sdk::{Backend, KeyTransportBackend, PublicKeyDer}; +use rite_runtime::{ActionError, HandlerContext, Reporter, StepInfo}; +use rite_sdk::{Backend, KeyFingerprint, KeyTransportBackend, PublicKeyDer}; /// The backend a step names, as the capability the step needs, once it is known /// to be the backend that holds the key. @@ -59,12 +59,33 @@ pub(crate) fn transport_backend<'a>( Ok((transport, name)) } +/// The fingerprint an `expect_recipient` parameter declares. +/// +/// The parameter stays text in the step's parameters, where the ceremony +/// schema checks its form and says what is wrong with it. This is where it +/// becomes a fingerprint, so a malformed value that reached the handler some +/// other way is refused rather than compared as text. +pub(crate) fn declared_fingerprint( + value: Option<&str>, +) -> Result, ActionError> { + value + .map(|text| { + text.parse().map_err(|_| { + ActionError::Failed(format!( + "expect_recipient '{text}' is not a fingerprint of the form \ + 'sha256:<64 lowercase hex digits>'" + )) + }) + }) + .transpose() +} + /// A public key held outside the ceremony that a step addresses its output to. pub(crate) struct ExternalRecipient<'a> { /// The input naming the key. pub key_ref: &'a ArtifactRef, /// The fingerprint the ceremony declared for it, if any. - pub expected: Option<&'a str>, + pub expected: Option, /// What addressing the wrong key would do, completing the sentence "..., /// but the ceremony declares X." in the refusal. pub consequence: &'a str, @@ -102,7 +123,7 @@ impl ExternalRecipient<'_> { )) })?; - let fingerprint = compute_fingerprint(recipient.as_bytes()); + let fingerprint = recipient.fingerprint(); if let Some(declared) = self.expected && declared != fingerprint { diff --git a/crates/rite-stdlib/src/crypto/unwrap_key.rs b/crates/rite-stdlib/src/crypto/unwrap_key.rs index 7a589ce..023403f 100644 --- a/crates/rite-stdlib/src/crypto/unwrap_key.rs +++ b/crates/rite-stdlib/src/crypto/unwrap_key.rs @@ -102,7 +102,7 @@ impl Action for UnwrapKeyAction { let recovered_fingerprint = key_metadata .public_key .as_ref() - .map(|key| compute_fingerprint(key.as_bytes())); + .map(|key| key.fingerprint().to_string()); // A symmetric key has no public half, so what names it is its check // value, which is also what a custodian reads off the token. One diff --git a/crates/rite-stdlib/src/crypto/verify_signature.rs b/crates/rite-stdlib/src/crypto/verify_signature.rs index be2d341..eaa08e6 100644 --- a/crates/rite-stdlib/src/crypto/verify_signature.rs +++ b/crates/rite-stdlib/src/crypto/verify_signature.rs @@ -126,7 +126,7 @@ impl Action for VerifySignatureAction { }), json!({ "verified": true, - "public_key_fingerprint": compute_fingerprint(public_key.as_bytes()), + "public_key_fingerprint": public_key.fingerprint().to_string(), "signature_fingerprint": compute_fingerprint(signature), }), None, diff --git a/crates/rite-stdlib/src/crypto/wrap_key.rs b/crates/rite-stdlib/src/crypto/wrap_key.rs index 2fb11b4..81106a5 100644 --- a/crates/rite-stdlib/src/crypto/wrap_key.rs +++ b/crates/rite-stdlib/src/crypto/wrap_key.rs @@ -65,7 +65,7 @@ impl Action for WrapKeyAction { // and exactly one of the two is present. let target_fingerprint = key_to_wrap .public_key - .map(|key| compute_fingerprint(key.as_bytes())); + .map(|key| key.fingerprint().to_string()); let target_check_value = key_to_wrap.check_value.map(ToString::to_string); let key_backend = key_to_wrap.backend_name; @@ -95,7 +95,9 @@ impl Action for WrapKeyAction { // takes is the backend's to say, at the wrap. let recipient = crate::crypto::ExternalRecipient { key_ref: wrapping_key_ref, - expected: typed.expect_recipient.as_deref(), + expected: crate::crypto::declared_fingerprint( + typed.expect_recipient.as_deref(), + )?, consequence: "Wrapping to an unintended recipient cannot be undone, so this \ step will not run.", } diff --git a/crates/rite-stdlib/tests/actions.rs b/crates/rite-stdlib/tests/actions.rs index 225a25b..f183b4e 100644 --- a/crates/rite-stdlib/tests/actions.rs +++ b/crates/rite-stdlib/tests/actions.rs @@ -1385,7 +1385,7 @@ fn encrypt_data_produces_a_container_addressed_to_the_key() { // whether the blob is theirs without decrypting anything. let envelope = rite_sdk::cms::read_auth_enveloped(encrypted.data()).expect("the container reads back"); - assert_eq!(envelope.key_identifier, expected.as_bytes()); + assert!(envelope.key_identifier.is_check_value(expected)); assert_eq!(encrypted.scheme(), rite_sdk::WrapScheme::CmsAes256Gcm); // The ciphertext is not the plaintext, which is the one thing a round trip @@ -1477,7 +1477,7 @@ fn decrypt_data_refuses_a_container_addressed_to_another_key() { // the blob wants rather than that a tag failed. let message = error.to_string(); assert!( - message.contains("addressed to the key with check value"), + message.contains("addressed to the key identified by"), "the refusal must name the recipient, got: {message}" ); } @@ -1551,7 +1551,7 @@ fn sealed_der( let sealed = rite_openssl::seal_content(data_key.plaintext(), payload).unwrap(); rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { protection: data_key.protection(), - key_identifier: identifier(public_key), + key_identifier: rite_sdk::cms::RecipientIdentifier::new(identifier(public_key)), wrapped_cek: data_key.wrapped().to_vec(), nonce: sealed.nonce, ciphertext: sealed.ciphertext, @@ -1637,8 +1637,9 @@ fn decrypt_data_refuses_content_sealed_to_another_public_key() { let message = result .expect_err("a container addressed elsewhere does not open") .to_string(); + let named = base16ct::lower::encode_string(public_half(&intended.1).fingerprint().as_bytes()); assert!( - message.contains("addressed to the key with subject key identifier sha256:"), + message.contains(&format!("addressed to the key identified by {named},")), "the refusal must name the recipient, got: {message}" ); } @@ -1669,8 +1670,8 @@ fn decrypt_data_opens_a_material_addressed_by_the_rfc_5280_identifier() { assert_eq!(recorded, Some(serde_json::json!(fingerprint))); } -/// A SHA-1 identifier for another key is shown as what it is, not as a -/// `sha256:` fingerprint the transcript could hold. +/// An identifier for another key is shown as the bytes it is, not as a +/// fingerprint or check value it might be. #[test] fn decrypt_data_names_a_foreign_identifier_as_plain_hex() { let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); @@ -1682,7 +1683,7 @@ fn decrypt_data_names_a_foreign_identifier_as_plain_hex() { let message = result.expect_err("addressed elsewhere").to_string(); let named = base16ct::lower::encode_string(&rfc5280_identifier(public_half(&intended.1))); assert!( - message.contains(&format!("subject key identifier {named},")), + message.contains(&format!("identified by {named},")), "{message}" ); } @@ -1698,7 +1699,7 @@ fn decrypt_data_refuses_a_content_key_of_the_wrong_length() { rite_openssl::data_key_for_recipient(public_key, Zeroizing::new(vec![0x5a; 16])).unwrap(); let der = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { protection: short.protection(), - key_identifier: spki_digest(public_key), + key_identifier: rite_sdk::cms::RecipientIdentifier::new(spki_digest(public_key)), wrapped_cek: short.wrapped().to_vec(), nonce: vec![0; 12], ciphertext: vec![0; 16], @@ -1832,7 +1833,10 @@ fn encrypt_data_seals_content_to_a_public_key_its_holder_opens() { let envelope = rite_sdk::cms::read_auth_enveloped(encrypted.data()).expect("the container reads back"); assert_eq!(envelope.protection, rite_sdk::KeyProtection::RsaOaepSha256); - assert_eq!(envelope.key_identifier, spki_digest(&public_key)); + assert_eq!( + envelope.key_identifier.as_bytes(), + spki_digest(&public_key).as_slice() + ); let recorded = facts.iter().find_map(|fact| match fact { StepFact::WrapRecipientRecorded { @@ -1840,7 +1844,7 @@ fn encrypt_data_seals_content_to_a_public_key_its_holder_opens() { declared, source, .. - } => Some((fingerprint.clone(), *declared, source.clone())), + } => Some((fingerprint.to_string(), *declared, source.clone())), _ => None, }); assert_eq!( @@ -1877,6 +1881,63 @@ fn encrypt_data_seals_content_to_a_public_key_its_holder_opens() { } } +/// An RSA-2048 SPKI as some encoders write it, with the NULL algorithm +/// parameters left out. OpenSSL reads it as the same key. +fn without_rsa_parameters(spki: &[u8]) -> Vec { + const RSA_ENCRYPTION: [u8; 11] = [ + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, + ]; + // SEQUENCE { SEQUENCE { rsaEncryption, NULL }, BIT STRING }: leaving out + // the NULL shortens both SEQUENCEs by its two bytes. + let head = [ + &[0x30, 0x82, 0x01, 0x22, 0x30, 0x0d][..], + &RSA_ENCRYPTION, + &[0x05, 0x00], + ] + .concat(); + let key_bits = spki + .strip_prefix(head.as_slice()) + .expect("an RSA-2048 SPKI as OpenSSL writes it"); + [ + &[0x30, 0x82, 0x01, 0x20, 0x30, 0x0b][..], + &RSA_ENCRYPTION, + key_bits, + ] + .concat() +} + +/// A recipient key supplied without its NULL parameters is still the key the +/// custodian holds: the fact names it as the custodian's backend does, and the +/// custodian opens what was sealed to it. +#[test] +fn encrypt_data_to_an_rsa_key_written_without_parameters_opens_under_its_holder() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let custodian = rsa_recipient(&mut backend, "custodian_key"); + let held = public_half(&custodian.1).clone(); + let supplied = without_rsa_parameters(held.as_bytes()); + + let (result, facts) = encrypt_to_recipient( + &mut backend, + b"the recovery phrase", + ArtifactValue::Bytes(supplied), + &serde_json::json!({}), + ); + let sealed = result + .expect("encrypt_data completes") + .artifacts + .pop() + .expect("one artifact") + .1; + let recorded = facts.iter().find_map(|fact| match fact { + StepFact::WrapRecipientRecorded { fingerprint, .. } => Some(*fingerprint), + _ => None, + }); + assert_eq!(recorded, Some(held.fingerprint())); + + let (result, _) = decrypt_with(&mut backend, sealed, custodian); + result.expect("the custodian opens content sealed to their key"); +} + /// A recipient other than the one the ceremony declared is refused before /// anything is encrypted or recorded. #[test] diff --git a/crates/rite/Cargo.toml b/crates/rite/Cargo.toml index 27d682b..a1d7221 100644 --- a/crates/rite/Cargo.toml +++ b/crates/rite/Cargo.toml @@ -41,7 +41,6 @@ rite-render = { workspace = true, optional = true } rite-stdlib = { workspace = true } rite-tui = { workspace = true, optional = true } openssl = { workspace = true, optional = true } -base16ct = { workspace = true } clap = { workspace = true } clap_complete = { workspace = true } serde_json = { workspace = true } diff --git a/crates/rite/src/container_checks.rs b/crates/rite/src/container_checks.rs index e974086..94af4aa 100644 --- a/crates/rite/src/container_checks.rs +++ b/crates/rite/src/container_checks.rs @@ -15,11 +15,12 @@ //! thought. A container that cannot be checked is reported as unchecked rather //! than counted as evidence. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::Path; use rite_model::StepFact; -use rite_sdk::{KeyCheckValue, RecipientInfoKind, WrapDescription, WrapScheme}; +use rite_sdk::cms::RecipientIdentifier; +use rite_sdk::{KeyCheckValue, KeyFingerprint, RecipientInfoKind, WrapDescription, WrapScheme}; use crate::verify::digest_hex; use rite_model::bundle::artifact_path; @@ -229,18 +230,18 @@ pub fn check_containers(dir: Option<&Path>, facts: &[&StepFact]) -> Vec { /// Keys this ceremony generated, by whatever names them: a fingerprint /// for a keypair, a check value for a symmetric key. - generated: HashSet<&'a str>, + generated: Vec, /// Keys this ceremony imported, named the same way. Kept apart from /// `generated` because the two support different claims: one says the /// ceremony made the key, the other only that it received it. - imported: HashSet<&'a str>, + imported: Vec, /// Recipient a wrap was given, by the step that wrapped to it, with /// whether the ceremony declared it in advance. /// /// Keyed by step because both facts come from one step. Keying by the /// input name instead would let two steps reading the same input share an /// entry, and the first `declared` would then stand for both. - recipients: HashMap<&'a str, (&'a str, bool)>, + recipients: HashMap<&'a str, (KeyFingerprint, bool)>, /// Artifact file names by the digest recorded for their contents. artifacts: HashMap<&'a str, &'a str>, } @@ -248,8 +249,8 @@ struct Index<'a> { impl<'a> Index<'a> { fn of(facts: &[&'a StepFact]) -> Self { let mut index = Index { - generated: HashSet::new(), - imported: HashSet::new(), + generated: Vec::new(), + imported: Vec::new(), recipients: HashMap::new(), artifacts: HashMap::new(), }; @@ -257,22 +258,20 @@ impl<'a> Index<'a> { match fact { StepFact::BackendOperation { kind, outputs, .. } if kind == "generate_key" => { // A keypair is named by its public half and a symmetric - // key by its check value. Both go in one set, because both - // are what a blob's recipient identifier renders to. - if let Some(fingerprint) = string_field(outputs, "public_key_fingerprint") { - index.generated.insert(fingerprint); - } - if let Some(check_value) = string_field(outputs, "key_check_value") { - index.generated.insert(check_value); - } + // key by its check value. Both go in one list, because a + // blob's recipient identifier may be either. + index.generated.extend(RecordedKey::named_in( + outputs, + "public_key_fingerprint", + "key_check_value", + )); } StepFact::BackendOperation { kind, outputs, .. } if kind == "import_key" => { - if let Some(fingerprint) = string_field(outputs, "imported_key_fingerprint") { - index.imported.insert(fingerprint); - } - if let Some(check_value) = string_field(outputs, "imported_key_check_value") { - index.imported.insert(check_value); - } + index.imported.extend(RecordedKey::named_in( + outputs, + "imported_key_fingerprint", + "imported_key_check_value", + )); } StepFact::WrapRecipientRecorded { step, @@ -283,7 +282,7 @@ impl<'a> Index<'a> { index .recipients .entry(step.as_str()) - .or_insert((fingerprint.as_str(), *declared)); + .or_insert((*fingerprint, *declared)); } StepFact::ArtifactWritten { file, @@ -326,12 +325,11 @@ fn check_one( return unchecked("the fact records no scheme"); }; - // Whichever form names the wrapped key. Both land in one set, so a + // Whichever form names the wrapped key. Both land in one list, so a // symmetric payload traces to its generate step the way a keypair does. - let wrapped_key_names: Vec<&str> = ["key_to_wrap_fingerprint", "key_to_wrap_check_value"] - .iter() - .filter_map(|field| string_field(inputs, field)) - .collect(); + let wrapped_key_names: Vec = + RecordedKey::named_in(inputs, "key_to_wrap_fingerprint", "key_to_wrap_check_value") + .collect(); let origin = if wrapped_key_names .iter() .any(|target| index.generated.contains(target)) @@ -394,39 +392,45 @@ fn check_one( }; } - // The recipient identifier in the blob is what names the recipient key, - // which is the form both the recipient fact and a generate_key fact - // record, so they compare without holding any key. - let recipient = match from_blob.recipient_key_identifier.as_deref() { + // The recipient identifier in the blob is compared with the names the + // recipient fact and a generate_key fact record, so the two compare + // without holding any key. A blob that names its recipient by an RFC 5280 + // identifier matches no record, since no record holds that name. + let form = from_blob.description.recipient_info; + let recipient = match from_blob.recipient_key_identifier.as_ref() { None => RecipientEvidence::None, - Some(identifier) => { - let named = names_recipient(&from_blob.description, identifier); - match index.recipients.get(step) { - Some(&(recorded, declared)) if recorded == named => { - if declared { - RecipientEvidence::Declared - } else { - RecipientEvidence::Recorded - } - } - Some(&(recorded, _)) => { - return ContainerStatus::Mismatch { - detail: format!( - "the transcript records recipient {recorded}, \ - the artifact names {named}" - ), - }; - } - // No recipient fact: the wrap went to a key the backend holds, - // and the transcript names that key only where it was - // generated. - None if index.generated.contains(named.as_str()) => { - RecipientEvidence::GeneratedHere + Some(identifier) => match index.recipients.get(step) { + Some(&(recorded, declared)) + if RecordedKey::Fingerprint(recorded).names(identifier, form) => + { + if declared { + RecipientEvidence::Declared + } else { + RecipientEvidence::Recorded } - None if index.imported.contains(named.as_str()) => RecipientEvidence::ImportedHere, - None => RecipientEvidence::None, } - } + Some(&(recorded, _)) => { + return ContainerStatus::Mismatch { + detail: format!( + "the transcript records recipient {recorded}, \ + the artifact names the key identified by {identifier}" + ), + }; + } + // No recipient fact: the wrap went to a key the backend holds, + // and the transcript names that key only where it was generated. + None if index + .generated + .iter() + .any(|key| key.names(identifier, form)) => + { + RecipientEvidence::GeneratedHere + } + None if index.imported.iter().any(|key| key.names(identifier, form)) => { + RecipientEvidence::ImportedHere + } + None => RecipientEvidence::None, + }, }; ContainerStatus::Checked { @@ -436,18 +440,55 @@ fn check_one( } } -/// Render a blob's recipient identifier the way the transcript names that key. +/// A key as a fact names it: a keypair by its fingerprint, a symmetric key by +/// its check value. /// -/// A public-key recipient is identified by a digest of its SPKI, which the -/// transcript writes as a `sha256:` fingerprint. A symmetric recipient has no -/// public half, so `KEKRecipientInfo` carries the key's check value instead, -/// which the transcript writes with its own method prefix. Rendering here is -/// what lets one comparison serve both. -fn names_recipient(description: &WrapDescription, identifier: &[u8]) -> String { - let hex = base16ct::lower::encode_string(identifier); - match description.recipient_info { - Some(RecipientInfoKind::Kekri) => format!("{}:{hex}", KeyCheckValue::METHOD), - _ => format!("sha256:{hex}"), +/// Parsed from the transcript, which is untrusted input. Each field is read as +/// the kind of name it is called, so a value in another form, a check value +/// recorded as a fingerprint for instance, names no key rather than being +/// compared as whatever it parses as. +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecordedKey { + Fingerprint(KeyFingerprint), + CheckValue(KeyCheckValue), +} + +impl RecordedKey { + /// The names a fact gives one key, from the field that holds each kind. + fn named_in( + value: &serde_json::Value, + fingerprint_field: &str, + check_value_field: &str, + ) -> impl Iterator { + let fingerprint = string_field(value, fingerprint_field) + .and_then(|text| text.parse().ok()) + .map(Self::Fingerprint); + let check_value = string_field(value, check_value_field) + .and_then(|text| text.parse().ok()) + .map(Self::CheckValue); + fingerprint.into_iter().chain(check_value) + } + + /// Whether a blob's recipient identifier is this name, in a recipient of + /// `form`. + /// + /// A `KEKRecipientInfo` names a symmetric key, by its check value. The + /// public-key forms name a key by its fingerprint. A name of the other + /// kind is never compared, so bytes that happen to equal it are not taken + /// as evidence the recipient form rules out. + fn names(&self, identifier: &RecipientIdentifier, form: Option) -> bool { + let public_key_form = matches!( + form, + Some(RecipientInfoKind::Ktri | RecipientInfoKind::Kari | RecipientInfoKind::Kemri) + ); + match self { + Self::Fingerprint(fingerprint) => { + public_key_form && identifier.is_fingerprint(fingerprint) + } + Self::CheckValue(check_value) => { + form == Some(RecipientInfoKind::Kekri) && identifier.is_check_value(check_value) + } + } } } @@ -547,8 +588,7 @@ mod tests { .unwrap(); let description = wrapped.description(); - let fingerprint = - |key: &rite_sdk::PublicKeyDer| rite_runtime::compute_fingerprint(key.as_bytes()); + let fingerprint = |key: &rite_sdk::PublicKeyDer| key.fingerprint().to_string(); Wrap { target_fingerprint: fingerprint(&backend.export_public_key(&target.key_id).unwrap()), recipient_fingerprint: fingerprint(&backend.export_public_key(&kek.key_id).unwrap()), @@ -580,7 +620,7 @@ mod tests { StepFact::WrapRecipientRecorded { step: StepId::new("wrap"), source: "kek".to_string(), - fingerprint: wrap.recipient_fingerprint.clone(), + fingerprint: wrap.recipient_fingerprint.parse().unwrap(), declared: true, }, StepFact::BackendOperation { @@ -637,7 +677,7 @@ mod tests { let content = rite_openssl::seal_content(data_key.plaintext(), b"an archive").unwrap(); let blob = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { protection: data_key.protection(), - key_identifier: check_value.as_bytes().to_vec(), + key_identifier: check_value.into(), wrapped_cek: data_key.wrapped().to_vec(), nonce: content.nonce, ciphertext: content.ciphertext, @@ -727,18 +767,14 @@ mod tests { }) .unwrap(); let public = backend.export_public_key(&recipient.key_id).unwrap(); - let fingerprint = rite_runtime::compute_fingerprint(public.as_bytes()); - let spki_digest = fingerprint - .strip_prefix("sha256:") - .and_then(|hex| base16ct::lower::decode_vec(hex).ok()) - .unwrap(); + let fingerprint = public.fingerprint(); let data_key = rite_openssl::data_key_for_recipient(&public, zeroize::Zeroizing::new(vec![0x5a; 32])) .unwrap(); let content = rite_openssl::seal_content(data_key.plaintext(), b"an archive").unwrap(); let blob = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { protection: data_key.protection(), - key_identifier: spki_digest, + key_identifier: fingerprint.into(), wrapped_cek: data_key.wrapped().to_vec(), nonce: content.nonce, ciphertext: content.ciphertext, @@ -748,7 +784,7 @@ mod tests { std::fs::create_dir_all(tmp.path().join("artifacts")).unwrap(); std::fs::write(tmp.path().join("artifacts/sealed.p7c"), &blob).unwrap(); - let facts = |fingerprint: String| { + let facts = |fingerprint: rite_sdk::KeyFingerprint| { vec![ StepFact::WrapRecipientRecorded { step: StepId::new("encrypt"), @@ -795,7 +831,7 @@ mod tests { check.describe() ); - let another = facts(format!("sha256:{}", "ab".repeat(32))); + let another = facts(format!("sha256:{}", "ab".repeat(32)).parse().unwrap()); let checks = check_containers(Some(tmp.path()), &borrow(&another)); assert!( checks.iter().all(ContainerCheck::failed), @@ -1281,6 +1317,39 @@ mod tests { ); } + /// A 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. + #[test] + fn a_recorded_name_is_read_by_its_field_and_compared_by_the_recipient_form() { + let fingerprint = format!("sha256:{}", "ab".repeat(32)); + let check_value = "cmac-aes:763cbc"; + + // A check value under a fingerprint field, and the reverse, name nothing. + let swapped = json!({ "fp": check_value, "kcv": fingerprint }); + assert_eq!(RecordedKey::named_in(&swapped, "fp", "kcv").count(), 0); + + let named: Vec = RecordedKey::named_in( + &json!({ "fp": fingerprint, "kcv": check_value }), + "fp", + "kcv", + ) + .collect(); + let [by_fingerprint, by_check_value] = named.as_slice() else { + panic!("both names read, got {named:?}"); + }; + + let digest = RecipientIdentifier::new(vec![0xab; 32]); + let kcv = RecipientIdentifier::new(vec![0x76, 0x3c, 0xbc]); + let ktri = Some(RecipientInfoKind::Ktri); + let kekri = Some(RecipientInfoKind::Kekri); + assert!(by_fingerprint.names(&digest, ktri)); + assert!(by_check_value.names(&kcv, kekri)); + // The same bytes in the form that cannot carry that kind of name. + assert!(!by_fingerprint.names(&digest, kekri)); + assert!(!by_check_value.names(&kcv, ktri)); + assert!(!by_fingerprint.names(&digest, None)); + } + #[test] fn a_record_naming_the_wrong_recipient_is_a_mismatch() { let tmp = tempfile::tempdir().unwrap(); @@ -1288,7 +1357,7 @@ mod tests { let mut facts = transcript(tmp.path(), wrap, wrap.outputs.clone()); for fact in &mut facts { if let StepFact::WrapRecipientRecorded { fingerprint, .. } = fact { - *fingerprint = "sha256:00".to_string(); + *fingerprint = format!("sha256:{}", "00".repeat(32)).parse().unwrap(); } }