diff --git a/Cargo.toml b/Cargo.toml index f2fc451..949518d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ base16ct = { version = "1.0.0", features = ["alloc"] } base32ct = { version = "0.3.1", features = ["alloc"] } base64ct = { version = "1.8.3", features = ["alloc"] } rand = "0.10.1" -openssl = { version = "0.10.78" } +openssl = { version = "0.10.79" } openssl-sys = { version = "0.9.109" } cryptoki = "0.12" yubikey = "0.8" diff --git a/crates/rite-model/src/types.rs b/crates/rite-model/src/types.rs index 7e945dc..a5fe6ec 100644 --- a/crates/rite-model/src/types.rs +++ b/crates/rite-model/src/types.rs @@ -134,9 +134,10 @@ pub enum ActionType { /// /// 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. + /// protects it with that key. With `recipient`, a public key held + /// elsewhere, the backend supplies the content-encryption key's random + /// bytes, which are protected with RSAES-OAEP over SHA-256 for an RSA-2048 + /// or RSA-4096 key, or with ECDH and AES key wrap for a P-256 or P-384 key. /// The data itself is not sent to the backend. EncryptData, /// Decrypt data encrypted by `encrypt_data`, or a CMS file sealed diff --git a/crates/rite-openssl/src/backend.rs b/crates/rite-openssl/src/backend.rs index 07dbf2b..a7d1ed2 100644 --- a/crates/rite-openssl/src/backend.rs +++ b/crates/rite-openssl/src/backend.rs @@ -23,10 +23,10 @@ use openssl::symm::Cipher; use openssl::x509::{X509Builder, X509Extension, X509NameBuilder}; use rite_sdk::cms; use rite_sdk::{ - Backend, BackendError, DataKey, KeyAlgorithm, KeyCheckValue, KeyFingerprint, KeyId, - KeyMetadata, KeyPolicy, KeyProtection, KeySpec, KeyStoreBackend, KeyTransportBackend, - KeyUsages, PublicKeyDer, RandomBackend, SignAlgorithm, SignBackend, VerifyBackend, WrapScheme, - WrappedKey, + AgreedSecret, Backend, BackendError, DataKey, KeyAgreementBackend, 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; @@ -292,6 +292,9 @@ impl Backend for OpenSslBackend { /// encrypting it, so they suit software keys rather than keys held /// inside a hardware boundary. as_transport_mut: KeyTransportBackend, + /// Agrees an ECDH secret under a P-256 or P-384 key, the device half of + /// opening content sealed to it. + as_agreement_mut: KeyAgreementBackend, /// Provides cryptographically secure random bytes via the OpenSSL CSPRNG. as_random_mut: RandomBackend, ); @@ -908,6 +911,34 @@ impl RandomBackend for OpenSslBackend { } } +impl KeyAgreementBackend for OpenSslBackend { + fn agree(&mut self, key_id: &KeyId, peer: &PublicKeyDer) -> Result { + let key = self.get_key(key_id)?; + let private = key.pkey()?; + if private.id() != Id::EC { + return Err(BackendError::UnsupportedAlgorithm(format!( + "a key agreement runs under an EC key, and '{}' is {}", + key.label, key.algorithm + ))); + } + key.policy.require(KeyUsages::DERIVE, "open a data key")?; + // Parsing the point checks that it is on its curve. + let peer = PKey::public_key_from_der(peer.as_bytes()).map_err(|e| { + BackendError::InvalidData(format!("the peer is not a public key on its curve: {e}")) + })?; + // OpenSSL refuses a peer on another curve too, as "different + // parameters"; this says which curves. + let peer_algorithm = key_algorithm_of(&peer)?; + if peer_algorithm != key.algorithm { + return Err(BackendError::InvalidData(format!( + "the peer key is {peer_algorithm}, and '{}' is {}: an agreement needs one curve", + key.label, key.algorithm + ))); + } + ecdh(private, &peer) + } +} + /// `id-ce-subjectKeyIdentifier` (RFC 5280 §4.2.1.2). const SUBJECT_KEY_IDENTIFIER_OID: &str = "2.5.29.14"; @@ -1088,7 +1119,7 @@ fn build_cert( /// /// **EC recipient, `KeyAgreeRecipientInfo` (RFC 5753 §3.1)** /// OpenSSL generates an ephemeral key on the recipient's curve, performs one-pass ECDH -/// against the recipient's static public key, and feeds the shared secret into the ANSI +/// against the recipient's static public key, and feeds the agreed secret into the ANSI /// X9.63 KDF to derive a key-encryption key that wraps the CEK with AES Key Wrap /// (RFC 3394). The KDF digest is SHA-1, `dhSinglePass-stdDH-sha1kdf-scheme` /// (OID 1.3.133.16.840.63.0.2): OpenSSL falls back to SHA-1 when no digest is set, and @@ -1182,45 +1213,234 @@ 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`]. +/// Protect a content-encryption key for a public-key recipient. /// -/// 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 uses RSAES-OAEP with SHA-256, opened by +/// [`KeyTransportBackend::open_data_key`]. A P-256 or P-384 recipient uses +/// ECDH, the X9.63 KDF, and AES-256 Key Wrap, opened with +/// [`KeyAgreementBackend::agree`] and [`open_key_agreement`]. Both forms name +/// the recipient by its fingerprint. /// -/// 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. +/// For ECDH, the ephemeral private key is drawn from `random`. Whoever knows +/// it can derive the key-encryption key from the recipient's public key. The +/// caller supplies the content-encryption key. /// /// # 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( +/// Returns [`BackendError::UnsupportedAlgorithm`] if the recipient is not +/// RSA-2048, RSA-4096, P-256, or P-384. Returns [`BackendError::Other`] if +/// the public key cannot be parsed or an OpenSSL operation fails. Returns +/// [`BackendError::InvalidData`] if the content key's length cannot be +/// carried by AES Key Wrap. Errors from `random` are returned as they are. +pub fn recipient_info_for( recipient: &PublicKeyDer, - cek: Zeroizing>, -) -> Result { - let recipient = PKey::public_key_from_der(recipient.as_bytes()) + cek: &[u8], + random: &mut dyn RandomBackend, +) -> Result { + let key_identifier = cms::RecipientIdentifier::from(recipient.fingerprint()); + let public = 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) { + // Refuses an RSA size or a curve outside the supported ones by name. + let algorithm = key_algorithm_of(&public)?; + + if WrapScheme::RsaOaepSha256.accepts_recipient(algorithm) { + return Ok(cms::RecipientInfo::KeyTransport { + key_identifier, + encrypted_key: rsa_oaep_encrypt(&public, cek)?, + }); + } + let Some(kdf) = cms::X963Kdf::for_recipient(algorithm) else { return Err(BackendError::UnsupportedAlgorithm(format!( - "a data key is sealed to an RSA public key, and this recipient is \ - {recipient_algorithm}" + "content is sealed to an RSA-2048, RSA-4096, P-256 or P-384 public key, and this \ + recipient is {algorithm}" ))); + }; + + let ephemeral = generate_ephemeral(&public, random)?; + let secret = ecdh(&ephemeral, &public)?; + let mut agreed = cms::KeyAgreeRecipient { + key_identifier, + ephemeral_point: uncompressed_point(&ephemeral)?, + ukm: None, + kdf, + wrapped_key: Vec::new(), + }; + let kek = agreed_kek(&agreed, &secret)?; + agreed.wrapped_key = aes_key_wrap(&kek, cek, KeyWrap::Plain, true)?; + Ok(cms::RecipientInfo::KeyAgreement(agreed)) +} + +/// Derive the CMS key-encryption key and unwrap the content key. +/// +/// `secret` is the agreed secret returned by +/// [`KeyAgreementBackend::agree`]. +/// +/// # Errors +/// +/// Returns [`BackendError::InvalidData`] if the wrapped key fails its +/// integrity check. +pub fn open_key_agreement( + agreed: &cms::KeyAgreeRecipient, + secret: &AgreedSecret, +) -> Result>, BackendError> { + let kek = agreed_kek(agreed, secret)?; + aes_key_wrap(&kek, &agreed.wrapped_key, KeyWrap::Plain, false) + .map(Zeroizing::new) + .map_err(|e| { + BackendError::InvalidData(format!( + "the content key does not unwrap under the agreed secret, which is what a \ + secret agreed with another key gives: {e}" + )) + }) +} + +/// The key-encryption key RFC 5753 derives from an agreed secret. +fn agreed_kek( + agreed: &cms::KeyAgreeRecipient, + secret: &AgreedSecret, +) -> Result>, BackendError> { + let shared_info = agreed + .shared_info() + .map_err(|e| BackendError::InvalidData(e.to_string()))?; + x963_kdf( + agreed.kdf, + secret.kdf_input(), + &shared_info, + cms::AGREED_KEK_LEN, + ) +} + +/// The ANSI X9.63 KDF (SEC 1 §3.6.1): the hash of the secret, a 32-bit +/// big-endian counter from 1, and the shared info, repeated until `len` +/// bytes are out. +/// +/// Written here because the `openssl` crate binds no KDF of this name. It is +/// one hash call for every length CMS asks of it. +fn x963_kdf( + kdf: cms::X963Kdf, + secret: &[u8], + shared_info: &[u8], + len: usize, +) -> Result>, BackendError> { + let digest = match kdf { + cms::X963Kdf::Sha256 => MessageDigest::sha256(), + cms::X963Kdf::Sha384 => MessageDigest::sha384(), + other => { + return Err(BackendError::UnsupportedAlgorithm(format!( + "this backend does not derive with {other}" + ))); + } + }; + // Reserved whole up front, so the buffer never reallocates and leaves + // derived bytes behind in a freed allocation. + let mut out = Zeroizing::new(Vec::with_capacity(len.next_multiple_of(digest.size()))); + let mut counter: u32 = 1; + while out.len() < len { + let mut hasher = + openssl::hash::Hasher::new(digest).map_err(|e| ossl_err("X9.63 KDF hash", &e))?; + for part in [secret, &counter.to_be_bytes(), shared_info] { + hasher + .update(part) + .map_err(|e| ossl_err("X9.63 KDF hash", &e))?; + } + // The digest is key material, wiped where it lands rather than copied + // into a wiping buffer first. + let mut block = hasher + .finish() + .map_err(|e| ossl_err("X9.63 KDF hash", &e))?; + out.extend_from_slice(&block); + zeroize::Zeroize::zeroize(&mut *block); + counter = counter + .checked_add(1) + .ok_or_else(|| BackendError::InvalidData("X9.63 KDF counter overflow".into()))?; + } + out.truncate(len); + Ok(out) +} + +/// A fresh key pair on the curve of `peer`, with its private scalar drawn +/// from `random`. +/// +/// `EcKey::generate` draws the scalar from OpenSSL's own generator, so this +/// function builds the scalar directly. It draws bytes the length of the +/// curve order and redraws until the scalar is between 1 and the order minus +/// one. A redraw occurs with probability about 2^-32 for P-256 and far less +/// for P-384. +fn generate_ephemeral( + peer: &PKeyRef, + random: &mut dyn RandomBackend, +) -> Result, BackendError> { + const DRAWS: usize = 16; + let fail = |e: openssl::error::ErrorStack| ossl_err("Generate ephemeral key", &e); + let peer = peer + .ec_key() + .map_err(|e| ossl_err("Read recipient EC key", &e))?; + let group = peer.group(); + let mut ctx = openssl::bn::BigNumContext::new_secure().map_err(fail)?; + let mut order = BigNum::new().map_err(fail)?; + group.order(&mut order, &mut ctx).map_err(fail)?; + let one = BigNum::from_u32(1).map_err(fail)?; + let len = usize::try_from(order.num_bytes()) + .map_err(|_| BackendError::Other("EC group order has a negative length".into()))?; + + let mut scalar = SecretNumber(BigNum::new_secure().map_err(fail)?); + for _ in 0..DRAWS { + let bytes = Zeroizing::new(random.generate_random(len)?); + scalar.0.copy_from_slice(&bytes).map_err(fail)?; + if scalar.0 >= one && scalar.0 < order { + scalar.0.set_const_time(); + let mut point = openssl::ec::EcPoint::new(group).map_err(fail)?; + point + .mul_generator2(group, &scalar.0, &mut ctx) + .map_err(fail)?; + let key = EcKey::from_private_components(group, &scalar.0, &point).map_err(fail)?; + key.check_key().map_err(fail)?; + return PKey::from_ec_key(key).map_err(fail); + } } + Err(BackendError::Other(format!( + "the random source gave no valid EC private key in {DRAWS} draws" + ))) +} + +/// A secret number, cleared when dropped. OpenSSL frees a `BigNum` without +/// clearing it, and this one is an ephemeral private key. +struct SecretNumber(BigNum); + +impl Drop for SecretNumber { + fn drop(&mut self) { + self.0.clear(); + } +} + +/// The point of an EC key, uncompressed, as an `originatorKey` carries it. +fn uncompressed_point(key: &PKeyRef) -> Result, BackendError> { + let key = key.ec_key().map_err(|e| ossl_err("Read EC key", &e))?; + let mut ctx = openssl::bn::BigNumContext::new().map_err(|e| ossl_err("EC point", &e))?; + key.public_key() + .to_bytes( + key.group(), + openssl::ec::PointConversionForm::UNCOMPRESSED, + &mut ctx, + ) + .map_err(|e| ossl_err("Encode EC point", &e)) +} - let wrapped = rsa_oaep_encrypt(&recipient, &cek)?; - Ok(DataKey::new(cek, wrapped, KeyProtection::RsaOaepSha256)) +/// ECDH between a private key and a peer's public key: the x-coordinate of +/// the shared point. +fn ecdh( + private: &PKeyRef, + peer: &PKeyRef, +) -> Result { + let mut deriver = + openssl::derive::Deriver::new(private).map_err(|e| ossl_err("ECDH context", &e))?; + deriver + .set_peer(peer) + .map_err(|e| ossl_err("ECDH peer key", &e))?; + // OpenSSL writes the x-coordinate at the field's full length. + let mut secret = Zeroizing::new(Vec::new()); + *secret = deriver.derive_to_vec().map_err(|e| ossl_err("ECDH", &e))?; + Ok(AgreedSecret::new(secret)) } /// SHA-256 for both the OAEP digest and MGF1. @@ -1408,9 +1628,10 @@ fn cms_seal_to_kek( let sealed = crate::content::seal_content(&cek, payload)?; let envelope = cms::Envelope { - protection: KeyProtection::AesKeyWrap, - key_identifier: check_value.into(), - wrapped_cek: aes_key_wrap(secret, &cek, KeyWrap::Plain, true)?, + recipient: cms::RecipientInfo::Kek { + key_identifier: check_value.into(), + wrapped_key: aes_key_wrap(secret, &cek, KeyWrap::Plain, true)?, + }, nonce: sealed.nonce, ciphertext: sealed.ciphertext, tag: sealed.tag, @@ -1456,34 +1677,32 @@ fn cms_open_from_kek( } let envelope = 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 { + // The reader also admits the public-key recipients, whose content key an + // AES key wrap under this secret must not be run over. + let cms::RecipientInfo::Kek { + key_identifier, + wrapped_key, + } = &envelope.recipient + else { return Err(BackendError::InvalidData(format!( - "this container protects its content key with {}, and the key held here is a \ + "this container is addressed to a public key, in a {}, and the key held here is a \ symmetric one", - envelope.protection + envelope.recipient.kind() ))); - } + }; // Not covered by the content tag, which authenticates the content and // nothing around it, so this comparison is what refuses a blob addressed // 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) { + if !key_identifier.is_check_value(check_value) { return Err(BackendError::InvalidData(format!( - "this blob is addressed to the key identified by {}, and the key held here has \ - check value {check_value}", - envelope.key_identifier + "this blob is addressed to the key identified by {key_identifier}, and the key held \ + here has check value {check_value}" ))); } - let cek = Zeroizing::new(aes_key_wrap( - secret, - &envelope.wrapped_cek, - KeyWrap::Plain, - false, - )?); + let cek = Zeroizing::new(aes_key_wrap(secret, wrapped_key, KeyWrap::Plain, false)?); crate::content::open_content( &cek, &crate::content::SealedContent { @@ -1686,7 +1905,8 @@ impl KeyTransportBackend for OpenSslBackend { // form; wipe it on drop rather than leaving it in freed heap memory. let key_material = { let kek = self.get_key(unwrapping_key_id)?; - kek.policy.require(KeyUsages::UNWRAP, "unwrap a key")?; + kek.policy + .require(KeyUsages::to_open(kek.algorithm), "unwrap a key")?; match wrapped.scheme() { WrapScheme::CmsAes256Gcm if kek.algorithm.is_symmetric() => { cms_decrypt_kek(kek, wrapped)? @@ -1865,6 +2085,35 @@ mod tests { use super::*; use rite_sdk::{KeyPolicy, KeySpec, RecipientInfoKind}; + /// The host's generator, as the random source of a seal that does not + /// care which source it has. + fn host_random() -> OpenSslBackend { + OpenSslBackend::try_new("random").unwrap() + } + + /// A random source that hands out the draws it was given, in order. + struct Draws(Vec>); + + impl Backend for Draws { + fn name(&self) -> &'static str { + "draws" + } + fn provider(&self) -> &'static str { + "test" + } + fn fingerprint(&self) -> String { + String::new() + } + } + + impl RandomBackend for Draws { + fn generate_random(&mut self, len: usize) -> Result, BackendError> { + let draw = self.0.remove(0); + assert_eq!(draw.len(), len); + Ok(draw) + } + } + fn spec(algorithm: KeyAlgorithm, label: &str) -> KeySpec { KeySpec { algorithm, @@ -1898,55 +2147,211 @@ mod tests { fn kek(algorithm: KeyAlgorithm, label: &str) -> KeySpec { KeySpec { policy: KeyPolicy { - usages: KeyUsages::WRAP | KeyUsages::UNWRAP, + usages: KeyUsages::WRAP | KeyUsages::to_open(algorithm), ..KeyPolicy::default() }, ..spec(algorithm, label) } } - /// 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. + /// The content key `recipient_info_for` put in a key transport recipient. + fn transported(info: cms::RecipientInfo) -> Vec { + match info { + cms::RecipientInfo::KeyTransport { encrypted_key, .. } => encrypted_key, + other => panic!("expected key transport, got {other:?}"), + } + } + + /// A content 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() { + fn a_content_key_sealed_to_an_rsa_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 cek = random_bytes(32).unwrap(); + let info = recipient_info_for(&public, &cek, &mut host_random()).unwrap(); + assert!(info.key_identifier().is_fingerprint(&public.fingerprint())); + let encrypted = transported(info); + assert_eq!(encrypted.len(), 256, "one RSA-2048 block"); let opened = backend - .open_data_key( - &recipient.key_id, - data_key.wrapped(), - KeyProtection::RsaOaepSha256, - ) + .open_data_key(&recipient.key_id, &encrypted, KeyProtection::RsaOaepSha256) .unwrap(); - assert_eq!(opened.as_slice(), data_key.plaintext()); + assert_eq!(opened, cek); } + /// An EC recipient takes a key agreement: the backend holding the private + /// key agrees the secret with the ephemeral key, and the host unwraps. #[test] - fn a_data_key_is_sealed_only_to_an_rsa_public_key() { + fn a_content_key_sealed_to_an_ec_key_opens_by_agreement() { let mut backend = OpenSslBackend::try_new("software").unwrap(); - let ec = backend - .generate_key(kek(KeyAlgorithm::EcdsaP256, "ec")) + for (algorithm, kdf, field_len) in [ + (KeyAlgorithm::EcdsaP256, cms::X963Kdf::Sha256, 32), + (KeyAlgorithm::EcdsaP384, cms::X963Kdf::Sha384, 48), + ] { + let recipient = backend.generate_key(kek(algorithm, "recipient")).unwrap(); + let public = backend.export_public_key(&recipient.key_id).unwrap(); + + let cek = random_bytes(32).unwrap(); + let cms::RecipientInfo::KeyAgreement(agreed) = + recipient_info_for(&public, &cek, &mut host_random()).unwrap() + else { + panic!("{algorithm} is a key agreement recipient"); + }; + assert_eq!(agreed.kdf, kdf, "the hash RFC 5753 pairs with {algorithm}"); + assert!(agreed.key_identifier.is_fingerprint(&public.fingerprint())); + assert_eq!(agreed.ukm, None); + assert_eq!(agreed.wrapped_key.len(), 40, "AES-KW of 32 bytes"); + + let ephemeral = agreed.ephemeral_key(&public).unwrap(); + let secret = backend.agree(&recipient.key_id, &ephemeral).unwrap(); + assert_eq!( + secret.kdf_input().len(), + field_len, + "Z at the field's length" + ); + assert_eq!(open_key_agreement(&agreed, &secret).unwrap(), cek); + + // Each container draws its own ephemeral key. + let cms::RecipientInfo::KeyAgreement(again) = + recipient_info_for(&public, &cek, &mut host_random()).unwrap() + else { + unreachable!() + }; + assert_ne!(again.ephemeral_point, agreed.ephemeral_point); + } + } + + /// The ephemeral private key is drawn from the source the caller names, + /// a draw outside 1 to the curve's order less one is drawn again, and the + /// key is the one the draw makes. + #[test] + fn the_ephemeral_key_is_drawn_from_the_named_source() { + let mut backend = OpenSslBackend::try_new("software").unwrap(); + let recipient = backend + .generate_key(kek(KeyAlgorithm::EcdsaP256, "recipient")) + .unwrap(); + let public = backend.export_public_key(&recipient.key_id).unwrap(); + let cek = random_bytes(32).unwrap(); + let point = |draws: Vec>| { + let mut source = Draws(draws); + let cms::RecipientInfo::KeyAgreement(agreed) = + recipient_info_for(&public, &cek, &mut source).unwrap() + else { + panic!("P-256 is a key agreement recipient"); + }; + assert!(source.0.is_empty(), "every draw is used"); + agreed.ephemeral_point + }; + + // 1·G is the generator, so a scalar of one gives its point, which + // SEC 2 §2.4.2 publishes. + let mut one = vec![0u8; 32]; + if let Some(last) = one.last_mut() { + *last = 1; + } + let generator = vec![ + 0x04, 0x6b, 0x17, 0xd1, 0xf2, 0xe1, 0x2c, 0x42, 0x47, 0xf8, 0xbc, 0xe6, 0xe5, 0x63, + 0xa4, 0x40, 0xf2, 0x77, 0x03, 0x7d, 0x81, 0x2d, 0xeb, 0x33, 0xa0, 0xf4, 0xa1, 0x39, + 0x45, 0xd8, 0x98, 0xc2, 0x96, 0x4f, 0xe3, 0x42, 0xe2, 0xfe, 0x1a, 0x7f, 0x9b, 0x8e, + 0xe7, 0xeb, 0x4a, 0x7c, 0x0f, 0x9e, 0x16, 0x2b, 0xce, 0x33, 0x57, 0x6b, 0x31, 0x5e, + 0xce, 0xcb, 0xb6, 0x40, 0x68, 0x37, 0xbf, 0x51, 0xf5, + ]; + assert_eq!(point(vec![vec![0; 32], vec![0xff; 32], one]), generator); + assert_eq!(point(vec![vec![0x42; 32]]), point(vec![vec![0x42; 32]])); + } + + /// The agreement runs only under an EC key on the peer's curve whose + /// policy permits opening, and a secret agreed by any other key does not + /// unwrap the content key. + #[test] + fn a_key_agreement_opens_only_under_its_recipient() { + let mut backend = OpenSslBackend::try_new("software").unwrap(); + let recipient = backend + .generate_key(kek(KeyAlgorithm::EcdsaP256, "recipient")) + .unwrap(); + let public = backend.export_public_key(&recipient.key_id).unwrap(); + let cms::RecipientInfo::KeyAgreement(agreed) = + recipient_info_for(&public, &random_bytes(32).unwrap(), &mut host_random()).unwrap() + else { + unreachable!() + }; + let ephemeral = agreed.ephemeral_key(&public).unwrap(); + + let other = backend + .generate_key(kek(KeyAlgorithm::EcdsaP256, "other")) + .unwrap(); + let secret = backend.agree(&other.key_id, &ephemeral).unwrap(); + assert!( + matches!( + open_key_agreement(&agreed, &secret), + Err(BackendError::InvalidData(_)) + ), + "another key's secret fails the unwrap's integrity check" + ); + + let p384 = backend + .generate_key(kek(KeyAlgorithm::EcdsaP384, "p384")) + .unwrap(); + let rsa = backend + .generate_key(kek(KeyAlgorithm::Rsa2048, "rsa")) + .unwrap(); + let signing_only = backend + .generate_key(spec(KeyAlgorithm::EcdsaP256, "signer")) .unwrap(); - let public = backend.export_public_key(&ec.key_id).unwrap(); - let message = data_key_for_recipient(&public, random_bytes(32).unwrap()) + // ECDH is a derivation, so `unwrap` alone does not permit it. + let unwrap_only = backend + .generate_key(KeySpec { + policy: KeyPolicy { + usages: KeyUsages::UNWRAP, + ..KeyPolicy::default() + }, + ..spec(KeyAlgorithm::EcdsaP256, "unwrap only") + }) + .unwrap(); + for (key, named) in [ + (&p384.key_id, "one curve"), + (&rsa.key_id, "runs under an ec key"), + (&signing_only.key_id, "may not open a data key"), + (&unwrap_only.key_id, "needs derive"), + ] { + let error = backend.agree(key, &ephemeral).unwrap_err().to_string(); + assert!(error.to_lowercase().contains(named), "{error}"); + } + + // A point off the curve, as a crafted container would carry it. + let mut off_curve = ephemeral.as_bytes().to_vec(); + if let Some(last) = off_curve.last_mut() { + *last ^= 1; + } + let off_curve = PublicKeyDer::new(off_curve).unwrap(); + assert!(matches!( + backend.agree(&recipient.key_id, &off_curve), + Err(BackendError::InvalidData(_)) + )); + } + + #[test] + fn content_is_sealed_only_to_a_key_a_container_addresses() { + let mut backend = OpenSslBackend::try_new("software").unwrap(); + let ed25519 = backend + .generate_key(spec(KeyAlgorithm::Ed25519, "ed")) + .unwrap(); + let public = backend.export_public_key(&ed25519.key_id).unwrap(); + let message = recipient_info_for(&public, &random_bytes(32).unwrap(), &mut host_random()) .unwrap_err() .to_string(); - assert!(message.contains("ECDSA-P256"), "{message}"); + assert!(message.contains("Ed25519"), "{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()) + let message = recipient_info_for(&weak, &random_bytes(32).unwrap(), &mut host_random()) .unwrap_err() .to_string(); assert!(message.contains("1024"), "{message}"); @@ -1961,7 +2366,9 @@ mod tests { .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 encrypted = transported( + recipient_info_for(&public, &random_bytes(32).unwrap(), &mut host_random()).unwrap(), + ); let ec = backend .generate_key(kek(KeyAlgorithm::EcdsaP256, "ec")) @@ -1979,7 +2386,7 @@ mod tests { ] { assert!( backend - .open_data_key(key, data_key.wrapped(), KeyProtection::RsaOaepSha256) + .open_data_key(key, &encrypted, KeyProtection::RsaOaepSha256) .is_err(), "{case} must not open it" ); @@ -1988,11 +2395,7 @@ mod tests { // The right key under the wrong protection is refused too. assert!( backend - .open_data_key( - &recipient.key_id, - data_key.wrapped(), - KeyProtection::AesKeyWrap - ) + .open_data_key(&recipient.key_id, &encrypted, KeyProtection::AesKeyWrap) .is_err() ); } @@ -2891,6 +3294,30 @@ mod tests { assert_eq!(unwrapped.algorithm, KeyAlgorithm::EcdsaP256); let unwrapped_pub = backend.export_public_key(&unwrapped.key_id).unwrap(); assert_eq!(original_pub, unwrapped_pub); + + // An EC key opens by key agreement, which `unwrap` alone does not + // permit. + let unwrap_only = backend + .generate_key(KeySpec { + policy: KeyPolicy { + usages: KeyUsages::WRAP | KeyUsages::UNWRAP, + ..KeyPolicy::default() + }, + ..spec(KeyAlgorithm::EcdsaP256, "unwrap only") + }) + .unwrap(); + let wrapped = backend + .wrap_to_public( + &target.key_id, + &backend.export_public_key(&unwrap_only.key_id).unwrap(), + WrapScheme::CmsAes256Gcm, + ) + .unwrap(); + let error = backend + .unwrap(&wrapped, &unwrap_only.key_id, "unwrapped", restored(), None) + .unwrap_err() + .to_string(); + assert!(error.contains("needs derive"), "{error}"); } #[test] @@ -3443,6 +3870,13 @@ mod tests { .wrap(&target.key_id, &kek.key_id, WrapScheme::CmsAes256Gcm) .unwrap(); let good = cms::read_auth_enveloped(wrapped.data()).unwrap(); + let cms::RecipientInfo::Kek { + key_identifier, + wrapped_key, + } = &good.recipient + else { + panic!("a symmetric KEK is a kekri recipient"); + }; let flip = |bytes: &[u8]| { let mut bytes = bytes.to_vec(); @@ -3476,16 +3910,22 @@ mod tests { ( "wrapped key", cms::Envelope { - wrapped_cek: flip(&good.wrapped_cek), + recipient: cms::RecipientInfo::Kek { + key_identifier: key_identifier.clone(), + wrapped_key: flip(wrapped_key), + }, ..good.clone() }, ), ( "recipient identifier", cms::Envelope { - key_identifier: cms::RecipientIdentifier::new(flip( - good.key_identifier.as_bytes(), - )), + recipient: cms::RecipientInfo::Kek { + key_identifier: cms::RecipientIdentifier::new(flip( + key_identifier.as_bytes(), + )), + wrapped_key: wrapped_key.clone(), + }, ..good.clone() }, ), diff --git a/crates/rite-openssl/src/lib.rs b/crates/rite-openssl/src/lib.rs index cdacbce..1924958 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, data_key_for_recipient, verify_signature}; +pub use backend::{OpenSslBackend, open_key_agreement, recipient_info_for, 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 58ace1a..0a59103 100644 --- a/crates/rite-openssl/tests/interop.rs +++ b/crates/rite-openssl/tests/interop.rs @@ -16,8 +16,8 @@ use openssl::rsa::Rsa; use openssl::x509::{X509Builder, X509Extension, X509NameBuilder}; use rite_openssl::OpenSslBackend; use rite_sdk::{ - KeyAlgorithm, KeyPolicy, KeySpec, KeyStoreBackend, KeyTransportBackend, PublicKeyDer, - WrapScheme, + KeyAgreementBackend, KeyAlgorithm, KeyPolicy, KeySpec, KeyStoreBackend, KeyTransportBackend, + PublicKeyDer, WrapScheme, }; use std::io::Write as _; use std::process::Command; @@ -502,9 +502,10 @@ 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.into(), - wrapped_cek: data_key.wrapped().to_vec(), + recipient: rite_sdk::cms::RecipientInfo::Kek { + key_identifier: kcv.into(), + wrapped_key: data_key.wrapped().to_vec(), + }, nonce: sealed.nonce, ciphertext: sealed.ciphertext, tag: sealed.tag, @@ -575,38 +576,66 @@ fn write_file(dir: &tempfile::TempDir, name: &str, bytes: &[u8]) -> String { path.to_str().unwrap().to_string() } +/// The public-key recipients a container is sealed to: RSA under key +/// transport, and each curve under key agreement. +fn public_key_recipients() -> [(&'static str, PKey); 3] { + [ + ( + "RSA-2048", + PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(), + ), + ("P-256", ec_key(openssl::nid::Nid::X9_62_PRIME256V1)), + ("P-384", ec_key(openssl::nid::Nid::SECP384R1)), + ] +} + +/// A fresh EC key on the curve `nid`. +fn ec_key(nid: openssl::nid::Nid) -> PKey { + let group = openssl::ec::EcGroup::from_curve_name(nid).unwrap(); + PKey::from_ec_key(openssl::ec::EcKey::generate(&group).unwrap()).unwrap() +} + /// 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. +/// selects the recipient 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. For an EC key it is also +/// the check that the KDF and its shared info are the ones RFC 5753 defines, +/// since the CLI derives the wrap key itself. #[test] fn the_openssl_cli_opens_content_sealed_to_a_public_key() { if !openssl_binary_available() { eprintln!("skipping: openssl binary not available"); return; } + for (name, recipient) in public_key_recipients() { + cli_opens_content_sealed_to(name, &recipient); + } +} - let recipient = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); +fn cli_opens_content_sealed_to(name: &str, recipient: &PKeyRef) { 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 recipient_info = rite_openssl::recipient_info_for( + &public, + &cek, + &mut rite_openssl::OpenSslBackend::try_new("random").unwrap(), + ) + .unwrap_or_else(|e| panic!("content is sealed to a {name} key: {e}")); + assert_eq!( + recipient_info.key_identifier().as_bytes(), + &openssl::hash::hash(MessageDigest::sha256(), &spki).unwrap()[..], + "the {name} recipient is named by the SHA-256 of its SPKI" + ); + let sealed = rite_openssl::seal_content(&cek, content).unwrap(); let der = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { - protection: data_key.protection(), - key_identifier: rite_sdk::cms::RecipientIdentifier::new( - openssl::hash::hash(MessageDigest::sha256(), &spki) - .unwrap() - .to_vec(), - ), - wrapped_cek: data_key.wrapped().to_vec(), + recipient: recipient_info, nonce: sealed.nonce, ciphertext: sealed.ciphertext, tag: sealed.tag, @@ -623,7 +652,7 @@ fn the_openssl_cli_opens_content_sealed_to_a_public_key() { let cert = write_file( &dir, "recipient.crt", - &build_recipient_cert(&recipient).to_pem().unwrap(), + &build_recipient_cert(recipient).to_pem().unwrap(), ); let decrypt = Command::new("openssl") @@ -635,7 +664,7 @@ fn the_openssl_cli_opens_content_sealed_to_a_public_key() { .expect("Failed to spawn openssl cms"); assert!( decrypt.status.success(), - "openssl cms -decrypt -recip failed:\nstderr: {}", + "openssl cms -decrypt -recip failed for {name}:\nstderr: {}", String::from_utf8_lossy(&decrypt.stderr), ); assert_eq!( @@ -644,28 +673,93 @@ fn the_openssl_cli_opens_content_sealed_to_a_public_key() { ); } -/// The other direction: a container the command line sealed to a public key, -/// opened by Rite. +/// A container the command line sealed to an EC key, opened by Rite: the +/// backend agrees the secret and the host does the rest, which is the split a +/// card that only does ECDH needs. /// -/// 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. +/// The CLI derives with SHA-1 unless told otherwise, so it is told the hash +/// RFC 5753 pairs with the curve. The same blob written with the default is +/// refused, by name. #[test] -fn rite_opens_content_the_openssl_cli_sealed_to_a_public_key() { +fn rite_opens_content_the_openssl_cli_sealed_to_an_ec_key() { if !openssl_binary_available() { eprintln!("skipping: openssl binary not available"); return; } - let recipient = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + for (name, nid, algorithm, kdf_md) in [ + ( + "P-256", + openssl::nid::Nid::X9_62_PRIME256V1, + KeyAlgorithm::EcdsaP256, + "sha256", + ), + ( + "P-384", + openssl::nid::Nid::SECP384R1, + KeyAlgorithm::EcdsaP384, + "sha384", + ), + ] { + let recipient = ec_key(nid); + let (mut backend, held) = held_recipient(algorithm, &recipient); + let content = b"a document for someone who is not in the room"; + let sealer = CliSealer::new(&recipient, content); + + let keyopt = format!("ecdh_kdf_md:{kdf_md}"); + let theirs = sealer.seal("kari.der", &[&keyopt]); + let envelope = rite_sdk::cms::read_auth_enveloped(&theirs).expect("Rite reads their blob"); + let rite_sdk::cms::RecipientInfo::KeyAgreement(agreed) = &envelope.recipient else { + panic!( + "a {name} recipient is a key agreement, not {:?}", + envelope.recipient + ); + }; + let public = PublicKeyDer::new(recipient.public_key_to_der().unwrap()).unwrap(); + assert!( + agreed.key_identifier.names_key(&public), + "the CLI takes the rid from the certificate's subject key identifier" + ); + + let ephemeral = agreed.ephemeral_key(&public).unwrap(); + let secret = backend + .agree(&held, &ephemeral) + .expect("the private key agrees a secret with the ephemeral key"); + let cek = rite_openssl::open_key_agreement(agreed, &secret) + .expect("the agreed secret unwraps the content key the CLI protected"); + let opened = rite_openssl::open_content( + &cek, + &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 = sealer.seal("sha1.der", &[]); + let refusal = rite_sdk::cms::read_auth_enveloped(&legacy) + .expect_err("a SHA-1 key derivation is not opened") + .to_string(); + assert!(refusal.contains("SHA-1"), "{refusal}"); + } +} + +/// A backend holding `recipient`'s private key, allowed to open what is +/// sealed to it. +fn held_recipient( + algorithm: KeyAlgorithm, + recipient: &PKeyRef, +) -> (OpenSslBackend, rite_sdk::KeyId) { let mut backend = OpenSslBackend::try_new("interop-test").unwrap(); let held = backend .import_key( KeySpec { - algorithm: KeyAlgorithm::Rsa2048, + algorithm, label: "recipient".to_string(), policy: KeyPolicy { - usages: rite_sdk::KeyUsages::UNWRAP, + usages: rite_sdk::KeyUsages::to_open(algorithm), ..KeyPolicy::default() }, location_hint: None, @@ -674,18 +768,32 @@ fn rite_opens_content_the_openssl_cli_sealed_to_a_public_key() { None, ) .unwrap(); + (backend, held.key_id) +} - 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(), - ); +/// `openssl cms -encrypt` of one content to one recipient's certificate, +/// naming the recipient by its subject key identifier. +struct CliSealer { + dir: tempfile::TempDir, + plain: String, + cert: String, +} + +impl CliSealer { + fn new(recipient: &PKeyRef, content: &[u8]) -> Self { + 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(), + ); + Self { dir, plain, cert } + } - let seal = |name: &str, keyopts: &[&str]| -> Vec { - let out = dir.path().join(name); + /// The container, written to `file` with `keyopts`. + fn seal(&self, file: &str, keyopts: &[&str]) -> Vec { + let out = self.dir.path().join(file); let mut args = vec![ "cms", "-encrypt", @@ -695,11 +803,11 @@ fn rite_opens_content_the_openssl_cli_sealed_to_a_public_key() { "-outform", "DER", "-in", - &plain, + &self.plain, "-out", out.to_str().unwrap(), "-recip", - &cert, + &self.cert, ]; for keyopt in keyopts { args.extend(["-keyopt", keyopt]); @@ -710,13 +818,32 @@ fn rite_opens_content_the_openssl_cli_sealed_to_a_public_key() { .expect("Failed to spawn openssl cms"); assert!( encrypt.status.success(), - "openssl cms -encrypt -recip failed:\nstderr: {}", + "openssl cms -encrypt -recip {keyopts:?} failed:\nstderr: {}", String::from_utf8_lossy(&encrypt.stderr), ); std::fs::read(&out).unwrap() - }; + } +} + +/// 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, held) = held_recipient(KeyAlgorithm::Rsa2048, &recipient); + let content = b"a document for someone who is not in the room"; + let sealer = CliSealer::new(&recipient, content); - let theirs = seal( + let theirs = sealer.seal( "oaep.der", &[ "rsa_padding_mode:oaep", @@ -725,16 +852,25 @@ fn rite_opens_content_the_openssl_cli_sealed_to_a_public_key() { ], ); let envelope = rite_sdk::cms::read_auth_enveloped(&theirs).expect("Rite reads their blob"); - assert_eq!(envelope.protection, rite_sdk::KeyProtection::RsaOaepSha256); + let rite_sdk::cms::RecipientInfo::KeyTransport { + key_identifier, + encrypted_key, + } = &envelope.recipient + else { + panic!( + "an RSA recipient is key transport, not {:?}", + envelope.recipient + ); + }; let spki = recipient.public_key_to_der().unwrap(); assert_eq!( - envelope.key_identifier.as_bytes(), + key_identifier.as_bytes(), &openssl::hash::hash(MessageDigest::sha256(), &spki).unwrap()[..], "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) + .open_data_key(&held, encrypted_key, rite_sdk::KeyProtection::RsaOaepSha256) .expect("the private key opens the data key the CLI protected"); let opened = rite_openssl::open_content( &data_key, @@ -747,7 +883,7 @@ fn rite_opens_content_the_openssl_cli_sealed_to_a_public_key() { .expect("the content authenticates under the recovered key"); assert_eq!(opened.as_slice(), content); - let legacy = seal("v15.der", &[]); + let legacy = sealer.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(); @@ -828,12 +964,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.as_bytes(), + envelope.recipient.key_identifier().as_bytes(), public.rfc5280_key_identifier().unwrap().as_bytes(), "the rid is the identifier the CLI wrote into the certificate" ); assert!( - envelope.key_identifier.names_key(&public), + envelope.recipient.key_identifier().names_key(&public), "and Rite recognises the key by it" ); } diff --git a/crates/rite-sdk/src/backend.rs b/crates/rite-sdk/src/backend.rs index b2a3873..57de602 100644 --- a/crates/rite-sdk/src/backend.rs +++ b/crates/rite-sdk/src/backend.rs @@ -30,9 +30,10 @@ use zeroize::Zeroizing; use crate::key_material::PublicKeyDer; use crate::types::{ - Attestation, CertRef, DataKey, KeyAlgorithm, KeyId, KeyMetadata, KeyPolicy, KeyProtection, - KeySecurityAttributes, KeySpec, PcrValue, PivDeviceInfo, PivSlot, PivSlotInfo, Pkcs11Mechanism, - Pkcs11TokenInfo, SignAlgorithm, TpmInfo, WrapScheme, WrappedKey, YubikeySlotMetadata, + AgreedSecret, Attestation, CertRef, DataKey, KeyAlgorithm, KeyId, KeyMetadata, KeyPolicy, + KeyProtection, KeySecurityAttributes, KeySpec, PcrValue, PivDeviceInfo, PivSlot, PivSlotInfo, + Pkcs11Mechanism, Pkcs11TokenInfo, SignAlgorithm, TpmInfo, WrapScheme, WrappedKey, + YubikeySlotMetadata, }; /// Implement the `as_*_mut` upcasting helpers on a backend struct. @@ -162,6 +163,11 @@ pub trait Backend: Send + Sync { None } + /// Not supported by this backend, always returns `None`. + fn as_agreement_mut(&mut self) -> Option<&mut dyn KeyAgreementBackend> { + None + } + /// Not supported by this backend, always returns `None`. fn as_certstore_mut(&mut self) -> Option<&mut dyn CertStoreBackend> { None @@ -392,7 +398,7 @@ pub trait KeyTransportBackend: Backend { /// /// The counterpart of [`generate_data_key`](Self::generate_data_key), and /// the half of the pair almost every backend implements: protecting a data - /// key needs only a public key or a shared secret, while opening one always + /// key needs only a public key or a symmetric key, while opening one always /// needs the device that holds the other half. /// /// `protection` says how `wrapped` was protected, taken from the container @@ -424,6 +430,58 @@ pub trait KeyTransportBackend: Backend { } } +/// Backends implementing this trait run a Diffie-Hellman key agreement with a +/// private key they hold: ECDH on P-256 and P-384. +/// +/// The backend returns the agreed secret Z. The caller runs the KDF and uses +/// the derived key. Returning Z rather than an unwrapped key keeps this trait +/// independent of the container format. Z crossing the device boundary +/// exposes nothing the host does not already get: it opens one container, +/// whose content key the host needs in the clear to decrypt the content. +/// +/// | Device | Operation | Gated by | +/// |---|---|---| +/// | PIV | GENERAL AUTHENTICATE in slot 9d | PIN, touch policy | +/// | PKCS#11 | `C_DeriveKey` with `CKM_ECDH1_DERIVE` and `CKD_NULL` | `CKA_DERIVE` | +/// | TPM 2.0 | `TPM2_ECDH_ZGen` | the key's `decrypt` attribute | +/// | AWS KMS | `DeriveSharedSecret` | the `KEY_AGREEMENT` key usage | +/// +/// The policy usage is [`KeyUsages::DERIVE`](crate::KeyUsages::DERIVE), the +/// PKCS#11 name. For PKCS#11, `C_DeriveKey` produces a key object. The token +/// can return the secret's bytes only if that object is extractable and not +/// sensitive. A token in FIPS mode may refuse those attributes; such a token +/// cannot implement this trait. +/// +/// KEM decapsulation is a separate operation. It takes a ciphertext rather +/// than a peer public key. +/// +/// This is a separate trait from [`KeyTransportBackend`] because a device may +/// support key agreement without supporting key transport. +pub trait KeyAgreementBackend: Backend { + /// Computes the agreed secret for `key_id` and `peer`. + /// + /// For ECDH, return the x-coordinate of the agreed point at the full + /// length of the curve field: 32 bytes for P-256 or 48 for P-384. + /// Preserve leading zero bytes. Removing them changes the KDF input and + /// produces the wrong result about once in 256 agreements. + /// + /// The peer is untrusted input. Validate that it is on the same curve as + /// the private key before agreeing. A point off the curve can enable an + /// invalid-curve attack against the private key. + /// + /// # Errors + /// + /// A backend refuses: + /// - a key whose policy does not permit + /// [`KeyUsages::DERIVE`](crate::KeyUsages::DERIVE), with + /// [`BackendError::OperationNotPermitted`]; + /// - a key that does not support Diffie-Hellman agreement, such as an RSA + /// key, with [`BackendError::UnsupportedAlgorithm`]; + /// - a peer that is invalid or on a different curve from the private key, + /// with [`BackendError::InvalidData`]. + fn agree(&mut self, key_id: &KeyId, peer: &PublicKeyDer) -> Result; +} + /// PKCS#11 token operations. /// /// Provides access to PKCS#11-specific functionality: token info, key diff --git a/crates/rite-sdk/src/cms.rs b/crates/rite-sdk/src/cms.rs index 89d38fe..ebb2642 100644 --- a/crates/rite-sdk/src/cms.rs +++ b/crates/rite-sdk/src/cms.rs @@ -16,7 +16,7 @@ use std::fmt; use crate::der; use crate::key_material::PublicKeyDer; use crate::types::{ - KeyCheckValue, KeyFingerprint, KeyProtection, Oid, RecipientInfoKind, WrapDescription, oid, + KeyAlgorithm, KeyCheckValue, KeyFingerprint, Oid, RecipientInfoKind, WrapDescription, oid, }; /// `id-envelopedData` (RFC 5652 §6). @@ -44,7 +44,8 @@ pub struct CmsFacts { /// The bytes a CMS container names its recipient by: `kekid.keyIdentifier` in /// a `KEKRecipientInfo`, the `subjectKeyIdentifier` rid in a -/// `KeyTransRecipientInfo`. +/// `KeyTransRecipientInfo`, the `rKeyId` subject key identifier in a +/// `KeyAgreeRecipientInfo`. /// /// Opaque. A container Rite writes carries a [`KeyCheckValue`] or a /// [`KeyFingerprint`] here, and one written elsewhere carries whatever its @@ -183,12 +184,12 @@ pub fn describe(der: &[u8]) -> Result { } /// What one `RecipientInfo` contributes to the description. -struct Recipient { +struct RecipientFacts { description: WrapDescription, key_identifier: Option, } -fn read_recipient(info: &Tlv<'_>) -> Result { +fn read_recipient(info: &Tlv<'_>) -> Result { match info.tag { TAG_SEQUENCE => { let ktri = read_ktri(info.value)?; @@ -200,7 +201,7 @@ fn read_recipient(info: &Tlv<'_>) -> Result { if algorithm.as_str() == oid::RSAES_OAEP { require_rsaes_oaep_sha256(&ktri.algorithm)?; } - Ok(Recipient { + Ok(RecipientFacts { description: WrapDescription::new(RecipientInfoKind::Ktri, algorithm), key_identifier: ktri .key_identifier @@ -210,7 +211,7 @@ fn read_recipient(info: &Tlv<'_>) -> Result { TAG_CONTEXT_1_CONSTRUCTED => read_kari(info.value), TAG_CONTEXT_2_CONSTRUCTED => { let kekri = read_kekri(info.value)?; - Ok(Recipient { + Ok(RecipientFacts { description: WrapDescription::new(RecipientInfoKind::Kekri, kekri.algorithm), key_identifier: Some(RecipientIdentifier(kekri.key_identifier.to_vec())), }) @@ -389,7 +390,7 @@ fn read_kekri(body: &[u8]) -> Result, CmsReadError> { let (key_identifier, after_identifier) = read_tlv(kekid.expect_tag(TAG_SEQUENCE, "KEKIdentifier")?)?; - kek_identifier_options(after_identifier)?; + key_identifier_options(after_identifier, "KEKIdentifier")?; Ok(Kekri { key_identifier: key_identifier.expect_tag(TAG_OCTET_STRING, "kekid.keyIdentifier")?, algorithm: algorithm_oid(&algorithm, "keyEncryptionAlgorithm")?, @@ -397,12 +398,13 @@ 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. +/// What may follow the key identifier in a `KEKIdentifier` or an `rKeyId` +/// (`field` names which): 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> { +fn key_identifier_options(mut rest: &[u8], field: &str) -> Result<(), CmsReadError> { for tag in [TAG_GENERALIZED_TIME, TAG_SEQUENCE] { if let Some(&next) = rest.first() && next == tag @@ -413,7 +415,9 @@ fn kek_identifier_options(mut rest: &[u8]) -> Result<(), CmsReadError> { if rest.is_empty() { Ok(()) } else { - Err(err("KEKIdentifier carries bytes after its optional fields")) + Err(err(format!( + "{field} carries bytes after its optional fields" + ))) } } @@ -423,56 +427,197 @@ fn kek_identifier_options(mut rest: &[u8]) -> Result<(), CmsReadError> { /// RFC 5753 folds the KDF into the key-encryption OID itself, so the KDF is /// named there rather than in a field of its own. The algorithm's parameter /// is the identifier of the wrap applied to the CEK under the derived KEK. -fn read_kari(body: &[u8]) -> Result { - let (_version, after_version) = read_tlv(body)?; - let (originator, after_originator) = read_tlv(after_version)?; - if originator.tag != TAG_CONTEXT_0_CONSTRUCTED { - return Err(err("KeyAgreeRecipientInfo without an originator")); +fn read_kari(body: &[u8]) -> Result { + let kari = read_kari_fields(body)?; + let (scheme, parameters) = algorithm_parts(&kari.algorithm, "keyEncryptionAlgorithm")?; + let mut description = WrapDescription::new(RecipientInfoKind::Kari, scheme); + if let Some(parameters) = parameters { + description = description.with_kek_wrap(algorithm_oid(¶meters, "keyWrapAlgorithm")?); + } + Ok(RecipientFacts { + description, + key_identifier: kari + .key_identifier + .map(|bytes| RecipientIdentifier(bytes.to_vec())), + }) +} + +/// The fields of a `KeyAgreeRecipientInfo` (RFC 5652 §6.2.2), with the first +/// key it addresses. +struct Kari<'a> { + /// The `OriginatorIdentifierOrKey` CHOICE element. + originator: Tlv<'a>, + /// The `ukm`'s octets, when present. + ukm: Option<&'a [u8]>, + algorithm: Tlv<'a>, + /// The first recipient key's subject key identifier, or `None` when it is + /// named by issuer and serial. + key_identifier: Option<&'a [u8]>, + /// The first recipient key's encrypted key. + encrypted_key: &'a [u8], + /// Whether `recipientEncryptedKeys` addresses further keys after the + /// first. + more_keys: bool, +} + +/// Read the fields of a `KeyAgreeRecipientInfo`. +/// +/// Requires version 3 and reads the structure to its end. When +/// `recipientEncryptedKeys` contains several entries, returns the first. +/// Opening refuses a `KeyAgreeRecipientInfo` that addresses more than one key. +fn read_kari_fields(body: &[u8]) -> Result, CmsReadError> { + let (version, after_version) = read_tlv(body)?; + if version.expect_tag(TAG_INTEGER, "KeyAgreeRecipientInfo.version")? != [3] { + return Err(err("a KeyAgreeRecipientInfo must be version 3")); } + let (originator, after_originator) = read_tlv(after_version)?; + let originator = sole_element( + originator.expect_tag( + TAG_CONTEXT_0_CONSTRUCTED, + "KeyAgreeRecipientInfo.originator", + )?, + "KeyAgreeRecipientInfo.originator", + )?; + let (next, after_next) = read_tlv(after_originator)?; - let (algorithm, after_algorithm) = if next.tag == TAG_CONTEXT_1_CONSTRUCTED { - read_tlv(after_next)? + let (ukm, (algorithm, after_algorithm)) = if next.tag == TAG_CONTEXT_1_CONSTRUCTED { + let ukm = sole_element(next.value, "ukm")?.expect_tag(TAG_OCTET_STRING, "ukm")?; + (Some(ukm), read_tlv(after_next)?) } else { - (next, after_next) + (None, (next, after_next)) }; - let (scheme, parameters) = algorithm_parts(&algorithm, "keyEncryptionAlgorithm")?; - let mut description = WrapDescription::new(RecipientInfoKind::Kari, scheme); - if let Some(parameters) = parameters { - description = description.with_kek_wrap(algorithm_oid(¶meters, "keyWrapAlgorithm")?); + let (recipient_keys, after_keys) = read_tlv(after_algorithm)?; + if !after_keys.is_empty() { + return Err(err( + "KeyAgreeRecipientInfo carries bytes after recipientEncryptedKeys", + )); } + let recipient_keys = recipient_keys.expect_tag(TAG_SEQUENCE, "recipientEncryptedKeys")?; + let (recipient_key, more) = read_tlv(recipient_keys)?; + let (rid, after_rid) = + read_tlv(recipient_key.expect_tag(TAG_SEQUENCE, "RecipientEncryptedKey")?)?; + let encrypted_key = sole_element(after_rid, "RecipientEncryptedKey")? + .expect_tag(TAG_OCTET_STRING, "encryptedKey")?; + + // rid is an IssuerAndSerialNumber or the implicit [0] + // RecipientKeyIdentifier, whose optional date and other follow the + // identifier exactly as they do in a KEKIdentifier. + let key_identifier = if rid.tag == TAG_CONTEXT_0_CONSTRUCTED { + let (identifier, after_identifier) = read_tlv(rid.value)?; + key_identifier_options(after_identifier, "rKeyId")?; + Some(identifier.expect_tag(TAG_OCTET_STRING, "rKeyId.subjectKeyIdentifier")?) + } else { + rid.expect_tag(TAG_SEQUENCE, "rid.issuerAndSerialNumber")?; + None + }; - let (recipient_keys, _) = read_tlv(after_algorithm)?; - Ok(Recipient { - description, - key_identifier: agreed_key_identifier(recipient_keys.value)?, + // Every further entry is read as a RecipientEncryptedKey, so trailing + // bytes are malformed DER rather than a second key. + let mut rest = more; + while !rest.is_empty() { + let (further, after) = read_tlv(rest)?; + further.expect_tag(TAG_SEQUENCE, "RecipientEncryptedKey")?; + rest = after; + } + + Ok(Kari { + originator, + ukm, + algorithm, + key_identifier, + encrypted_key, + more_keys: !more.is_empty(), }) } -/// `RecipientEncryptedKeys`: a SEQUENCE OF `RecipientEncryptedKey`, whose rid -/// is either an `IssuerAndSerialNumber` or `[0] RecipientKeyIdentifier`. -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(RecipientIdentifier( - key_id - .expect_tag(TAG_OCTET_STRING, "subjectKeyIdentifier")? - .to_vec(), - ))) +/// Read an uncompressed EC point from an `originatorKey`. +/// +/// Accepts only `id-ecPublicKey` with absent or NULL parameters. A named +/// curve is refused because this reader takes the curve from the recipient's +/// key. +fn originator_point<'a>(originator: &Tlv<'a>) -> Result<&'a [u8], CmsReadError> { + let body = match originator.tag { + TAG_CONTEXT_1_CONSTRUCTED => originator.value, + _ => { + return Err(err( + "the originator is named rather than given as a key, and an ephemeral key is \ + what the agreement needs", + )); + } + }; + let (algorithm, after_algorithm) = read_tlv(body)?; + let (identifier, parameters) = exact_algorithm_parts(&algorithm, "originatorKey.algorithm")?; + if identifier.as_str() != oid::ID_EC_PUBLIC_KEY { + return Err(err(format!( + "the originator key is {identifier}, and this reader agrees over id-ecPublicKey" + ))); + } + match parameters { + None + | Some(Tlv { + tag: TAG_NULL, + value: [], + }) => {} + Some(_) => { + return Err(err( + "the originator key names its curve, which this reader does not open", + )); + } + } + let key = sole_element(after_algorithm, "originatorKey")? + .expect_tag(TAG_BIT_STRING, "originatorKey.publicKey")?; + match key.split_first() { + Some((0, point @ [0x04, ..])) => Ok(point), + Some((0, [_, ..])) => Err(err( + "the originator key's point is compressed, and Rite accepts EC points uncompressed", + )), + _ => Err(err( + "originatorKey.publicKey is not a whole number of bytes", + )), + } +} + +/// The KDF a `keyEncryptionAlgorithm` names, admitting only AES-256 key wrap +/// under it, which is the wrap this crate writes and opens. +/// +/// RFC 5753 folds the KDF into the scheme's identifier and makes the wrap its +/// parameter. The wrap identifier takes no parameters (RFC 3565 §2.3.2). +fn agreement_kdf(algorithm: &Tlv<'_>) -> Result { + let (scheme, parameters) = exact_algorithm_parts(algorithm, "keyEncryptionAlgorithm")?; + let kdf = match scheme.as_str() { + oid::DH_SINGLE_PASS_STDDH_SHA256KDF => X963Kdf::Sha256, + oid::DH_SINGLE_PASS_STDDH_SHA384KDF => X963Kdf::Sha384, + oid::DH_SINGLE_PASS_STDDH_SHA1KDF => { + return Err(err( + "the key agreement derives its key with SHA-1, which this reader does not open", + )); + } + other => { + return Err(err(format!( + "key agreement scheme {other} is not ECDH with the X9.63 KDF over SHA-256 or \ + SHA-384" + ))); + } + }; + let wrap = parameters.ok_or_else(|| err("the key agreement names no key wrap"))?; + let (wrap, wrap_parameters) = exact_algorithm_parts(&wrap, "keyWrapAlgorithm")?; + if wrap.as_str() != oid::AES_256_WRAP || wrap_parameters.is_some() { + return Err(err(format!( + "key wrap algorithm {wrap} is not id-aes256-wrap without parameters" + ))); + } + Ok(kdf) } /// `OtherRecipientInfo`: an `oriType` OID and a value read according to it. -fn read_ori(body: &[u8]) -> Result { +fn read_ori(body: &[u8]) -> Result { let (oid, after_oid) = read_tlv(body)?; let oid = oid_of(&oid, "OtherRecipientInfo.oriType")?; if oid.as_str() != ORI_KEM { // A form this build does not know: name it and read no further, // rather than guess at a structure whose shape is defined elsewhere. - return Ok(Recipient { + return Ok(RecipientFacts { description: WrapDescription::new(RecipientInfoKind::Ori, oid), key_identifier: None, }); @@ -486,7 +631,7 @@ fn read_ori(body: &[u8]) -> Result { /// /// Unlike the key-agreement form, a KEM names its KDF in a field of its own, /// which is what the `kdf_oid` on a description is for. -fn read_kemri(body: &[u8]) -> Result { +fn read_kemri(body: &[u8]) -> Result { let (_version, after_version) = read_tlv(body)?; let (rid, after_rid) = read_tlv(after_version)?; let (kem, after_kem) = read_tlv(after_rid)?; @@ -508,7 +653,7 @@ fn read_kemri(body: &[u8]) -> Result { .with_kdf(algorithm_oid(&kdf, "KEMRecipientInfo.kdf")?) .with_kek_wrap(algorithm_oid(&wrap, "KEMRecipientInfo.wrap")?); - Ok(Recipient { + Ok(RecipientFacts { description, key_identifier: (rid.tag == TAG_CONTEXT_0_PRIMITIVE) .then(|| RecipientIdentifier(rid.value.to_vec())), @@ -570,6 +715,7 @@ fn algorithm_parts<'a>( } const TAG_INTEGER: u8 = 0x02; +const TAG_BIT_STRING: u8 = 0x03; const TAG_OCTET_STRING: u8 = 0x04; const TAG_NULL: u8 = 0x05; const TAG_OID: u8 = 0x06; @@ -697,13 +843,207 @@ 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 one recipient an [`Envelope`] is addressed to, in the `RecipientInfo` +/// form its key takes, with the protected content-encryption key that form +/// carries. +/// +/// Each form carries its own fields, so a reader can determine how to open it. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum RecipientInfo { + /// A `KEKRecipientInfo` under `id-aes256-wrap`, for a symmetric key both + /// sides hold. Rite names the key by its [`KeyCheckValue`]. + Kek { + /// `kekid.keyIdentifier`. + key_identifier: RecipientIdentifier, + /// The CEK under RFC 3394 AES Key Wrap. + wrapped_key: Vec, + }, + /// A version 2 `KeyTransRecipientInfo` under RSAES-OAEP over SHA-256, for + /// an RSA public key. Rite names the key by its [`KeyFingerprint`]. + KeyTransport { + /// The `subjectKeyIdentifier` rid. + key_identifier: RecipientIdentifier, + /// The CEK under RSAES-OAEP. + encrypted_key: Vec, + }, + /// A `KeyAgreeRecipientInfo` under one-pass ECDH, for an EC public key. + KeyAgreement(KeyAgreeRecipient), +} + +impl RecipientInfo { + /// What names the recipient key. + #[must_use] + pub fn key_identifier(&self) -> &RecipientIdentifier { + match self { + Self::Kek { key_identifier, .. } | Self::KeyTransport { key_identifier, .. } => { + key_identifier + } + Self::KeyAgreement(agreed) => &agreed.key_identifier, + } + } + + /// The `RecipientInfo` CHOICE this is. + #[must_use] + pub fn kind(&self) -> RecipientInfoKind { + match self { + Self::Kek { .. } => RecipientInfoKind::Kekri, + Self::KeyTransport { .. } => RecipientInfoKind::Ktri, + Self::KeyAgreement(_) => RecipientInfoKind::Kari, + } + } +} + +/// A content-encryption key protected with ECDH in a CMS +/// `KeyAgreeRecipientInfo` (RFC 5753). +/// +/// The sender creates an ephemeral key pair on the recipient's curve and +/// computes ECDH with the recipient's public key. The X9.63 KDF uses the +/// agreed secret and [`shared_info`](Self::shared_info) to derive a +/// 256-bit key-encryption key. AES Key Wrap protects the content-encryption +/// key with that key. +/// +/// To open the container, the recipient computes ECDH with its private key +/// and the ephemeral public key. The ephemeral point is carried in the +/// container; [`ephemeral_key`](Self::ephemeral_key) builds an SPKI for it +/// using the recipient's curve. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyAgreeRecipient { + /// The `subjectKeyIdentifier` in the `rKeyId` rid. + pub key_identifier: RecipientIdentifier, + /// The ephemeral public key's point, in the SEC 1 encoding the + /// `originatorKey` carries. + pub ephemeral_point: Vec, + /// The user keying material, when the sender supplied some. Rite writes + /// none; the agreement is fresh because the ephemeral key is. + pub ukm: Option>, + /// The KDF the scheme names. + pub kdf: X963Kdf, + /// The CEK under AES-256 Key Wrap. + pub wrapped_key: Vec, +} + +/// The length of the key-encryption key the agreement derives, in bytes: +/// AES-256 key wrap, the one wrap this module writes under it. +pub const AGREED_KEK_LEN: usize = 32; + +impl KeyAgreeRecipient { + /// The `ECC-CMS-SharedInfo` the KDF takes (RFC 5753 §7.2): the wrap + /// algorithm, the `ukm` when present, and the length of the derived key + /// in bits. + /// + /// Binding the wrap algorithm and length into the derivation means a key + /// derived for one wrap cannot be reused for another. + /// + /// # Errors + /// + /// Returns [`CmsReadError`] if this module's wrap identifier fails to + /// encode, which is unreachable. + pub fn shared_info(&self) -> Result, CmsReadError> { + let bits = u32::try_from(AGREED_KEK_LEN * 8).unwrap_or(u32::MAX); + let mut fields = vec![der::sequence(&[encoded_oid(oid::AES_256_WRAP)?])]; + if let Some(ukm) = &self.ukm { + fields.push(der::tlv(TAG_CONTEXT_0_CONSTRUCTED, &der::octet_string(ukm))); + } + fields.push(der::tlv( + TAG_CONTEXT_2_CONSTRUCTED, + &der::octet_string(&bits.to_be_bytes()), + )); + Ok(der::sequence(&fields)) + } + + /// The ephemeral public key, on the curve of `recipient`, the key it was + /// agreed with. + /// + /// # Errors + /// + /// Returns [`CmsReadError`] if `recipient` is not an EC key, or if the + /// point does not form a public key on its curve. + pub fn ephemeral_key(&self, recipient: &PublicKeyDer) -> Result { + let algorithm = recipient.algorithm().map_err(|e| err(e.to_string()))?; + let (curve, _) = agreement_curve(algorithm).ok_or_else(|| { + err(format!( + "a key agreement is addressed to a P-256 or P-384 key, and the recipient is \ + {algorithm}" + )) + })?; + let spki = der::sequence(&[ + der::sequence(&[encoded_oid(oid::ID_EC_PUBLIC_KEY)?, encoded_oid(curve)?]), + der::tlv( + TAG_BIT_STRING, + &[&[0u8][..], &self.ephemeral_point].concat(), + ), + ]); + PublicKeyDer::new(spki).map_err(|e| err(format!("the ephemeral key: {e}"))) + } +} + +/// The curve identifier and the KDF of a key agreement with a key of +/// `algorithm`, if it is one this module writes for. +/// +/// The one table of agreement curves, matched exhaustively, so a key +/// algorithm added to this crate is placed here or refused here at compile +/// time, for sealing and opening alike. +fn agreement_curve(algorithm: KeyAlgorithm) -> Option<(&'static str, X963Kdf)> { + match algorithm { + KeyAlgorithm::EcdsaP256 => Some((oid::SECP256R1, X963Kdf::Sha256)), + KeyAlgorithm::EcdsaP384 => Some((oid::SECP384R1, X963Kdf::Sha384)), + KeyAlgorithm::Rsa2048 + | KeyAlgorithm::Rsa4096 + | KeyAlgorithm::Ed25519 + | KeyAlgorithm::MlDsa44 + | KeyAlgorithm::MlDsa65 + | KeyAlgorithm::MlDsa87 + | KeyAlgorithm::MlKem512 + | KeyAlgorithm::MlKem768 + | KeyAlgorithm::MlKem1024 + | KeyAlgorithm::Aes128 + | KeyAlgorithm::Aes256 => None, + } +} + +/// The X9.63 KDF used to derive a key-encryption key for a CMS key agreement. +/// +/// RFC 5753 §7.1.4 pairs SHA-256 with P-256 and SHA-384 with P-384. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum X963Kdf { + /// `dhSinglePass-stdDH-sha256kdf-scheme`. + Sha256, + /// `dhSinglePass-stdDH-sha384kdf-scheme`. + Sha384, +} + +impl X963Kdf { + /// The KDF RFC 5753 pairs with the curve of `recipient`, if it is an EC + /// key on a curve this module writes for. + #[must_use] + pub fn for_recipient(recipient: KeyAlgorithm) -> Option { + agreement_curve(recipient).map(|(_, kdf)| kdf) + } + + /// The scheme identifier a `keyEncryptionAlgorithm` names for it. + fn scheme(self) -> &'static str { + match self { + Self::Sha256 => oid::DH_SINGLE_PASS_STDDH_SHA256KDF, + Self::Sha384 => oid::DH_SINGLE_PASS_STDDH_SHA384KDF, + } + } +} + +impl fmt::Display for X963Kdf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Sha256 => f.write_str("the X9.63 KDF over SHA-256"), + Self::Sha384 => f.write_str("the X9.63 KDF over SHA-384"), + } + } +} + /// 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. +/// The [`RecipientInfo`] carries the protected content-encryption key in the +/// form its recipient takes. The content is the same whichever it is. /// /// 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 @@ -713,16 +1053,17 @@ pub const GCM_ICV_LEN: usize = 16; /// them out again, and never holds a key. /// /// ``` -/// use rite_sdk::cms::{self, Envelope}; -/// use rite_sdk::{KeyCheckValue, KeyProtection}; +/// use rite_sdk::KeyCheckValue; +/// use rite_sdk::cms::{self, Envelope, RecipientInfo}; /// /// // 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: (&check_value).into(), -/// wrapped_cek: vec![0x11; 40], +/// recipient: RecipientInfo::Kek { +/// key_identifier: (&check_value).into(), +/// wrapped_key: vec![0x11; 40], +/// }, /// nonce: vec![0x22; 12], /// ciphertext: vec![0x33; 64], /// tag: vec![0x44; cms::GCM_ICV_LEN], @@ -742,16 +1083,10 @@ pub const GCM_ICV_LEN: usize = 16; /// ``` #[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. 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 recipient and the content-encryption key protected for it. Rite + /// names a symmetric key by its check value and a public key by its + /// fingerprint; a container read back carries what its writer put there. + pub recipient: RecipientInfo, /// The 12-byte AES-GCM nonce. pub nonce: Vec, /// The content under AES-256-GCM. @@ -768,9 +1103,9 @@ pub struct Envelope { /// /// # Errors /// -/// 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. +/// Returns [`CmsReadError`] if the tag is not a length RFC 5084 admits, if a +/// key agreement carries no ephemeral point, 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!( @@ -817,39 +1152,83 @@ fn assemble_auth_enveloped( ])) } -/// The one `RecipientInfo` an envelope carries, in the form its protection -/// takes. +/// The one `RecipientInfo` an envelope carries. fn recipient_info(envelope: &Envelope) -> Result, CmsReadError> { - match envelope.protection { + match &envelope.recipient { // 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( + RecipientInfo::Kek { + key_identifier, + wrapped_key, + } => Ok(der::tlv( TAG_CONTEXT_2_CONSTRUCTED, &[ der::small_integer(4), - der::sequence(&[der::octet_string(envelope.key_identifier.as_bytes())]), + der::sequence(&[der::octet_string(key_identifier.as_bytes())]), der::sequence(&[encoded_oid(oid::AES_256_WRAP)?]), - der::octet_string(&envelope.wrapped_cek), + der::octet_string(wrapped_key), ] .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(&[ + RecipientInfo::KeyTransport { + key_identifier, + encrypted_key, + } => Ok(der::sequence(&[ der::small_integer(2), - der::tlv(TAG_CONTEXT_0_PRIMITIVE, envelope.key_identifier.as_bytes()), + der::tlv(TAG_CONTEXT_0_PRIMITIVE, key_identifier.as_bytes()), rsaes_oaep_sha256()?, - der::octet_string(&envelope.wrapped_cek), + der::octet_string(encrypted_key), ])), - KeyProtection::ProviderDefined => Err(err( - "a data key in its provider's own envelope names no algorithm a RecipientInfo \ - can declare", - )), + RecipientInfo::KeyAgreement(agreed) => key_agree_recipient_info(agreed), } } +/// A `KeyAgreeRecipientInfo`, tagged [1] in the `RecipientInfo` CHOICE, as +/// OpenSSL writes one: version 3 (RFC 5652 §6.2.2), the ephemeral key as an +/// `originatorKey` under `id-ecPublicKey` with the parameters absent, and the +/// wrap identifier without parameters. The recipient is named by `rKeyId`, +/// whose subject key identifier names the key itself, as a `ktri`'s does. +fn key_agree_recipient_info(agreed: &KeyAgreeRecipient) -> Result, CmsReadError> { + // The reader refuses a public key with no point, so the writer does too. + if agreed.ephemeral_point.is_empty() { + return Err(err("a key agreement needs the ephemeral key's point")); + } + let originator_key = der::tlv( + TAG_CONTEXT_1_CONSTRUCTED, + &[ + der::sequence(&[encoded_oid(oid::ID_EC_PUBLIC_KEY)?]), + der::tlv( + TAG_BIT_STRING, + &[&[0u8][..], &agreed.ephemeral_point].concat(), + ), + ] + .concat(), + ); + let mut fields = vec![ + der::small_integer(3), + der::tlv(TAG_CONTEXT_0_CONSTRUCTED, &originator_key), + ]; + if let Some(ukm) = &agreed.ukm { + fields.push(der::tlv(TAG_CONTEXT_1_CONSTRUCTED, &der::octet_string(ukm))); + } + fields.push(der::sequence(&[ + encoded_oid(agreed.kdf.scheme())?, + der::sequence(&[encoded_oid(oid::AES_256_WRAP)?]), + ])); + fields.push(der::sequence(&[der::sequence(&[ + der::tlv( + TAG_CONTEXT_0_CONSTRUCTED, + &der::octet_string(agreed.key_identifier.as_bytes()), + ), + der::octet_string(&agreed.wrapped_key), + ])])); + Ok(der::tlv(TAG_CONTEXT_1_CONSTRUCTED, &fields.concat())) +} + /// `id-RSAES-OAEP` with SHA-256 for the digest and for MGF1, and the empty /// label. /// @@ -876,10 +1255,9 @@ fn encoded_oid(dotted: &str) -> Result, CmsReadError> { /// 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. +/// and a single recipient, in one of the [`RecipientInfo`] forms, naming 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 @@ -926,7 +1304,7 @@ pub fn read_auth_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 (protection, key_identifier, wrapped_cek) = openable_recipient(&recipient)?; + let recipient = openable_recipient(&recipient)?; let (content_info, after_content) = read_tlv(after_recipients)?; let (nonce, icv_len, ciphertext) = @@ -956,26 +1334,21 @@ pub fn read_auth_enveloped(der_bytes: &[u8]) -> Result { } Ok(Envelope { - protection, - key_identifier: RecipientIdentifier(key_identifier.to_vec()), - wrapped_cek: wrapped_cek.to_vec(), + recipient, 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. +/// A recipient this crate can open, with what names it and its protected CEK. /// /// 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> { +fn openable_recipient(recipient: &Tlv<'_>) -> Result { match recipient.tag { TAG_CONTEXT_2_CONSTRUCTED => { let kekri = read_kekri(recipient.value)?; @@ -985,11 +1358,10 @@ fn openable_recipient<'a>( kekri.algorithm ))); } - Ok(( - KeyProtection::AesKeyWrap, - kekri.key_identifier, - kekri.encrypted_key, - )) + Ok(RecipientInfo::Kek { + key_identifier: RecipientIdentifier(kekri.key_identifier.to_vec()), + wrapped_key: kekri.encrypted_key.to_vec(), + }) } TAG_SEQUENCE => { let ktri = read_ktri(recipient.value)?; @@ -1002,15 +1374,38 @@ fn openable_recipient<'a>( certificate rather than a key", ) })?; - Ok(( - KeyProtection::RsaOaepSha256, - key_identifier, - ktri.encrypted_key, - )) + Ok(RecipientInfo::KeyTransport { + key_identifier: RecipientIdentifier(key_identifier.to_vec()), + encrypted_key: ktri.encrypted_key.to_vec(), + }) + } + TAG_CONTEXT_1_CONSTRUCTED => { + let kari = read_kari_fields(recipient.value)?; + if kari.more_keys { + // As for several recipients: choosing which key to try is how + // a blob opens with the wrong one. + return Err(err( + "this KeyAgreeRecipientInfo addresses more than one key", + )); + } + let kdf = agreement_kdf(&kari.algorithm)?; + let ephemeral_point = originator_point(&kari.originator)?; + // As for a key transport recipient: no certificate is here to + // match an issuer and serial against. + let key_identifier = kari.key_identifier.ok_or_else(|| { + err( + "the recipient is named by issuer and serial number, which identifies a \ + certificate rather than a key", + ) + })?; + Ok(RecipientInfo::KeyAgreement(KeyAgreeRecipient { + key_identifier: RecipientIdentifier(key_identifier.to_vec()), + ephemeral_point: ephemeral_point.to_vec(), + ukm: kari.ukm.map(<[u8]>::to_vec), + kdf, + wrapped_key: kari.encrypted_key.to_vec(), + })) } - 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", )), @@ -1132,9 +1527,10 @@ mod tests { fn sample_envelope() -> Envelope { Envelope { - protection: KeyProtection::AesKeyWrap, - key_identifier: RecipientIdentifier(vec![0x76, 0x3c, 0xbc]), - wrapped_cek: vec![0x11; 40], + recipient: RecipientInfo::Kek { + key_identifier: RecipientIdentifier(vec![0x76, 0x3c, 0xbc]), + wrapped_key: vec![0x11; 40], + }, nonce: vec![0x22; 12], ciphertext: vec![0x33; 64], tag: vec![0x44; 16], @@ -1238,13 +1634,111 @@ mod tests { /// of its SPKI. fn key_transport_envelope() -> Envelope { Envelope { - protection: KeyProtection::RsaOaepSha256, - key_identifier: RecipientIdentifier(vec![0x89; 32]), - wrapped_cek: vec![0x11; 256], + recipient: RecipientInfo::KeyTransport { + key_identifier: RecipientIdentifier(vec![0x89; 32]), + encrypted_key: vec![0x11; 256], + }, ..sample_envelope() } } + /// An envelope whose CEK is wrapped under a key agreed with a P-256 key. + fn key_agreement_envelope() -> Envelope { + Envelope { + recipient: RecipientInfo::KeyAgreement(KeyAgreeRecipient { + key_identifier: RecipientIdentifier(vec![0x89; 32]), + ephemeral_point: [&[0x04][..], &[0x5c; 64]].concat(), + ukm: None, + kdf: X963Kdf::Sha256, + wrapped_key: vec![0x11; 40], + }), + ..sample_envelope() + } + } + + #[test] + fn a_key_agreement_envelope_round_trips() { + let envelope = key_agreement_envelope(); + let der = write_auth_enveloped(&envelope).unwrap(); + assert_eq!(read_auth_enveloped(&der).unwrap(), envelope); + + // With user keying material and the other KDF. + let RecipientInfo::KeyAgreement(agreed) = envelope.recipient else { + unreachable!() + }; + let envelope = Envelope { + recipient: RecipientInfo::KeyAgreement(KeyAgreeRecipient { + ukm: Some(vec![0x7e; 64]), + kdf: X963Kdf::Sha384, + ..agreed.clone() + }), + ..sample_envelope() + }; + let der = write_auth_enveloped(&envelope).unwrap(); + assert_eq!(read_auth_enveloped(&der).unwrap(), envelope); + + // What the reader would refuse, the writer refuses. + let pointless = Envelope { + recipient: RecipientInfo::KeyAgreement(KeyAgreeRecipient { + ephemeral_point: Vec::new(), + ..agreed + }), + ..sample_envelope() + }; + assert!(write_auth_enveloped(&pointless).is_err()); + } + + #[test] + fn a_key_agreement_envelope_describes_itself() { + let der = write_auth_enveloped(&key_agreement_envelope()).unwrap(); + let facts = describe(&der).unwrap(); + + assert_eq!( + facts.description.recipient_info, + Some(RecipientInfoKind::Kari) + ); + assert_eq!( + facts.description.key_encryption_oid.as_str(), + oid::DH_SINGLE_PASS_STDDH_SHA256KDF + ); + assert_eq!( + facts.description.kek_wrap_oid.as_ref().map(Oid::as_str), + Some(oid::AES_256_WRAP) + ); + assert_eq!( + facts.recipient_key_identifier, + Some(RecipientIdentifier(vec![0x89; 32])) + ); + assert!(crate::types::WrapScheme::CmsAes256Gcm.permits(&facts.description)); + } + + /// The `ECC-CMS-SharedInfo` OpenSSL feeds its KDF for a 256-bit wrap key + /// and no ukm: the wrap identifier, then `[2]` holding the length in bits. + #[test] + fn the_shared_info_binds_the_wrap_and_its_length() { + let RecipientInfo::KeyAgreement(agreed) = key_agreement_envelope().recipient else { + unreachable!() + }; + assert_eq!( + agreed.shared_info().unwrap(), + [ + 0x30, 0x15, 0x30, 0x0b, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, + 0x2d, 0xa2, 0x06, 0x04, 0x04, 0x00, 0x00, 0x01, 0x00, + ] + ); + + let with_ukm = KeyAgreeRecipient { + ukm: Some(vec![0xab, 0xcd]), + ..agreed + }; + let encoded = with_ukm.shared_info().unwrap(); + assert_eq!( + encoded.get(15..21), + Some(&[0xa0, 0x04, 0x04, 0x02, 0xab, 0xcd][..]), + "the ukm is entityUInfo, [0], between the wrap and the length" + ); + } + #[test] fn a_key_transport_envelope_round_trips() { let envelope = key_transport_envelope(); @@ -1301,11 +1795,18 @@ mod tests { /// well-formed. fn with_key_transport_algorithm(algorithm: Vec) -> Vec { let envelope = key_transport_envelope(); + let RecipientInfo::KeyTransport { + key_identifier, + encrypted_key, + } = &envelope.recipient + else { + unreachable!() + }; let ktri = der::sequence(&[ der::small_integer(2), - der::tlv(TAG_CONTEXT_0_PRIMITIVE, envelope.key_identifier.as_bytes()), + der::tlv(TAG_CONTEXT_0_PRIMITIVE, key_identifier.as_bytes()), algorithm, - der::octet_string(&envelope.wrapped_cek), + der::octet_string(encrypted_key), ]); assemble_auth_enveloped(&envelope, ktri).unwrap() } @@ -1334,8 +1835,9 @@ mod tests { assert_eq!( read_auth_enveloped(&with_key_transport_algorithm(accepted)) .unwrap() - .protection, - KeyProtection::RsaOaepSha256 + .recipient + .kind(), + RecipientInfoKind::Ktri ); let refused = [ @@ -1437,13 +1939,15 @@ 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.as_bytes())]; + let mut kekid = vec![der::octet_string( + envelope.recipient.key_identifier().as_bytes(), + )]; 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), + der::octet_string(&[0x11; 40]), ] .concat(); body.extend_from_slice(trailing); @@ -1481,9 +1985,12 @@ 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.as_bytes()), + der::tlv( + TAG_CONTEXT_0_PRIMITIVE, + envelope.recipient.key_identifier().as_bytes(), + ), algorithm, - der::octet_string(&envelope.wrapped_cek), + der::octet_string(&[0x11; 256]), ] .concat(); body.extend_from_slice(trailing); @@ -1529,15 +2036,254 @@ mod tests { assert!(error.contains("hashFunc"), "{error}"); } - /// A data key in its provider's own envelope names no algorithm, so no - /// `RecipientInfo` can carry it. + /// The fields of a `KeyAgreeRecipientInfo`, each encoded, to build one + /// that differs from what the writer produces in one field. + struct KariFields { + version: u8, + originator: Vec, + algorithm: Vec, + keys: Vec, + } + + fn oid(dotted: &str) -> Vec { + der::object_identifier(dotted).unwrap() + } + + /// An `originatorKey` under `id-ecPublicKey` with `parameters`. + fn originator_key(parameters: &[Vec]) -> Vec { + let mut algorithm = vec![oid(super::oid::ID_EC_PUBLIC_KEY)]; + algorithm.extend_from_slice(parameters); + let point = der::tlv(TAG_BIT_STRING, &[&[0u8, 0x04][..], &[0x5c; 64]].concat()); + der::tlv( + TAG_CONTEXT_1_CONSTRUCTED, + &[der::sequence(&algorithm), point].concat(), + ) + } + + fn agreement_scheme(kdf: &str, wrap: &str) -> Vec { + der::sequence(&[oid(kdf), der::sequence(&[oid(wrap)])]) + } + + fn recipient_keys(rids: &[Vec]) -> Vec { + let keys: Vec<_> = rids + .iter() + .map(|rid| der::sequence(&[rid.clone(), der::octet_string(&[0x11; 40])])) + .collect(); + der::sequence(&keys) + } + + fn rkeyid() -> Vec { + der::tlv(TAG_CONTEXT_0_CONSTRUCTED, &der::octet_string(&[0x89; 32])) + } + + impl KariFields { + /// What the writer produces for [`key_agreement_envelope`]. + fn written() -> Self { + Self { + version: 3, + originator: originator_key(&[]), + algorithm: agreement_scheme( + super::oid::DH_SINGLE_PASS_STDDH_SHA256KDF, + super::oid::AES_256_WRAP, + ), + keys: recipient_keys(&[rkeyid()]), + } + } + + fn container(self) -> Vec { + let body = [ + der::small_integer(self.version), + der::tlv(TAG_CONTEXT_0_CONSTRUCTED, &self.originator), + self.algorithm, + self.keys, + ] + .concat(); + assemble_auth_enveloped( + &key_agreement_envelope(), + der::tlv(TAG_CONTEXT_1_CONSTRUCTED, &body), + ) + .unwrap() + } + } + + /// The key agreement recipient is read as exactly as the others, by the + /// walker describing and opening share. #[test] - fn writing_refuses_a_provider_defined_protection() { - let envelope = Envelope { - protection: KeyProtection::ProviderDefined, - ..sample_envelope() + fn a_key_agreement_recipient_is_read_exactly() { + assert_eq!( + read_auth_enveloped(&KariFields::written().container()).unwrap(), + key_agreement_envelope() + ); + // RFC 5753 admits a NULL for the originator key's parameters too. + let with_null = KariFields { + originator: originator_key(&[vec![TAG_NULL, 0x00]]), + ..KariFields::written() }; - assert!(write_auth_enveloped(&envelope).is_err()); + assert!(read_auth_enveloped(&with_null.container()).is_ok()); + + let trailing_rkeyid = der::tlv( + TAG_CONTEXT_0_CONSTRUCTED, + &[der::octet_string(&[0x89; 32]), vec![TAG_NULL, 0x00]].concat(), + ); + for (fields, named) in [ + ( + KariFields { + version: 2, + ..KariFields::written() + }, + "version 3", + ), + ( + KariFields { + keys: recipient_keys(&[trailing_rkeyid]), + ..KariFields::written() + }, + "rKeyId carries bytes", + ), + ( + KariFields { + keys: der::sequence(&[ + der::sequence(&[rkeyid(), der::octet_string(&[0x11; 40])]), + vec![TAG_NULL, 0x00], + ]), + ..KariFields::written() + }, + "RecipientEncryptedKey", + ), + ] { + let der = fields.container(); + 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:?}"); + } + } + + /// Only the scheme this crate runs opens: ECDH with the X9.63 KDF over + /// SHA-256 or SHA-384, under AES-256 key wrap, from an ephemeral key whose + /// curve is the recipient's, to a key named by its identifier. A foreign + /// container naming anything else is still described. + #[test] + fn opening_admits_only_the_agreement_this_crate_runs() { + let issuer_and_serial = der::sequence(&[der::sequence(&[]), der::small_integer(1)]); + for (fields, named) in [ + ( + KariFields { + algorithm: agreement_scheme( + super::oid::DH_SINGLE_PASS_STDDH_SHA1KDF, + super::oid::AES_256_WRAP, + ), + ..KariFields::written() + }, + "SHA-1", + ), + ( + KariFields { + algorithm: agreement_scheme( + super::oid::DH_SINGLE_PASS_STDDH_SHA256KDF, + super::oid::AES_128_WRAP, + ), + ..KariFields::written() + }, + "id-aes256-wrap", + ), + ( + KariFields { + originator: originator_key(&[oid(super::oid::SECP256R1)]), + ..KariFields::written() + }, + "names its curve", + ), + ( + KariFields { + originator: der::tlv( + TAG_CONTEXT_1_CONSTRUCTED, + &[ + der::sequence(&[oid(super::oid::ID_EC_PUBLIC_KEY)]), + der::tlv(TAG_BIT_STRING, &[&[0u8, 0x02][..], &[0x5c; 32]].concat()), + ] + .concat(), + ), + ..KariFields::written() + }, + "point is compressed", + ), + ( + KariFields { + originator: der::tlv(TAG_CONTEXT_0_PRIMITIVE, &[0x89; 20]), + ..KariFields::written() + }, + "named rather than given", + ), + ( + KariFields { + keys: recipient_keys(&[issuer_and_serial]), + ..KariFields::written() + }, + "issuer and serial", + ), + ( + KariFields { + keys: recipient_keys(&[rkeyid(), rkeyid()]), + ..KariFields::written() + }, + "more than one key", + ), + ] { + let der = fields.container(); + assert!(describe(&der).is_ok()); + let error = read_auth_enveloped(&der).unwrap_err().to_string(); + assert!(error.contains(named), "{error}"); + } + } + + /// The ephemeral key takes the recipient's curve, and only an EC recipient + /// has one. + #[test] + fn the_ephemeral_key_is_on_the_recipients_curve() { + // A P-256 public key: the generator point, which is on the curve. + let generator = [ + 0x04, 0x6b, 0x17, 0xd1, 0xf2, 0xe1, 0x2c, 0x42, 0x47, 0xf8, 0xbc, 0xe6, 0xe5, 0x63, + 0xa4, 0x40, 0xf2, 0x77, 0x03, 0x7d, 0x81, 0x2d, 0xeb, 0x33, 0xa0, 0xf4, 0xa1, 0x39, + 0x45, 0xd8, 0x98, 0xc2, 0x96, 0x4f, 0xe3, 0x42, 0xe2, 0xfe, 0x1a, 0x7f, 0x9b, 0x8e, + 0xe7, 0xeb, 0x4a, 0x7c, 0x0f, 0x9e, 0x16, 0x2b, 0xce, 0x33, 0x57, 0x6b, 0x31, 0x5e, + 0xce, 0xcb, 0xb6, 0x40, 0x68, 0x37, 0xbf, 0x51, 0xf5, + ]; + let agreed = KeyAgreeRecipient { + ephemeral_point: generator.to_vec(), + ..match key_agreement_envelope().recipient { + RecipientInfo::KeyAgreement(agreed) => agreed, + RecipientInfo::Kek { .. } | RecipientInfo::KeyTransport { .. } => unreachable!(), + } + }; + let recipient = agreed.ephemeral_key(&p256_public_key(&generator)).unwrap(); + assert_eq!(recipient.algorithm().unwrap(), KeyAlgorithm::EcdsaP256); + assert_eq!(recipient, p256_public_key(&generator)); + + let ed25519 = PublicKeyDer::new(der::sequence(&[ + der::sequence(&[der::object_identifier("1.3.101.112").unwrap()]), + der::tlv(TAG_BIT_STRING, &[0u8; 33]), + ])) + .unwrap(); + let error = agreed.ephemeral_key(&ed25519).unwrap_err().to_string(); + assert!( + error.contains("addressed to a P-256 or P-384 key"), + "{error}" + ); + } + + fn p256_public_key(point: &[u8]) -> PublicKeyDer { + PublicKeyDer::new(der::sequence(&[ + der::sequence(&[ + der::object_identifier(super::oid::ID_EC_PUBLIC_KEY).unwrap(), + der::object_identifier(super::oid::SECP256R1).unwrap(), + ]), + der::tlv(TAG_BIT_STRING, &[&[0u8][..], point].concat()), + ])) + .unwrap() } /// Reading is the half that takes untrusted bytes, so it refuses what it diff --git a/crates/rite-sdk/src/key_material.rs b/crates/rite-sdk/src/key_material.rs index 934e517..0d42301 100644 --- a/crates/rite-sdk/src/key_material.rs +++ b/crates/rite-sdk/src/key_material.rs @@ -52,15 +52,31 @@ impl PublicKeyDer { /// OpenSSL exports, which is what a backend holding the private half /// reports. /// + /// An EC key has two encodings for the same point: uncompressed, + /// `04 || x || y`, and compressed, `02` or `03` followed by `x` alone. + /// They would have different fingerprints, and conversion requires curve + /// arithmetic this crate does not carry. A compressed key is refused rather + /// than converted, so the fingerprint an operator computes from their own + /// bytes is the one Rite records. + /// /// # Errors /// /// Returns [`BackendError::InvalidKeyFormat`] when the bytes are not a /// `SubjectPublicKeyInfo`, which is what a certificate or a PEM armouring - /// arriving here looks like. + /// arriving here looks like, and when an EC key's point is not + /// uncompressed. pub fn new(der: Vec) -> Result { let spki = SubjectPublicKeyInfoRef::from_der(&der).map_err(|e| { BackendError::InvalidKeyFormat(format!("not a SubjectPublicKeyInfo: {e}")) })?; + if spki.algorithm.oid == ID_EC_PUBLIC_KEY + && spki.subject_public_key.as_bytes().and_then(<[u8]>::first) != Some(&0x04) + { + return Err(BackendError::InvalidKeyFormat( + "the EC public key's point is compressed, and Rite accepts EC keys uncompressed" + .to_string(), + )); + } if spki.algorithm.oid == RSA_ENCRYPTION && spki.algorithm.parameters.is_none() { let canonical = SubjectPublicKeyInfoRef { algorithm: AlgorithmIdentifierRef { @@ -107,16 +123,16 @@ impl PublicKeyDer { }; } - // DER says nothing about itself, so try both shapes. - if let Ok(key) = Self::new(bytes.to_vec()) { - return Ok(key); - } - // Neither shape parsed. The certificate attempt carries the more useful - // reason, since bytes that reach here and are close to valid are far - // more often a certificate than a bare key. + // DER says nothing about itself, so try both shapes, and report both + // reasons when neither parses. + let key_error = match Self::new(bytes.to_vec()) { + Ok(key) => return Ok(key), + Err(e) => e, + }; certificate_public_key(bytes).map_err(|e| { BackendError::InvalidKeyFormat(format!( - "not a public key or certificate, in DER or PEM ({e})" + "not a public key or certificate, in DER or PEM (as a key: {key_error}; as a \ + certificate: {e})" )) }) } @@ -563,6 +579,43 @@ mod tests { ); } + /// The P-256 vector with its point compressed: `02` or `03` by the parity + /// of y, then x. + fn p256_compressed() -> Vec { + let spki = SubjectPublicKeyInfoRef::from_der(vectors::P256).unwrap(); + let (_, coordinates) = spki + .subject_public_key + .as_bytes() + .unwrap() + .split_first() + .unwrap(); + let (x, y) = coordinates.split_at(32); + let prefix = 0x02 | (y.last().unwrap() & 0x01); + let compressed = [&[prefix][..], x].concat(); + SubjectPublicKeyInfoRef { + algorithm: spki.algorithm, + subject_public_key: x509_cert::der::asn1::BitStringRef::from_bytes(&compressed) + .unwrap(), + } + .to_der() + .unwrap() + } + + /// A compressed point is the same key under another encoding, so it would + /// have another fingerprint. It is refused, naming the conversion. + #[test] + fn an_ec_key_is_accepted_only_with_its_point_uncompressed() { + assert!(PublicKeyDer::new(vectors::P256.to_vec()).is_ok()); + let error = PublicKeyDer::new(p256_compressed()) + .unwrap_err() + .to_string(); + assert!(error.contains("compressed"), "{error}"); + let error = PublicKeyDer::from_key_material(&p256_compressed()) + .unwrap_err() + .to_string(); + assert!(error.contains("point is compressed"), "{error}"); + } + #[test] fn a_fingerprint_is_the_sha256_of_the_spki() { use sha2::Digest as _; diff --git a/crates/rite-sdk/src/lib.rs b/crates/rite-sdk/src/lib.rs index 523d35e..8a8495e 100644 --- a/crates/rite-sdk/src/lib.rs +++ b/crates/rite-sdk/src/lib.rs @@ -34,16 +34,18 @@ mod types; pub use key_material::{CertificateDer, PublicKeyDer}; pub use backend::{ - AttestationBackend, Backend, BackendError, CertStoreBackend, KeyStoreBackend, - KeyTransportBackend, PivBackend, Pkcs11AdminBackend, Pkcs11Backend, RandomBackend, - Retriability, SignBackend, TpmBackend, VerifyBackend, YubikeyBackend, lock_device, + AttestationBackend, Backend, BackendError, CertStoreBackend, KeyAgreementBackend, + KeyStoreBackend, KeyTransportBackend, PivBackend, Pkcs11AdminBackend, Pkcs11Backend, + RandomBackend, Retriability, SignBackend, TpmBackend, VerifyBackend, YubikeyBackend, + lock_device, }; pub use types::{ - Attestation, AttestationKind, BackendConfig, CertRef, DataKey, DeviceInfo, EncryptedData, - 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, + AgreedSecret, Attestation, AttestationKind, BackendConfig, CertRef, DataKey, DeviceInfo, + EncryptedData, 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 a372a65..6cd1878 100644 --- a/crates/rite-sdk/src/types.rs +++ b/crates/rite-sdk/src/types.rs @@ -211,9 +211,11 @@ impl TryFrom for KeyCheckValue { /// /// 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 names an encoding of a key. `PublicKeyDer` keeps one encoding per +/// key, writing an RSA key's absent parameters as NULL and refusing an EC +/// point that is not uncompressed, so within Rite a key has one +/// fingerprint. Another tool hashing an encoding Rite would not keep gets +/// another value. /// - 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 @@ -562,6 +564,29 @@ impl KeyUsages { .collect() } + /// The usage a key of `algorithm` needs to open a protected key. + /// + /// P-256 and P-384 keys need [`DERIVE`](Self::DERIVE) for key agreement. + /// Every other algorithm maps to [`UNWRAP`](Self::UNWRAP); whether a key of + /// that algorithm can open a protected key is for the operation to refuse. + #[must_use] + pub fn to_open(algorithm: KeyAlgorithm) -> Self { + match algorithm { + KeyAlgorithm::EcdsaP256 | KeyAlgorithm::EcdsaP384 => Self::DERIVE, + KeyAlgorithm::Rsa2048 + | KeyAlgorithm::Rsa4096 + | KeyAlgorithm::Ed25519 + | KeyAlgorithm::MlDsa44 + | KeyAlgorithm::MlDsa65 + | KeyAlgorithm::MlDsa87 + | KeyAlgorithm::MlKem512 + | KeyAlgorithm::MlKem768 + | KeyAlgorithm::MlKem1024 + | KeyAlgorithm::Aes128 + | KeyAlgorithm::Aes256 => Self::UNWRAP, + } + } + /// Every usage name a ceremony may write, for a message listing what is /// available. #[must_use] @@ -620,8 +645,9 @@ impl KeyPolicy { return Ok(()); } Err(BackendError::OperationNotPermitted(format!( - "this key may not {operation}: its policy allows {}. \ + "this key may not {operation}: that needs {} and its policy allows {}. \ Declare the usage under `policy:` on the step that generates it.", + usage.describe(), self.usages.describe() ))) } @@ -870,6 +896,18 @@ pub mod oid { /// `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"; + /// `dhSinglePass-stdDH-sha256kdf-scheme`, one-pass ECDH with the X9.63 KDF + /// over SHA-256 (RFC 5753). + pub const DH_SINGLE_PASS_STDDH_SHA256KDF: &str = "1.3.132.1.11.1"; + /// `dhSinglePass-stdDH-sha384kdf-scheme`, one-pass ECDH with the X9.63 KDF + /// over SHA-384 (RFC 5753). + pub const DH_SINGLE_PASS_STDDH_SHA384KDF: &str = "1.3.132.1.11.2"; + /// `id-ecPublicKey`, an elliptic-curve public key (RFC 5480). + pub const ID_EC_PUBLIC_KEY: &str = "1.2.840.10045.2.1"; + /// `secp256r1`, the NIST P-256 curve (RFC 5480). + pub const SECP256R1: &str = "1.2.840.10045.3.1.7"; + /// `secp384r1`, the NIST P-384 curve (RFC 5480). + pub const SECP384R1: &str = "1.3.132.0.34"; /// `id-aes256-GCM` (RFC 5084). pub const AES_256_GCM: &str = "2.16.840.1.101.3.4.1.46"; /// `id-aes256-CBC` (RFC 3565). @@ -895,7 +933,7 @@ pub enum RecipientInfoKind { /// `KeyTransRecipientInfo`: the CEK is encrypted under the recipient's /// public key. Ktri, - /// `KeyAgreeRecipientInfo`: a shared secret is agreed with the recipient's + /// `KeyAgreeRecipientInfo`: a secret is agreed with the recipient's /// key, run through a KDF, and used to wrap the CEK. Kari, /// `KEKRecipientInfo`: the CEK is wrapped under a previously shared @@ -1524,6 +1562,36 @@ impl DataKey { } } +/// The secret a key agreement produces: Z in NIST SP 800-56A, input to a +/// key-derivation function and never a key on its own. +/// +/// The bytes are available only through [`kdf_input`](Self::kdf_input), for +/// use as input to the KDF specified by the protocol. +/// +/// The secret is wiped when dropped, cannot be cloned, and its `Debug` output +/// shows only its length. +pub struct AgreedSecret(zeroize::Zeroizing>); + +impl AgreedSecret { + /// Take the bytes a key agreement produced. + #[must_use] + pub fn new(bytes: zeroize::Zeroizing>) -> Self { + Self(bytes) + } + + /// The bytes, for the KDF the container format names. + #[must_use] + pub fn kdf_input(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for AgreedSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "AgreedSecret({} bytes)", self.0.len()) + } +} + /// The kind of attestation evidence a backend can produce. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1847,6 +1915,14 @@ pub struct BackendConfig { mod tests { use super::*; + /// An agreed secret's bytes stay out of debug output and logs. + #[test] + fn an_agreed_secret_debugs_as_its_length_alone() { + let secret = AgreedSecret::new(zeroize::Zeroizing::new(vec![0x5a; 32])); + assert_eq!(format!("{secret:?}"), "AgreedSecret(32 bytes)"); + assert_eq!(secret.kdf_input(), [0x5a; 32]); + } + #[test] fn key_algorithm_serde_roundtrip() { // Serde uses Display strings via `serde(into/try_from)`. These are the canonical diff --git a/crates/rite-stdlib/src/backend/mock.rs b/crates/rite-stdlib/src/backend/mock.rs index 8cdef56..4eae644 100644 --- a/crates/rite-stdlib/src/backend/mock.rs +++ b/crates/rite-stdlib/src/backend/mock.rs @@ -31,8 +31,9 @@ use rite_sdk::{ use rite_openssl::OpenSslBackend; #[cfg(feature = "openssl")] use rite_sdk::{ - DataKey, KeyMetadata, KeyPolicy, KeyProtection, KeySpec, KeyStoreBackend, KeyTransportBackend, - RandomBackend, SignAlgorithm, SignBackend, VerifyBackend, WrapScheme, WrappedKey, + DataKey, KeyAgreementBackend, KeyMetadata, KeyPolicy, KeyProtection, KeySpec, KeyStoreBackend, + KeyTransportBackend, RandomBackend, SignAlgorithm, SignBackend, VerifyBackend, WrapScheme, + WrappedKey, }; #[cfg(feature = "openssl")] use zeroize::Zeroizing; @@ -205,6 +206,8 @@ impl Backend for MockBackend { #[cfg(feature = "openssl")] as_transport_mut: KeyTransportBackend, #[cfg(feature = "openssl")] + as_agreement_mut: KeyAgreementBackend, + #[cfg(feature = "openssl")] as_random_mut: RandomBackend, as_attest_mut: AttestationBackend, as_certstore_mut: CertStoreBackend, @@ -331,6 +334,17 @@ impl KeyTransportBackend for MockBackend { } } +#[cfg(feature = "openssl")] +impl KeyAgreementBackend for MockBackend { + fn agree( + &mut self, + key_id: &KeyId, + peer: &PublicKeyDer, + ) -> Result { + self.crypto.agree(key_id, peer) + } +} + #[cfg(feature = "openssl")] impl RandomBackend for MockBackend { fn generate_random(&mut self, len: usize) -> Result, BackendError> { diff --git a/crates/rite-stdlib/src/crypto/content.rs b/crates/rite-stdlib/src/crypto/content.rs index 83884f1..2e9303a 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, DataKey, PublicKeyDer}; +use rite_sdk::{AgreedSecret, BackendError, PublicKeyDer, RandomBackend, cms}; use zeroize::Zeroizing; /// Content and the values a container carries beside it. @@ -41,19 +41,38 @@ pub fn open(cek: &[u8], sealed: &SealedContent) -> Result>, Ba rite_openssl::open_content(cek, sealed) } -/// Protect a content-encryption key for the holder of `recipient`. +/// Protect a content-encryption key for the holder of `recipient`, in the +/// recipient form its key takes. /// -/// 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. +/// No backend key takes part: encrypting to a public key uses no stored +/// secret. The step's backend contributes random bytes: the content key's, +/// which the caller draws, and for an EC recipient the ephemeral key's, drawn +/// from `random` here. /// /// # Errors /// /// Returns [`BackendError`] if the recipient is not a key a container can -/// address. +/// address, or if `random` fails. pub fn protect_for( recipient: &PublicKeyDer, - cek: Zeroizing>, -) -> Result { - rite_openssl::data_key_for_recipient(recipient, cek) + cek: &[u8], + random: &mut dyn RandomBackend, +) -> Result { + rite_openssl::recipient_info_for(recipient, cek, random) +} + +/// Recover the content-encryption key a key agreement recipient carries, from +/// the secret the recipient's key agreed with the ephemeral one. +/// +/// The host's half of the opening, after the backend's ECDH. +/// +/// # Errors +/// +/// Returns [`BackendError`] if the unwrap fails its integrity check, which is +/// what a secret agreed with the wrong key gets. +pub fn open_key_agreement( + agreed: &cms::KeyAgreeRecipient, + secret: &AgreedSecret, +) -> Result>, BackendError> { + rite_openssl::open_key_agreement(agreed, secret) } diff --git a/crates/rite-stdlib/src/crypto/decrypt_data.rs b/crates/rite-stdlib/src/crypto/decrypt_data.rs index 9abe499..905bc8d 100644 --- a/crates/rite-stdlib/src/crypto/decrypt_data.rs +++ b/crates/rite-stdlib/src/crypto/decrypt_data.rs @@ -6,10 +6,12 @@ use rite_runtime::{ StepResult, compute_fingerprint, resolve_backend_key, }; use rite_sdk::{ - Backend, EncryptedData, KeyCheckValue, KeyProtection, PublicKeyDer, WrapScheme, cms, + Backend, EncryptedData, KeyCheckValue, KeyProtection, PublicKeyDer, RecipientInfoKind, + WrapScheme, cms, }; use secrecy::SecretBox; use serde_json::{Value, json}; +use zeroize::Zeroizing; use crate::crypto::content::{self, SealedContent}; @@ -83,15 +85,16 @@ impl Action for DecryptDataAction { 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())?; + let own = KeyName::of(&key, envelope.recipient.kind(), &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 !own.answers_to(&envelope.key_identifier) { + let identifier = envelope.recipient.key_identifier(); + if !own.answers_to(identifier) { return Err(ActionError::Failed( - own.refusal(&envelope.key_identifier, &key_ref.display_name()), + own.refusal(identifier, &key_ref.display_name()), )); } @@ -100,20 +103,18 @@ impl Action for DecryptDataAction { format!("Decrypting '{}' under {scheme}...", data_ref.display_name()), )?; - let (transport, _) = - crate::crypto::transport_backend(backend, &key_backend, "open a data key")?; - - // 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)?; + let data_key = open_content_key( + &envelope.recipient, + &own, + backend, + &key_backend, + &backend_key_id, + )?; 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 {}", @@ -182,6 +183,54 @@ fn produced(content_bytes: usize) -> Value { }) } +/// Recover the content key `recipient` carries, under the backend key `own` +/// names. +/// +/// The opening follows the recipient form rather than an assumption, so a +/// backend that does not implement it refuses rather than decrypting under the +/// wrong mechanism. +fn open_content_key( + recipient: &cms::RecipientInfo, + own: &KeyName<'_>, + backend: Option<&mut dyn Backend>, + key_backend: &str, + key_id: &rite_sdk::KeyId, +) -> Result>, ActionError> { + let (protected, protection) = match (recipient, own) { + (cms::RecipientInfo::Kek { wrapped_key, .. }, _) => { + (wrapped_key, KeyProtection::AesKeyWrap) + } + (cms::RecipientInfo::KeyTransport { encrypted_key, .. }, _) => { + (encrypted_key, KeyProtection::RsaOaepSha256) + } + // The backend agrees the secret, and the host derives the wrap key from + // it and unwraps, so a device that only does ECDH opens this. + (cms::RecipientInfo::KeyAgreement(agreed), KeyName::PublicKey(public_key)) => { + let ephemeral = agreed + .ephemeral_key(public_key) + .map_err(|e| ActionError::Failed(format!("Cannot read the container: {e}")))?; + let agreement = crate::crypto::agreement_backend( + backend, + key_backend, + "agree a key to open a data key", + )?; + let secret = agreement.agree(key_id, &ephemeral)?; + return Ok(content::open_key_agreement(agreed, &secret)?); + } + // `KeyName::of` takes its form from the recipient, so a key agreement + // always has a public key to agree under. The remaining forms are the + // ones this crate does not open. + (other, _) => { + return Err(ActionError::Failed(format!( + "This container's recipient is a {}, which decrypt_data does not open", + other.kind() + ))); + } + }; + let (transport, _) = crate::crypto::transport_backend(backend, key_backend, "open a data key")?; + Ok(transport.open_data_key(key_id, protected, protection)?) +} + /// Describe a material's bytes as a container this step opens. fn container_from_material( data_id: &impl std::fmt::Display, @@ -215,15 +264,17 @@ enum KeyName<'a> { } impl<'a> KeyName<'a> { - /// The names `key` answers to, for a container whose content key is - /// protected with `protection`. + /// The names `key` answers to, for a container whose recipient takes the + /// `form`. fn of( key: &BackendKeyMeta<'a>, - protection: KeyProtection, + form: RecipientInfoKind, display_name: &str, ) -> Result { - match protection { - KeyProtection::AesKeyWrap => { + // A key of another kind is the wrong kind of key, not a different key + // of the right kind, and the refusal says so. + let (accepted, addressed_to) = match form { + RecipientInfoKind::Kekri => { let check_value = key.check_value.ok_or_else(|| { ActionError::Failed(format!( "'{display_name}' is a {} key, and this container is addressed to a \ @@ -231,32 +282,36 @@ impl<'a> KeyName<'a> { key.algorithm )) })?; - Ok(Self::CheckValue(check_value.clone())) + return 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(public_key)) + RecipientInfoKind::Ktri => ( + WrapScheme::RsaOaepSha256.accepts_recipient(key.algorithm), + "an RSA-2048 or RSA-4096 key", + ), + RecipientInfoKind::Kari => ( + cms::X963Kdf::for_recipient(key.algorithm).is_some(), + "a P-256 or P-384 key", + ), + other => { + return Err(ActionError::Failed(format!( + "This container's recipient is a {other}, which decrypt_data does not open" + ))); } - other => Err(ActionError::Failed(format!( - "This container protects its content key with {other}, which decrypt_data does \ - not open" - ))), + }; + if !accepted { + return Err(ActionError::Failed(format!( + "'{display_name}' is {}, and this container is addressed to {addressed_to}", + 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 {addressed_to}", + key.algorithm + )) + })?; + Ok(Self::PublicKey(public_key)) } /// Whether the container's recipient identifier names this key. diff --git a/crates/rite-stdlib/src/crypto/encrypt_data.rs b/crates/rite-stdlib/src/crypto/encrypt_data.rs index 1b42a98..fa056ea 100644 --- a/crates/rite-stdlib/src/crypto/encrypt_data.rs +++ b/crates/rite-stdlib/src/crypto/encrypt_data.rs @@ -7,8 +7,8 @@ use rite_runtime::{ compute_fingerprint, parse_params, resolve_artifact_bytes, resolve_backend_key, }; use rite_sdk::{ - Backend, DataKey, EncryptedData, KeyAlgorithm, KeyCheckValue, KeyFingerprint, KeyProtection, - PublicKeyDer, RandomBackend, WrapScheme, cms, + Backend, DataKey, EncryptedData, KeyAlgorithm, KeyCheckValue, KeyProtection, PublicKeyDer, + RandomBackend, WrapScheme, cms, }; use serde_json::json; use zeroize::Zeroizing; @@ -77,7 +77,7 @@ impl Action for EncryptDataAction { key_ref.display_name() ), )?; - let (data_key, addressee) = match path { + let (content_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 @@ -96,7 +96,7 @@ impl Action for EncryptDataAction { recipient_data_key(&recipient, random)? } }; - let encrypted = seal_into_container(&data_key, &addressee, payload)?; + let encrypted = seal_into_container(content_key, payload)?; let fingerprint = compute_fingerprint(encrypted.data()); reporter.log(Icon::Checkmark, "Content encrypted")?; @@ -115,7 +115,7 @@ impl Action for EncryptDataAction { // 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!({ + Addressee::PublicKey => json!({ "scheme": scheme.to_string(), "data": data_ref.display_name(), "content_bytes": payload.len(), @@ -177,31 +177,21 @@ fn recipient_input(step: &StepInfo) -> Result<(Path, &ArtifactRef), ActionError> } } -/// What the container names its recipient by. +/// How the transcript names the recipient. enum Addressee { - /// A symmetric key, by its check value, in a `KEKRecipientInfo`. + /// A symmetric key, by its check value. CheckValue(KeyCheckValue), - /// 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), + /// A public key. Its fingerprint is in the recipient fact, recorded before + /// the content key was drawn, and the container names it the same way, so + /// a blob and a record compare without holding the key. + PublicKey, } -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) -> cms::RecipientIdentifier { - match self { - Self::CheckValue(check_value) => check_value.into(), - Self::PublicKey(fingerprint) => (*fingerprint).into(), - } - } +/// The content-encryption key, and the recipient it is protected for in the +/// form the container carries. +struct ContentKey { + cek: Zeroizing>, + recipient: cms::RecipientInfo, } /// Both halves of the data key from the backend that holds `key_ref`. @@ -212,7 +202,7 @@ fn backend_data_key( ctx: &HandlerContext, key_ref: &ArtifactRef, backend: Option<&mut dyn Backend>, -) -> Result<(DataKey, Addressee), ActionError> { +) -> Result<(ContentKey, 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 \ @@ -224,7 +214,43 @@ fn backend_data_key( 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))) + let recipient = kek_recipient(&data_key, &check_value)?; + Ok(( + ContentKey { + cek: Zeroizing::new(data_key.plaintext().to_vec()), + recipient, + }, + Addressee::CheckValue(check_value), + )) +} + +/// The `KEKRecipientInfo` a backend's data key goes in. +fn kek_recipient( + data_key: &DataKey, + check_value: &KeyCheckValue, +) -> Result { + // A symmetric key is named by its check value, which only a + // `KEKRecipientInfo` carries, and that form declares `id-aes256-wrap`. + // Anything else is refused by name. A data key in the provider's own + // envelope, written under `id-aes256-wrap` anyway, would be an artifact + // whose structure lies about it, which no CMS reader could open and which + // `rite verify` would report as checked. + if data_key.protection() != KeyProtection::AesKeyWrap { + let reach = if data_key.protection().is_nameable_in_a_container() { + "a container addressed to a symmetric key declares AES key wrap" + } else { + "a CMS container cannot name it, so the artifact could not be opened by anything \ + but this backend" + }; + return Err(ActionError::Failed(format!( + "this backend protects a data key with {}, and {reach}", + data_key.protection() + ))); + } + Ok(cms::RecipientInfo::Kek { + key_identifier: check_value.into(), + wrapped_key: data_key.wrapped().to_vec(), + }) } /// The step's backend, as the source of the content-encryption key's random @@ -247,74 +273,53 @@ fn random_source(backend: Option<&mut dyn Backend>) -> Result<&mut dyn RandomBac }) } -/// 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. +/// Whether the container can address `recipient`: a key of a size or curve +/// every other path accepts. An RSA-2048 or RSA-4096 key takes a +/// `KeyTransRecipientInfo` under RSAES-OAEP, and a P-256 or P-384 key a +/// `KeyAgreeRecipientInfo` under ECDH. 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) { + if WrapScheme::RsaOaepSha256.accepts_recipient(algorithm) + || cms::X963Kdf::for_recipient(algorithm).is_some() + { Ok(()) } else { Err(ActionError::Failed(format!( - "{} encrypts to an RSA-2048 or RSA-4096 public key, and the recipient is {algorithm}", + "{} encrypts to an RSA-2048, RSA-4096, P-256 or P-384 public key, and the recipient \ + is {algorithm}", WrapScheme::CmsAes256Gcm ))) } } -/// A data key protected for `recipient`, its bytes drawn from `random`. +/// A content key protected for `recipient`, its bytes drawn from `random`, +/// as an EC recipient's ephemeral key is. /// /// 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> { +) -> Result<(ContentKey, Addressee), ActionError> { let cek = Zeroizing::new(random.generate_random(content::KEY_BYTES)?); - let data_key = content::protect_for(recipient, cek)?; - Ok((data_key, Addressee::PublicKey(recipient.fingerprint()))) + let recipient = content::protect_for(recipient, &cek, random)?; + Ok((ContentKey { cek, recipient }, Addressee::PublicKey)) } -/// Encrypt the content under the data key and assemble the container around -/// both halves. +/// Encrypt the content under the content key and assemble the container +/// around it and its recipient. /// /// The result is described by reading the bytes back, never by restating what /// was just written. A description that disagreed with the artifact is what /// this path exists to make impossible. fn seal_into_container( - data_key: &DataKey, - addressee: &Addressee, + content_key: ContentKey, payload: &[u8], ) -> Result { - // The container has to declare the key-encryption algorithm, so a data key - // the backend protected in its own envelope cannot go in one. Refused by - // name: writing it under `id-aes256-wrap` anyway would produce an artifact - // whose structure lies about it, which no CMS reader could open and which - // `rite verify` would report as checked. - if !data_key.protection().is_nameable_in_a_container() { - return Err(ActionError::Failed(format!( - "this backend protects a data key with {}, which a CMS container cannot name, so \ - the artifact could not be opened by anything but this backend", - 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 sealed = content::seal(&content_key.cek, payload)?; let der = cms::write_auth_enveloped(&cms::Envelope { - protection: data_key.protection(), - key_identifier: addressee.key_identifier(), - wrapped_cek: data_key.wrapped().to_vec(), + recipient: content_key.recipient, nonce: sealed.nonce, ciphertext: sealed.ciphertext, tag: sealed.tag, diff --git a/crates/rite-stdlib/src/crypto/mod.rs b/crates/rite-stdlib/src/crypto/mod.rs index 817f975..cec106f 100644 --- a/crates/rite-stdlib/src/crypto/mod.rs +++ b/crates/rite-stdlib/src/crypto/mod.rs @@ -23,7 +23,7 @@ pub use wrap_key::WrapKeyAction; use rite_model::{ArtifactRef, StepFact}; use rite_runtime::{ActionError, HandlerContext, Reporter, StepInfo}; -use rite_sdk::{Backend, KeyFingerprint, KeyTransportBackend, PublicKeyDer}; +use rite_sdk::{Backend, KeyAgreementBackend, 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. @@ -45,6 +45,35 @@ pub(crate) fn transport_backend<'a>( key_backend: &str, operation: &str, ) -> Result<(&'a mut dyn KeyTransportBackend, String), ActionError> { + let (backend, name) = holding_backend(backend, key_backend, operation)?; + let transport = backend + .as_transport_mut() + .ok_or_else(|| ActionError::Failed(format!("Backend '{name}' cannot {operation}")))?; + Ok((transport, name)) +} + +/// [`transport_backend`] for a step that needs the backend to agree a key. +/// +/// # Errors +/// +/// As [`transport_backend`], with "does not agree keys" for the last. +pub(crate) fn agreement_backend<'a>( + backend: Option<&'a mut dyn Backend>, + key_backend: &str, + operation: &str, +) -> Result<&'a mut dyn KeyAgreementBackend, ActionError> { + let (backend, name) = holding_backend(backend, key_backend, operation)?; + backend + .as_agreement_mut() + .ok_or_else(|| ActionError::Failed(format!("Backend '{name}' cannot {operation}"))) +} + +/// The two questions every key operation asks first, and the backend's name. +fn holding_backend<'a>( + backend: Option<&'a mut dyn Backend>, + key_backend: &str, + operation: &str, +) -> Result<(&'a mut dyn Backend, String), ActionError> { let backend = backend.ok_or_else(|| ActionError::Failed(format!("Backend required to {operation}")))?; let name = backend.name().to_string(); @@ -53,10 +82,7 @@ pub(crate) fn transport_backend<'a>( "Key owned by backend '{key_backend}', but current backend is '{name}'" ))); } - let transport = backend - .as_transport_mut() - .ok_or_else(|| ActionError::Failed(format!("Backend '{name}' cannot {operation}")))?; - Ok((transport, name)) + Ok((backend, name)) } /// The fingerprint an `expect_recipient` parameter declares. diff --git a/crates/rite-stdlib/tests/actions.rs b/crates/rite-stdlib/tests/actions.rs index f183b4e..1087c3a 100644 --- a/crates/rite-stdlib/tests/actions.rs +++ b/crates/rite-stdlib/tests/actions.rs @@ -21,7 +21,6 @@ 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) @@ -1385,7 +1384,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!(envelope.key_identifier.is_check_value(expected)); + assert!(envelope.recipient.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 @@ -1546,13 +1545,18 @@ fn sealed_der( 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(); + let cek = [0x5a; 32]; + let sealed = rite_openssl::seal_content(&cek, payload).unwrap(); rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { - protection: data_key.protection(), - key_identifier: rite_sdk::cms::RecipientIdentifier::new(identifier(public_key)), - wrapped_cek: data_key.wrapped().to_vec(), + recipient: renamed( + rite_openssl::recipient_info_for( + public_key, + &cek, + &mut rite_openssl::OpenSslBackend::try_new("random").unwrap(), + ) + .unwrap(), + rite_sdk::cms::RecipientIdentifier::new(identifier(public_key)), + ), nonce: sealed.nonce, ciphertext: sealed.ciphertext, tag: sealed.tag, @@ -1560,6 +1564,25 @@ fn sealed_der( .unwrap() } +/// `recipient`, naming its key by `identifier` instead. +fn renamed( + recipient: rite_sdk::cms::RecipientInfo, + identifier: rite_sdk::cms::RecipientIdentifier, +) -> rite_sdk::cms::RecipientInfo { + use rite_sdk::cms::{KeyAgreeRecipient, RecipientInfo}; + match recipient { + RecipientInfo::KeyTransport { encrypted_key, .. } => RecipientInfo::KeyTransport { + key_identifier: identifier, + encrypted_key, + }, + RecipientInfo::KeyAgreement(agreed) => RecipientInfo::KeyAgreement(KeyAgreeRecipient { + key_identifier: identifier, + ..agreed + }), + other => panic!("a public-key recipient, not {other:?}"), + } +} + /// Run `decrypt_data` on `sealed` with `key`, returning the result and the /// facts it recorded. fn decrypt_with( @@ -1695,12 +1718,13 @@ 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: rite_sdk::cms::RecipientIdentifier::new(spki_digest(public_key)), - wrapped_cek: short.wrapped().to_vec(), + recipient: rite_openssl::recipient_info_for( + public_key, + &[0x5a; 16], + &mut rite_openssl::OpenSslBackend::try_new("random").unwrap(), + ) + .unwrap(), nonce: vec![0; 12], ciphertext: vec![0; 16], tag: vec![0; 16], @@ -1832,9 +1856,9 @@ 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.recipient.kind(), rite_sdk::RecipientInfoKind::Ktri); assert_eq!( - envelope.key_identifier.as_bytes(), + envelope.recipient.key_identifier().as_bytes(), spki_digest(&public_key).as_slice() ); @@ -1988,20 +2012,20 @@ fn encrypt_data_records_a_declared_recipient_as_declared() { /// A public key the container cannot address is refused by name. #[test] -fn encrypt_data_refuses_a_recipient_that_is_not_an_rsa_key() { +fn encrypt_data_refuses_a_recipient_the_container_cannot_address() { let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); - let (_, ec) = backend_key(&mut backend, "signing_key", KeyAlgorithm::EcdsaP256); + let (_, ed25519) = backend_key(&mut backend, "signing_key", KeyAlgorithm::Ed25519); let (result, facts) = encrypt_to_recipient( &mut backend, b"the recovery phrase", - ec, + ed25519, &serde_json::json!({}), ); let message = result - .expect_err("an EC recipient has no key transport here") + .expect_err("an Ed25519 key signs and receives nothing") .to_string(); - assert!(message.contains("ECDSA-P256"), "{message}"); + assert!(message.contains("Ed25519"), "{message}"); // Refused before the recipient is recorded, so the transcript does not // say the key received content it never did. assert!( @@ -2031,6 +2055,106 @@ fn decrypt_data_names_a_key_of_the_wrong_kind_for_a_public_key_recipient() { ); } +/// An EC key the backend holds, allowed to open what is sealed to it. +fn ec_recipient( + backend: &mut MockBackend, + id: &str, + algorithm: KeyAlgorithm, +) -> (ArtifactId, ArtifactValue) { + backend_key_with( + backend, + id, + algorithm, + KeyPolicy { + usages: rite_sdk::KeyUsages::DERIVE, + ..KeyPolicy::default() + }, + ) +} + +/// Content encrypted to an EC public key is addressed through a key +/// agreement, under the same recipient fact an RSA key gets, and the holder of +/// the private half opens it: the backend agrees the secret, and the host +/// unwraps the content key. +#[test] +fn encrypt_data_seals_content_to_an_ec_key_its_holder_opens() { + for algorithm in [KeyAlgorithm::EcdsaP256, KeyAlgorithm::EcdsaP384] { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let custodian = ec_recipient(&mut backend, "custodian_key", algorithm); + let public_key = public_half(&custodian.1).clone(); + let fingerprint = public_key.fingerprint(); + + let (result, facts) = encrypt_to_recipient( + &mut backend, + b"the recovery phrase", + ArtifactValue::Bytes(public_key.as_bytes().to_vec()), + &serde_json::json!({ "expect_recipient": fingerprint.to_string() }), + ); + 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()).unwrap(); + assert_eq!(envelope.recipient.kind(), rite_sdk::RecipientInfoKind::Kari); + assert!( + envelope + .recipient + .key_identifier() + .is_fingerprint(&fingerprint) + ); + assert!(facts.iter().any(|fact| matches!( + fact, + StepFact::WrapRecipientRecorded { fingerprint: recorded, declared: true, .. } + if *recorded == fingerprint + ))); + + let (opened, facts) = 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:?}"), + } + 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.to_string()))); + } +} + +/// A key agreement is refused before any agreement when the key is another +/// EC key, or not an EC key at all, each by what is wrong with it. +#[test] +fn decrypt_data_refuses_a_key_agreement_under_any_other_key() { + let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); + let intended = ec_recipient(&mut backend, "intended", KeyAlgorithm::EcdsaP256); + let sealed = || sealed_to_public_key(&intended.1, b"the recovery phrase"); + + let other = ec_recipient(&mut backend, "other", KeyAlgorithm::EcdsaP256); + let (result, _) = decrypt_with(&mut backend, sealed(), other); + let message = result.expect_err("addressed elsewhere").to_string(); + let named = base16ct::lower::encode_string(public_half(&intended.1).fingerprint().as_bytes()); + assert!( + message.contains(&format!("addressed to the key identified by {named},")), + "{message}" + ); + + let rsa = rsa_recipient(&mut backend, "rsa"); + let (result, _) = decrypt_with(&mut backend, sealed(), rsa); + let message = result.expect_err("an RSA key agrees nothing").to_string(); + assert!( + message.contains("RSA-2048") && message.contains("P-256 or P-384"), + "{message}" + ); +} + #[test] fn decrypt_data_refuses_a_wrapped_key() { let mut backend = MockBackend::new("mock".to_string(), "seed".to_string()); diff --git a/crates/rite/src/container_checks.rs b/crates/rite/src/container_checks.rs index 94af4aa..bfcbb1f 100644 --- a/crates/rite/src/container_checks.rs +++ b/crates/rite/src/container_checks.rs @@ -676,9 +676,10 @@ mod tests { .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: check_value.into(), - wrapped_cek: data_key.wrapped().to_vec(), + recipient: rite_sdk::cms::RecipientInfo::Kek { + key_identifier: check_value.into(), + wrapped_key: data_key.wrapped().to_vec(), + }, nonce: content.nonce, ciphertext: content.ciphertext, tag: content.tag, @@ -753,14 +754,21 @@ 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. + /// mismatch. The same for an RSA key under key transport and an EC key + /// under key agreement. #[test] fn an_encrypt_to_a_public_key_is_checked_against_its_recipient_fact() { + for algorithm in [KeyAlgorithm::Rsa2048, KeyAlgorithm::EcdsaP256] { + encrypt_to_a_public_key_is_checked(algorithm); + } + } + + fn encrypt_to_a_public_key_is_checked(algorithm: KeyAlgorithm) { let tmp = tempfile::tempdir().unwrap(); let mut backend = rite_openssl::OpenSslBackend::try_new("test").unwrap(); let recipient = backend .generate_key(KeySpec { - algorithm: KeyAlgorithm::Rsa2048, + algorithm, label: "custodian".to_string(), policy: KeyPolicy::default(), location_hint: None, @@ -768,14 +776,15 @@ mod tests { .unwrap(); let public = backend.export_public_key(&recipient.key_id).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 cek = [0x5a; 32]; + let content = rite_openssl::seal_content(&cek, b"an archive").unwrap(); let blob = rite_sdk::cms::write_auth_enveloped(&rite_sdk::cms::Envelope { - protection: data_key.protection(), - key_identifier: fingerprint.into(), - wrapped_cek: data_key.wrapped().to_vec(), + recipient: rite_openssl::recipient_info_for( + &public, + &cek, + &mut rite_openssl::OpenSslBackend::try_new("random").unwrap(), + ) + .unwrap(), nonce: content.nonce, ciphertext: content.ciphertext, tag: content.tag, @@ -827,7 +836,7 @@ mod tests { recipient: RecipientEvidence::Declared, origin: OriginEvidence::None, }, - "{}", + "{algorithm}: {}", check.describe() ); @@ -835,7 +844,7 @@ mod tests { let checks = check_containers(Some(tmp.path()), &borrow(&another)); assert!( checks.iter().all(ContainerCheck::failed), - "a record naming another recipient is a mismatch: {checks:?}" + "{algorithm}: a record naming another recipient is a mismatch: {checks:?}" ); } diff --git a/docs/encrypting-content.md b/docs/encrypting-content.md index e47b16c..d5ef658 100644 --- a/docs/encrypting-content.md +++ b/docs/encrypting-content.md @@ -78,11 +78,21 @@ seal_the_runbook_for_the_custodian: 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. +The recipient is an RSA-2048, RSA-4096, P-256 or P-384 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. An EC key's point must +be uncompressed. The step's backend supplies the random bytes for the +content-encryption key and, for an EC recipient, the ephemeral private key. No +key held by the backend is involved. + +- An RSA key protects the content-encryption key with RSAES-OAEP over SHA-256. +- An EC key protects it with ECDH, the X9.63 KDF, and AES key wrap. The step + creates a fresh ephemeral key pair on the recipient's curve for this + container. The KDF derives a 256-bit wrapping key from the agreed secret, and + the content-encryption key is wrapped under it with AES key wrap. + +If the ceremony holds the recipient's private key and opens the container, an +EC key needs the `derive` usage and an RSA key needs `unwrap`. `expect_recipient` is the recipient's `sha256:` fingerprint, written into the definition before the ceremony. The step compares the key it receives against @@ -106,12 +116,17 @@ 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 +- for an RSA `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. +- for an EC `recipient`, a `KeyAgreeRecipientInfo` (RFC 5652 §6.2.2) under + `dhSinglePass-stdDH-sha256kdf-scheme` for P-256 or + `dhSinglePass-stdDH-sha384kdf-scheme` for P-384 (RFC 5753), with + `id-aes256-wrap` and the ephemeral public key in the container. `openssl cms + -decrypt -inkey ` opens it. -Either is what makes the archive readable in a decade with no Rite binary -present. +Each is a standard structure, which 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 @@ -153,30 +168,45 @@ open_the_sealed_document: ``` The file has to be an `AuthEnvelopedData` with AES-256-GCM content and one -recipient, in one of two forms: +recipient, in one of three 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. + usage; +- a `KeyAgreeRecipientInfo` under ECDH with the X9.63 KDF and + `id-aes256-wrap`, for a P-256 or P-384 key the backend holds with the + `derive` usage. Of Rite's backends, `openssl` opens this form; a key on a + hardware device does not. -The `openssl` command line writes the second form for a certificate when told to -use OAEP and to name the recipient by key identifier: +A public-key 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 an RSA certificate when +told to use OAEP and to name the recipient by key identifier, and the third for +an EC certificate when told which hash the KDF takes: ```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 + +openssl cms -encrypt -binary -aes-256-gcm -keyid -outform DER \ + -recip custodian-p256.crt -keyopt ecdh_kdf_md:sha256 \ + -in document.pdf -out document.p7m ``` +Use `ecdh_kdf_md:sha384` for a P-384 certificate. + 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. +- An EC key agreement whose KDF hashes with SHA-1, the default without + `-keyopt ecdh_kdf_md`. - 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 @@ -217,7 +247,7 @@ container sealed elsewhere. ## What the transcript records 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 +symmetric key, the `sha256:` fingerprint of its public key otherwise), the size of the content, and, read back out of the DER, every algorithm identifier the container names. diff --git a/docs/key-wrapping.md b/docs/key-wrapping.md index b4e0721..0f0b86f 100644 --- a/docs/key-wrapping.md +++ b/docs/key-wrapping.md @@ -323,7 +323,9 @@ Which fields are enforced depends on who holds the key. A token enforces the who policy itself, because each field is an attribute the key is created with. The software backend has no token, so it enforces the two that describe operations it performs: `extractable` gates both wrap paths, and `usages` gates signing, -wrapping, and unwrapping. `persistent`, `sensitive`, and `wrap_with_trusted_only` +wrapping, unwrapping, and key agreement. An EC recipient key needs +`usages: [derive]` to open what `wrap_key` sealed to it; an RSA recipient key +needs `usages: [unwrap]`. `persistent`, `sensitive`, and `wrap_with_trusted_only` describe a token that is not there, so in software they are recorded in the transcript and nothing more. diff --git a/docs/schema/ceremony.schema.json b/docs/schema/ceremony.schema.json index 3baa37e..f9e95d0 100644 --- a/docs/schema/ceremony.schema.json +++ b/docs/schema/ceremony.schema.json @@ -211,7 +211,7 @@ "const": "unwrap_key" }, { - "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.", + "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`, a public key held elsewhere, the backend supplies the content-encryption key's random bytes, which are protected with RSAES-OAEP over SHA-256 for an RSA-2048 or RSA-4096 key, or with ECDH and AES key wrap for a P-256 or P-384 key. The data itself is not sent to the backend.", "type": "string", "const": "encrypt_data" }, diff --git a/examples/showcase/README.md b/examples/showcase/README.md index 4a6bcca..ed79ffd 100644 --- a/examples/showcase/README.md +++ b/examples/showcase/README.md @@ -71,7 +71,9 @@ and compared against the transcript. See [key wrapping](../../docs/key-wrapping. ### `encrypt_and_decrypt.rite.yaml` Encrypts a sample runbook with `encrypt_data`, decrypts it with -`decrypt_data`, and compares the result. These are separate from `wrap_key` +`decrypt_data`, and compares the result, first under a symmetric archive key, +then to a custodian's P-256 public key, whose private key opens it under the +`derive` usage. These are separate from `wrap_key` and `unwrap_key` because the claim differs: a wrap says a key left a backend under protection, while encrypted content says only that the bytes are unreadable without the key. `rite verify` lists the container under diff --git a/examples/showcase/encrypt_and_decrypt.rite.yaml b/examples/showcase/encrypt_and_decrypt.rite.yaml index 7941409..26cf402 100644 --- a/examples/showcase/encrypt_and_decrypt.rite.yaml +++ b/examples/showcase/encrypt_and_decrypt.rite.yaml @@ -1,7 +1,8 @@ version: "0.3" name: "Sealing a Runbook and Proving It Opens" description: | - Encrypt a sample runbook, decrypt it, and compare the result. This example + Encrypt a sample runbook, decrypt it, and compare the result, once under a + symmetric archive key and once to a custodian's public key. This example contrasts content encryption (`encrypt_data`) with key wrapping (`wrap_key`). backends: @@ -24,6 +25,9 @@ output: sealed_runbook: type: encrypted_data description: "The runbook, encrypted to the archive key." + runbook_for_the_custodian: + type: encrypted_data + description: "The runbook, encrypted to the custodian's public key." sections: seal: @@ -81,3 +85,56 @@ sections: description: | Compare the SHA-256 digests of the source and recovered runbook. The recovered bytes stay in memory and are wiped when the run ends. + + custodian: + name: "Seal to a Public Key" + role: ${role.archivist} + steps: + generate_the_custodian_key: + action: generate_key + backend: openssl + with: + algorithm: ECDSA-P256 + policy: + usages: [derive] + creates: custodian_key + # In a real ceremony the custodian holds this key elsewhere and only + # the public key arrives, as a material, the way the escrow key does + # in wrap_and_unwrap.rite.yaml. + description: | + Generate the custodian's P-256 key. An EC key opens content sealed + to it by key agreement, so its policy grants `derive`. + + seal_the_runbook_for_the_custodian: + action: encrypt_data + backend: openssl + reads: + data: ${artifact.runbook} + recipient: ${artifact.custodian_key} + creates: runbook_for_the_custodian + # `openssl cms -decrypt -inkey` opens the container with the + # custodian's private key and no Rite binary. + description: | + Encrypt the runbook to the custodian's public key. The container + carries a fresh ephemeral key, and only the holder of the private + key can agree the secret that opens it. + + open_as_the_custodian: + action: decrypt_data + backend: openssl + reads: + encrypted_data: ${artifact.runbook_for_the_custodian} + decryption_key: ${artifact.custodian_key} + creates: custodian_runbook + description: | + Open the container with the custodian's private key, after checking + that it is addressed to that key. + + confirm_the_custodian_copy: + action: check_value + with: + message: "The custodian's copy is the document that went in." + actual: ${artifact.custodian_runbook | sha256 | hex} + expected: ${artifact.runbook | sha256 | hex} + description: | + Compare the SHA-256 digests of the source and the custodian's copy. diff --git a/examples/showcase/wrap_and_unwrap.rite.yaml b/examples/showcase/wrap_and_unwrap.rite.yaml index b44a96e..cac0c89 100644 --- a/examples/showcase/wrap_and_unwrap.rite.yaml +++ b/examples/showcase/wrap_and_unwrap.rite.yaml @@ -73,8 +73,9 @@ sections: algorithm: RSA-2048 policy: usages: [wrap, unwrap] - # An EC or ML-KEM key works the same way: the recipient's key type - # selects the encapsulation. + # An EC or ML-KEM key works too, and the recipient's key type selects + # the encapsulation. An EC key opens by key agreement, so it needs + # `usages: [wrap, derive]` instead. creates: transport_keypair description: | Generate an RSA transport key with wrap and unwrap usages. The