diff --git a/Cargo.lock b/Cargo.lock index 6c775ae1..68bcaa71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4272,6 +4272,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array", + "hkdf", + "hmac 0.12.1", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "x25519-dalek", + "zeroize", +] + [[package]] name = "http" version = "0.2.12" @@ -10096,6 +10116,7 @@ dependencies = [ "blst", "ed25519-dalek", "hex", + "hpke", "proptest", "rand_chacha 0.3.1", "rand_core 0.6.4", diff --git a/tinycloud-threshold/Cargo.toml b/tinycloud-threshold/Cargo.toml index a938d951..918d9538 100644 --- a/tinycloud-threshold/Cargo.toml +++ b/tinycloud-threshold/Cargo.toml @@ -14,6 +14,7 @@ zeroize = { version = "1.8", features = ["derive"] } subtle = "2.6" rand_core = "0.6" thiserror = "2" +hpke = { version = "=0.12.0", default-features = false, features = ["x25519"] } [dev-dependencies] ed25519-dalek = "=2.2.0" diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md index 8c7406f3..4416ed2d 100644 --- a/tinycloud-threshold/SPEC.md +++ b/tinycloud-threshold/SPEC.md @@ -190,6 +190,10 @@ Secrets that must become unrecoverable, and when: Public ciphertext transcripts may be kept once the matching recipient keys are unrecoverable. In memory the crate zeroizes on drop and avoids hidden copies (blst `Copy` types are wiped explicitly where they hold secrets); `ZeroizeOnDrop` is an in-memory operation; durable storage is handled by integration. Durable erasure, snapshots, swap and core dumps are gate A's acceptance (ยง12). +The `seal` module's lifecycle behavior covers only accessible owned storage. `CeremonyHpkeSecretKey` clears its owned 32-byte array on explicit zeroization or drop. Internal plaintext/candidate buffers use `Zeroizing`, including error paths; a successful seal transfers ciphertext out of that buffer. Opened plaintext is returned in `Zeroizing>`. The borrowed sealing input belongs to the caller and is not cleared by the module. + +HPKE 0.12's `PrivateKey`, `AeadCtxS` and `AeadCtxR` are opaque dependency-managed values. This owned-buffer description makes no claim about zeroization of their internal storage. + ### 3.12 Activation boundary (contract fixed now; durable state record is integration) An owner signature on a new certificate is **not** an erasure trigger. Transition states: diff --git a/tinycloud-threshold/src/error.rs b/tinycloud-threshold/src/error.rs index 6a5afb2d..0c567eb2 100644 --- a/tinycloud-threshold/src/error.rs +++ b/tinycloud-threshold/src/error.rs @@ -34,6 +34,10 @@ pub enum Error { DegenerateRandomness, #[error("invalid IBE ciphertext")] InvalidCiphertext, + #[error("invalid HPKE key")] + InvalidHpkeKey, + #[error("invalid sealed dealing")] + InvalidSealedDealing, #[error("derived key does not match the request")] InvalidDerivedKey, #[error("invalid signature")] diff --git a/tinycloud-threshold/src/lib.rs b/tinycloud-threshold/src/lib.rs index bc623b7a..a864b3bb 100644 --- a/tinycloud-threshold/src/lib.rs +++ b/tinycloud-threshold/src/lib.rs @@ -29,6 +29,14 @@ pub mod ibe; pub mod identity; pub mod ids; mod poly; +#[cfg_attr( + not(test), + allow( + dead_code, + reason = "Crate-private dealing helpers await DKG/reshare callers." + ) +)] +pub mod seal; pub use activation::{ activation_preimage, readiness_preimage, verify_activation, ReadinessReceipt, ReadyQuorum, @@ -47,6 +55,7 @@ pub use fault::{verify_fault_proof, FaultProof, FaultVerdict, NotAFaultReason}; pub use ibe::*; pub use identity::IbeIdentity; pub use ids::*; +pub use seal::{CeremonyHpkePublicKey, CeremonyHpkeSecretKey, SealedDealing}; #[cfg(test)] mod checkpoint; diff --git a/tinycloud-threshold/src/seal.rs b/tinycloud-threshold/src/seal.rs new file mode 100644 index 00000000..f9d883fe --- /dev/null +++ b/tinycloud-threshold/src/seal.rs @@ -0,0 +1,629 @@ +//! Ceremony-scoped RFC 9180 base-mode dealing envelopes. Details: TC-2220 + +use core::fmt; + +use hpke::{ + aead::{AeadTag, ChaCha20Poly1305}, + kdf::HkdfSha256, + kem::X25519HkdfSha256, + Deserializable, HpkeError, Kem, OpModeR, OpModeS, Serializable, +}; +use rand_core::CryptoRngCore; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use crate::{ + dst, + encoding::{Decoder, Encoder}, + Error, MemberIndex, +}; + +type HpkeKem = X25519HkdfSha256; +type HpkeKdf = HkdfSha256; +type HpkeAead = ChaCha20Poly1305; + +const VERSION: u8 = 1; +const TAG_LEN: usize = 16; +const CIPHERTEXT_CAP: usize = 65_499; +const INFO_LEN: usize = dst::DEALING.len() + 32 + 1 + 2 + 2; + +/// A fresh recipient key's owned bytes, cleared on explicit zeroization or drop. +/// +/// Generate independently for each ceremony. The ceremony owner retains this +/// value through the required rounds and drops it on completion or abort. +/// This type describes its own byte storage, not opaque dependency internals. +/// Details: TC-2220 +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct CeremonyHpkeSecretKey { + bytes: [u8; 32], +} + +impl CeremonyHpkeSecretKey { + /// Generate a recipient key from the caller's CSPRNG. Details: TC-2220 + pub fn generate(rng: &mut impl CryptoRngCore) -> (Self, CeremonyHpkePublicKey) { + let (secret, public) = HpkeKem::gen_keypair(rng); + let mut owned = Self { bytes: [0; 32] }; + secret.write_exact(&mut owned.bytes); + let mut public_bytes = [0; 32]; + public.write_exact(&mut public_bytes); + (owned, CeremonyHpkePublicKey(public_bytes)) + } +} + +impl fmt::Debug for CeremonyHpkeSecretKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CeremonyHpkeSecretKey([REDACTED])") + } +} + +/// The exact 32 recipient-public-key bytes carried by a ceremony directory. +/// Details: TC-2220 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CeremonyHpkePublicKey([u8; 32]); + +impl CeremonyHpkePublicKey { + /// Decode exactly 32 bytes; the KEM validates agreement when used. + /// Details: TC-2220 + pub fn decode(bytes: &[u8]) -> Result { + Ok(Self(bytes.try_into().map_err(|_| Error::InvalidLength)?)) + } + + /// Return the directory's exact public bytes. Details: TC-2220 + pub fn encode(&self) -> [u8; 32] { + self.0 + } +} + +/// A construction-bounded `01 || enc:32 || lp(ciphertext)` envelope. +/// Details: TC-2220 +#[derive(Debug, Eq, PartialEq)] +pub struct SealedDealing { + enc: [u8; 32], + ciphertext: Vec, +} + +impl SealedDealing { + /// Encode an envelope whose complete length is at most 64 KiB. + /// Details: TC-2220 + pub fn encode(&self) -> Vec { + let mut encoder = Encoder::new(); + encoder.u8(VERSION); + encoder.fixed(&self.enc); + encoder.lp(&self.ciphertext); + encoder.finish() + } + + /// Check the version, whole-message cap, ciphertext cap and tag length + /// before allocating; consume all input bytes. Details: TC-2220 + pub fn decode(bytes: &[u8]) -> Result { + let mut decoder = Decoder::new(bytes)?; + if decoder.u8()? != VERSION { + return Err(Error::InvalidEncoding); + } + let enc = decoder.fixed()?; + let ciphertext = decoder.lp(CIPHERTEXT_CAP)?; + if ciphertext.len() < TAG_LEN { + return Err(Error::InvalidLength); + } + decoder.finish()?; + Ok(Self { + enc, + ciphertext: ciphertext.to_vec(), + }) + } +} + +/// Bind transport bytes to the caller's selected ceremony and closed round. +/// Details: TC-2220 +#[derive(Clone, Copy, Debug)] +pub(crate) struct DealingContext { + pub(crate) ceremony_digest: [u8; 32], + pub(crate) round: u8, + pub(crate) sender: MemberIndex, + pub(crate) recipient: MemberIndex, + pub(crate) commitment_hash: [u8; 32], +} + +impl DealingContext { + fn info(&self) -> [u8; INFO_LEN] { + let sender = self.sender.get().to_be_bytes(); + let recipient = self.recipient.get().to_be_bytes(); + let fields: [&[u8]; 5] = [ + dst::DEALING, + &self.ceremony_digest, + core::slice::from_ref(&self.round), + &sender, + &recipient, + ]; + let mut info = [0; INFO_LEN]; + for (output, byte) in info.iter_mut().zip(fields.into_iter().flatten()) { + *output = *byte; + } + info + } +} + +/// Seal one dealing with fresh encapsulation randomness and a one-shot context. +/// +/// The caller owns the input slice; only the internal copy is cleared here. +/// Authorization and closed-round verification belong to the ceremony API. +/// Details: TC-2220 +pub(crate) fn seal_dealing( + public: &CeremonyHpkePublicKey, + context: &DealingContext, + plaintext: &[u8], + rng: &mut impl CryptoRngCore, +) -> Result { + let ciphertext_len = plaintext + .len() + .checked_add(TAG_LEN) + .ok_or(Error::LimitExceeded)?; + if ciphertext_len > CIPHERTEXT_CAP { + return Err(Error::LimitExceeded); + } + let public = + ::PublicKey::from_bytes(&public.0).map_err(|_| Error::InvalidHpkeKey)?; + let info = context.info(); + let mut buffer = Zeroizing::new(Vec::with_capacity(ciphertext_len)); + buffer.extend_from_slice(plaintext); + let (enc, tag) = hpke::single_shot_seal_in_place_detached::( + &OpModeS::Base, + &public, + &info, + &mut buffer, + &context.commitment_hash, + rng, + ) + .map_err(dealing_error)?; + buffer.extend_from_slice(&tag.to_bytes()); + let mut encapsulation = [0; 32]; + enc.write_exact(&mut encapsulation); + Ok(SealedDealing { + enc: encapsulation, + ciphertext: core::mem::take(&mut *buffer), + }) +} + +/// Open one envelope into owned storage cleared when the caller drops it. +/// +/// The in-place candidate is also cleared on error. Opaque HPKE key/context +/// lifecycle behavior is outside this module's owned-storage description. +/// Details: TC-2220 +pub(crate) fn open_dealing( + secret: &CeremonyHpkeSecretKey, + context: &DealingContext, + sealed: &SealedDealing, +) -> Result>, Error> { + let body_len = sealed + .ciphertext + .len() + .checked_sub(TAG_LEN) + .ok_or(Error::InvalidLength)?; + let (body, tag) = sealed.ciphertext.split_at(body_len); + let tag = AeadTag::::from_bytes(tag).map_err(|_| Error::InvalidSealedDealing)?; + let enc = ::EncappedKey::from_bytes(&sealed.enc) + .map_err(|_| Error::InvalidHpkeKey)?; + let secret = ::PrivateKey::from_bytes(&secret.bytes) + .map_err(|_| Error::InvalidHpkeKey)?; + let info = context.info(); + let mut plaintext = Zeroizing::new(body.to_vec()); + hpke::single_shot_open_in_place_detached::( + &OpModeR::Base, + &secret, + &enc, + &info, + &mut plaintext, + &context.commitment_hash, + &tag, + ) + .map_err(dealing_error)?; + Ok(plaintext) +} + +fn dealing_error(error: HpkeError) -> Error { + match error { + HpkeError::ValidationError | HpkeError::EncapError | HpkeError::DecapError => { + Error::InvalidHpkeKey + } + _ => Error::InvalidSealedDealing, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hpke::{aead::AeadTag, Deserializable, Kem, OpModeR, OpModeS, Serializable}; + use rand_chacha::ChaCha20Rng; + use rand_core::{CryptoRng, RngCore, SeedableRng}; + use serde_json::Value; + use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + + fn context() -> DealingContext { + DealingContext { + ceremony_digest: [0x11; 32], + round: 1, + sender: MemberIndex::new(1).unwrap(), + recipient: MemberIndex::new(2).unwrap(), + commitment_hash: [0x22; 32], + } + } + + fn bytes(value: &Value, field: &str) -> Vec { + hex::decode(value[field].as_str().unwrap()).unwrap() + } + + struct FixedIkm { + bytes: [u8; 32], + consumed: bool, + } + + impl RngCore for FixedIkm { + fn next_u32(&mut self) -> u32 { + panic!("RFC test supplies exactly one 32-byte IKM") + } + + fn next_u64(&mut self) -> u64 { + panic!("RFC test supplies exactly one 32-byte IKM") + } + + fn fill_bytes(&mut self, dest: &mut [u8]) { + assert!(!self.consumed); + assert_eq!(dest.len(), self.bytes.len()); + dest.copy_from_slice(&self.bytes); + self.consumed = true; + } + + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { + self.fill_bytes(dest); + Ok(()) + } + } + + impl CryptoRng for FixedIkm {} + + #[test] + fn rfc9180_appendix_a2_1_base_suite() { + let vector: Value = + serde_json::from_str(include_str!("../vectors/v1/hpke-rfc9180-a2-1.json")).unwrap(); + let case = &vector["cases"][0]; + let input = &case["inputs"]; + let output = &case["outputs"]; + assert_eq!(input["mode"], 0); + assert_eq!(input["kem_id"], HpkeKem::KEM_ID); + assert_eq!(input["kdf_id"], 1); + assert_eq!(input["aead_id"], 3); + let (_sk_e, pk_e) = HpkeKem::derive_keypair(&bytes(input, "ikmE")); + let (sk_r, pk_r) = HpkeKem::derive_keypair(&bytes(input, "ikmR")); + assert_eq!(&*pk_e.to_bytes(), bytes(output, "pkEm")); + assert_eq!(&*pk_r.to_bytes(), bytes(output, "pkRm")); + let mut rng = FixedIkm { + bytes: bytes(input, "ikmE").try_into().unwrap(), + consumed: false, + }; + let info = bytes(input, "info"); + let (enc, mut sender) = hpke::setup_sender::( + &OpModeS::Base, + &pk_r, + &info, + &mut rng, + ) + .unwrap(); + assert!(rng.consumed); + assert_eq!(&*enc.to_bytes(), bytes(output, "enc")); + let mut receiver = + hpke::setup_receiver::(&OpModeR::Base, &sk_r, &enc, &info) + .unwrap(); + let encryptions = output["encryptions"].as_array().unwrap(); + let mut sequence = 0; + for kat in encryptions { + let wanted = kat["sequence"].as_u64().unwrap(); + while sequence < wanted { + let mut padding = []; + let aad = b"advance RFC sequence"; + let tag = sender.seal_in_place_detached(&mut padding, aad).unwrap(); + receiver + .open_in_place_detached(&mut padding, aad, &tag) + .unwrap(); + sequence += 1; + } + let plaintext = bytes(kat, "pt"); + let aad = bytes(kat, "aad"); + let mut ciphertext = Zeroizing::new(plaintext.clone()); + let tag = sender + .seal_in_place_detached(&mut ciphertext, &aad) + .unwrap(); + let mut joined = ciphertext.to_vec(); + joined.extend_from_slice(&tag.to_bytes()); + assert_eq!(joined, bytes(kat, "ct"), "RFC sequence {wanted}"); + receiver + .open_in_place_detached(&mut ciphertext, &aad, &tag) + .unwrap(); + assert_eq!(*ciphertext, plaintext); + sequence += 1; + } + for kat in output["exports"].as_array().unwrap() { + let expected = bytes(kat, "value"); + let mut sender_value = Zeroizing::new(vec![0; expected.len()]); + let mut receiver_value = Zeroizing::new(vec![0; expected.len()]); + sender + .export(&bytes(kat, "context"), &mut sender_value) + .unwrap(); + receiver + .export(&bytes(kat, "context"), &mut receiver_value) + .unwrap(); + assert_eq!(*sender_value, expected); + assert_eq!(*receiver_value, expected); + } + let kat = &encryptions[0]; + let mut plaintext = Zeroizing::new(bytes(kat, "pt")); + let mut rng = FixedIkm { + bytes: bytes(input, "ikmE").try_into().unwrap(), + consumed: false, + }; + let (enc, tag) = hpke::single_shot_seal_in_place_detached::( + &OpModeS::Base, + &pk_r, + &info, + &mut plaintext, + &bytes(kat, "aad"), + &mut rng, + ) + .unwrap(); + let mut joined = plaintext.to_vec(); + joined.extend_from_slice(&tag.to_bytes()); + assert_eq!(joined, bytes(kat, "ct")); + hpke::single_shot_open_in_place_detached::( + &OpModeR::Base, + &sk_r, + &enc, + &info, + &mut plaintext, + &bytes(kat, "aad"), + &tag, + ) + .unwrap(); + assert_eq!(*plaintext, bytes(kat, "pt")); + } + + #[test] + fn generated_recipient_keys_are_independent_and_redacted() { + let mut rng = ChaCha20Rng::from_seed([0x31; 32]); + let (mut secret, public) = CeremonyHpkeSecretKey::generate(&mut rng); + let (other_secret, other_public) = CeremonyHpkeSecretKey::generate(&mut rng); + assert_ne!(public, other_public); + assert_eq!(format!("{secret:?}"), "CeremonyHpkeSecretKey([REDACTED])"); + fn zeroized_on_drop(_: &T) {} + zeroized_on_drop(&secret); + let sealed = seal_dealing(&public, &context(), b"private dealing", &mut rng).unwrap(); + assert_eq!( + open_dealing(&secret, &context(), &sealed) + .unwrap() + .as_slice(), + b"private dealing" + ); + assert!(open_dealing(&other_secret, &context(), &sealed).is_err()); + secret.zeroize(); + assert!(open_dealing(&secret, &context(), &sealed).is_err()); + let encoded = public.encode(); + assert_eq!(encoded.len(), 32); + assert_eq!(CeremonyHpkePublicKey::decode(&encoded).unwrap(), public); + for length in [0, 31, 33, 65537] { + assert!(CeremonyHpkePublicKey::decode(&vec![0; length]).is_err()); + } + } + + #[test] + fn hpke_seal_open_wrong_context_smoke() { + let mut rng = ChaCha20Rng::from_seed([0x32; 32]); + let (secret, public) = CeremonyHpkeSecretKey::generate(&mut rng); + let ctx = context(); + let plaintext = b"two scalar values for a ceremony recipient"; + let sealed = seal_dealing(&public, &ctx, plaintext, &mut rng).unwrap(); + let wire = sealed.encode(); + let decoded = SealedDealing::decode(&wire).unwrap(); + let opened: Zeroizing> = open_dealing(&secret, &ctx, &decoded).unwrap(); + assert_eq!(opened.as_slice(), plaintext); + let mut wrong = ctx; + wrong.ceremony_digest[0] ^= 1; + assert!(open_dealing(&secret, &wrong, &decoded).is_err()); + println!("seal/open: original plaintext recovered; canonical envelope: {} bytes; changed ceremony: rejected", wire.len()); + } + + #[test] + fn every_context_field_and_recipient_key_is_bound() { + let mut rng = ChaCha20Rng::from_seed([0x33; 32]); + let (secret, public) = CeremonyHpkeSecretKey::generate(&mut rng); + let (other_secret, _) = CeremonyHpkeSecretKey::generate(&mut rng); + let ctx = context(); + let sealed = seal_dealing(&public, &ctx, b"dealing", &mut rng).unwrap(); + let mut ceremony = ctx; + ceremony.ceremony_digest[0] ^= 1; + let mut round = ctx; + round.round = 2; + let mut sender = ctx; + sender.sender = MemberIndex::new(3).unwrap(); + let mut recipient = ctx; + recipient.recipient = MemberIndex::new(3).unwrap(); + let mut commitments = ctx; + commitments.commitment_hash[0] ^= 1; + for changed in [ceremony, round, sender, recipient, commitments] { + assert!( + open_dealing(&secret, &changed, &sealed).is_err(), + "{changed:?}" + ); + } + assert!(open_dealing(&other_secret, &ctx, &sealed).is_err()); + } + + #[test] + fn encapsulation_ciphertext_and_tag_mutations_are_rejected() { + let mut rng = ChaCha20Rng::from_seed([0x34; 32]); + let (secret, public) = CeremonyHpkeSecretKey::generate(&mut rng); + let ctx = context(); + let sealed = seal_dealing(&public, &ctx, b"dealing", &mut rng).unwrap(); + let wire = sealed.encode(); + for offset in [1, 32, 37, wire.len() - 1] { + let mut changed = wire.clone(); + changed[offset] ^= 1; + let changed = SealedDealing::decode(&changed).unwrap(); + assert!(open_dealing(&secret, &ctx, &changed).is_err()); + } + let again = seal_dealing(&public, &ctx, b"dealing", &mut rng).unwrap(); + assert_ne!(sealed.enc, again.enc); + } + + #[test] + fn low_order_recipient_and_encapsulation_fail() { + let mut rng = ChaCha20Rng::from_seed([0x35; 32]); + let (secret, public) = CeremonyHpkeSecretKey::generate(&mut rng); + let ctx = context(); + let sealed = seal_dealing(&public, &ctx, b"dealing", &mut rng).unwrap(); + let mut one = [0; 32]; + one[0] = 1; + let mut p_minus_one = [0xff; 32]; + p_minus_one[0] = 0xec; + p_minus_one[31] = 0x7f; + for low_order in [[0; 32], one, p_minus_one] { + let key = CeremonyHpkePublicKey::decode(&low_order).unwrap(); + assert!(seal_dealing(&key, &ctx, b"dealing", &mut rng).is_err()); + let mut wire = sealed.encode(); + wire[1..33].copy_from_slice(&low_order); + let changed = SealedDealing::decode(&wire).unwrap(); + assert!(open_dealing(&secret, &ctx, &changed).is_err()); + } + } + + #[test] + fn envelope_has_exact_layout_and_canonical_lengths() { + let mut rng = ChaCha20Rng::from_seed([0x36; 32]); + let (secret, public) = CeremonyHpkeSecretKey::generate(&mut rng); + let ctx = context(); + let sealed = seal_dealing(&public, &ctx, b"dealing", &mut rng).unwrap(); + let wire = sealed.encode(); + assert_eq!(wire[0], 1); + assert_eq!(&wire[1..33], sealed.enc); + assert_eq!(&wire[33..37], &(7u32 + 16).to_be_bytes()); + assert_eq!(&wire[37..], sealed.ciphertext); + assert_eq!(SealedDealing::decode(&wire).unwrap().encode(), wire); + for length in 0..wire.len() { + assert!(SealedDealing::decode(&wire[..length]).is_err()); + } + let mut trailing = wire.clone(); + trailing.push(0); + assert!(matches!( + SealedDealing::decode(&trailing), + Err(Error::InvalidEncoding) + )); + let mut version = wire.clone(); + version[0] = 2; + assert!(matches!( + SealedDealing::decode(&version), + Err(Error::InvalidEncoding) + )); + let mut oversized_length = wire.clone(); + oversized_length[33..37].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(matches!( + SealedDealing::decode(&oversized_length), + Err(Error::LimitExceeded) + )); + let mut short_tag = wire[..37].to_vec(); + short_tag[33..37].copy_from_slice(&15u32.to_be_bytes()); + short_tag.extend_from_slice(&[0; 15]); + assert!(matches!( + SealedDealing::decode(&short_tag), + Err(Error::InvalidLength) + )); + for size in [0, 65_483] { + let plaintext = Zeroizing::new(vec![0x5a; size]); + let sealed = seal_dealing(&public, &ctx, &plaintext, &mut rng).unwrap(); + assert_eq!(sealed.encode().len(), size + 53); + assert_eq!(open_dealing(&secret, &ctx, &sealed).unwrap(), plaintext); + } + assert!(matches!( + seal_dealing(&public, &ctx, &vec![0; 65_484], &mut rng), + Err(Error::LimitExceeded) + )); + assert!(matches!( + SealedDealing::decode(&vec![0; 65_537]), + Err(Error::LimitExceeded) + )); + } + + #[test] + fn deterministic_binding_matches_direct_rfc_api() { + let mut rng = ChaCha20Rng::from_seed([0x37; 32]); + let (secret, public) = CeremonyHpkeSecretKey::generate(&mut rng); + let (other_secret, other_public) = CeremonyHpkeSecretKey::generate(&mut rng); + let ctx = context(); + let mut direct_info = b"TC-THRESHOLD-V1-DEALING".to_vec(); + direct_info.extend_from_slice(&[0x11; 32]); + direct_info.extend_from_slice(&[1, 0, 1, 0, 2]); + let mut rng = ChaCha20Rng::from_seed([0x38; 32]); + let sealed = seal_dealing(&public, &ctx, b"bound dealing", &mut rng).unwrap(); + let pk = ::PublicKey::from_bytes(&public.encode()).unwrap(); + let enc = ::EncappedKey::from_bytes(&sealed.enc).unwrap(); + let ciphertext = &sealed.ciphertext; + let (body, tag) = ciphertext.split_at(ciphertext.len() - 16); + let tag = AeadTag::::from_bytes(tag).unwrap(); + let mut direct = Zeroizing::new(b"bound dealing".to_vec()); + let mut direct_rng = ChaCha20Rng::from_seed([0x38; 32]); + let (expected_enc, expected_tag) = + hpke::single_shot_seal_in_place_detached::( + &OpModeS::Base, + &pk, + &direct_info, + &mut direct, + &[0x22; 32], + &mut direct_rng, + ) + .unwrap(); + assert_eq!(&*expected_enc.to_bytes(), sealed.enc); + assert_eq!(expected_tag.to_bytes(), tag.to_bytes()); + assert_eq!(direct.as_slice(), body); + let mut other_ctx = ctx; + other_ctx.recipient = MemberIndex::new(3).unwrap(); + let mut rng = ChaCha20Rng::from_seed([0x38; 32]); + let for_other = + seal_dealing(&other_public, &other_ctx, b"bound dealing", &mut rng).unwrap(); + assert_ne!(sealed.ciphertext, for_other.ciphertext); + assert_eq!( + open_dealing(&other_secret, &other_ctx, &for_other) + .unwrap() + .as_slice(), + b"bound dealing" + ); + assert!(open_dealing(&secret, &ctx, &for_other).is_err()); + let mut other_ceremony = ctx; + other_ceremony.ceremony_digest[0] ^= 1; + let mut rng = ChaCha20Rng::from_seed([0x38; 32]); + let other = seal_dealing(&public, &other_ceremony, b"bound dealing", &mut rng).unwrap(); + assert_ne!(sealed.ciphertext, other.ciphertext); + assert!(open_dealing(&secret, &ctx, &other).is_err()); + assert_eq!(&*enc.to_bytes(), sealed.enc); + } + + proptest::proptest! { + #[test] + fn arbitrary_envelopes_are_rejected_or_canonical( + wire in proptest::collection::vec(proptest::prelude::any::(), 0..70_000) + ) { + if let Ok(sealed) = SealedDealing::decode(&wire) { + proptest::prop_assert_eq!(sealed.encode(), wire); + } + } + + #[test] + fn structured_envelopes_preserve_all_public_bytes( + enc in proptest::prelude::any::<[u8; 32]>(), + ciphertext in proptest::collection::vec(proptest::prelude::any::(), 16..256) + ) { + let mut wire = vec![1]; + wire.extend_from_slice(&enc); + wire.extend_from_slice(&(ciphertext.len() as u32).to_be_bytes()); + wire.extend_from_slice(&ciphertext); + let decoded = SealedDealing::decode(&wire).unwrap(); + proptest::prop_assert_eq!(decoded.enc, enc); + proptest::prop_assert_eq!(&decoded.ciphertext, &ciphertext); + proptest::prop_assert_eq!(decoded.encode(), wire); + } + } +} diff --git a/tinycloud-threshold/vectors/v1/hpke-rfc9180-a2-1.json b/tinycloud-threshold/vectors/v1/hpke-rfc9180-a2-1.json new file mode 100644 index 00000000..5257a648 --- /dev/null +++ b/tinycloud-threshold/vectors/v1/hpke-rfc9180-a2-1.json @@ -0,0 +1,73 @@ +{ + "format_version": 1, + "suite": "DHKEM(X25519, HKDF-SHA256)/HKDF-SHA256/ChaCha20Poly1305", + "spec_sections": ["RFC9180 A.2.1", "3.10"], + "encoding": "hex", + "source": "https://www.rfc-editor.org/rfc/rfc9180.html#appendix-A.2.1", + "errata": ["https://www.rfc-editor.org/errata/eid7121"], + "cases": [ + { + "name": "rfc9180-a2-1-base", + "operation": "hpke_base", + "inputs": { + "mode": 0, + "kem_id": 32, + "kdf_id": 1, + "aead_id": 3, + "info": "4f6465206f6e2061204772656369616e2055726e", + "ikmE": "909a9b35d3dc4713a5e72a4da274b55d3d3821a37e5d099e74a647db583a904b", + "ikmR": "1ac01f181fdf9f352797655161c58b75c656a6cc2716dcb66372da835542e1df" + }, + "outputs": { + "pkEm": "1afa08d3dec047a643885163f1180476fa7ddb54c6a8029ea33f95796bf2ac4a", + "skEm": "f0ec9b33b792c372c1d2c2063507b684ef925b8c75a42dbcbf57d63ccd381640", + "pkRm": "4310ee97d88cc1f088a5576c77ab0cf5c3ac797f3d95139c6c84b5429c59662a", + "skRm": "8057991eef8f1f1af18f4a9491d16a1ce333f695d4db8e38da75975c4478e07b", + "enc": "1afa08d3dec047a643885163f1180476fa7ddb54c6a8029ea33f95796bf2ac4a", + "encryptions": [ + { + "sequence": 0, + "pt": "4265617574792069732074727574682c20747275746820626561757479", + "aad": "436f756e742d30", + "ct": "1c5250d8034ec2b784ba2cfd69dbdb8af406cfe3ff938e131f0def8c8b60b4db21993c62ce81883d2dd1b51a28" + }, + { + "sequence": 1, + "pt": "4265617574792069732074727574682c20747275746820626561757479", + "aad": "436f756e742d31", + "ct": "6b53c051e4199c518de79594e1c4ab18b96f081549d45ce015be002090bb119e85285337cc95ba5f59992dc98c" + }, + { + "sequence": 2, + "pt": "4265617574792069732074727574682c20747275746820626561757479", + "aad": "436f756e742d32", + "ct": "71146bd6795ccc9c49ce25dda112a48f202ad220559502cef1f34271e0cb4b02b4f10ecac6f48c32f878fae86b" + }, + { + "sequence": 4, + "pt": "4265617574792069732074727574682c20747275746820626561757479", + "aad": "436f756e742d34", + "ct": "63357a2aa291f5a4e5f27db6baa2af8cf77427c7c1a909e0b37214dd47db122bb153495ff0b02e9e54a50dbe16" + }, + { + "sequence": 255, + "pt": "4265617574792069732074727574682c20747275746820626561757479", + "aad": "436f756e742d323535", + "ct": "18ab939d63ddec9f6ac2b60d61d36a7375d2070c9b683861110757062c52b8880a5f6b3936da9cd6c23ef2a95c" + }, + { + "sequence": 256, + "pt": "4265617574792069732074727574682c20747275746820626561757479", + "aad": "436f756e742d323536", + "ct": "7a4a13e9ef23978e2c520fd4d2e757514ae160cd0cd05e556ef692370ca53076214c0c40d4c728d6ed9e727a5b" + } + ], + "exports": [ + { "context": "", "value": "4bbd6243b8bb54cec311fac9df81841b6fd61f56538a775e7c80a9f40160606e" }, + { "context": "00", "value": "8c1df14732580e5501b00f82b10a1647b40713191b7c1240ac80e2b68808ba69" }, + { "context": "54657374436f6e74657874", "value": "5acb09211139c43b3090489a9da433e8a30ee7188ba8b0a9a1ccf0c229283e53" } + ] + } + } + ] +}