From 1464ad5ca0d724009a8e2e77f349061ba7526215 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:49:08 +0000 Subject: [PATCH 1/4] test(threshold): specify sealed dealing behavior; Details: TC-2220 --- tinycloud-threshold/src/seal.rs | 367 ++++++++++++++++++ .../vectors/v1/hpke-rfc9180-a2-1.json | 72 ++++ 2 files changed, 439 insertions(+) create mode 100644 tinycloud-threshold/src/seal.rs create mode 100644 tinycloud-threshold/vectors/v1/hpke-rfc9180-a2-1.json diff --git a/tinycloud-threshold/src/seal.rs b/tinycloud-threshold/src/seal.rs new file mode 100644 index 00000000..81bbfe1e --- /dev/null +++ b/tinycloud-threshold/src/seal.rs @@ -0,0 +1,367 @@ +#[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!(sk_e.to_bytes().as_slice(), bytes(output, "skEm")); + assert_eq!(pk_e.to_bytes().as_slice(), bytes(output, "pkEm")); + assert_eq!(sk_r.to_bytes().as_slice(), bytes(output, "skRm")); + assert_eq!(pk_r.to_bytes().as_slice(), 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().as_slice(), 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::< + HpkeAead, + HpkeKdf, + HpkeKem, + _, + >(&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().as_slice(), 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().as_slice(), 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..40b50513 --- /dev/null +++ b/tinycloud-threshold/vectors/v1/hpke-rfc9180-a2-1.json @@ -0,0 +1,72 @@ +{ + "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", + "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": "f4ec9b33b792c372c1d2c2063507b684ef925b8c75a42dbcbf57d63ccd381600", + "pkRm": "4310ee97d88cc1f088a5576c77ab0cf5c3ac797f3d95139c6c84b5429c59662a", + "skRm": "8057991eef8f1f1af18f4a9491d16a1ce333f695d4db8e38da75975c4478e0fb", + "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" } + ] + } + } + ] +} From d766809e4013a34b05fc41c4695491b0487a37ce Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:19:04 +0000 Subject: [PATCH 2/4] feat(threshold): compose ceremony dealing envelopes; Details: TC-2220 --- tinycloud-threshold/src/seal.rs | 239 +++++++++++++++++- .../vectors/v1/hpke-rfc9180-a2-1.json | 5 +- 2 files changed, 239 insertions(+), 5 deletions(-) diff --git a/tinycloud-threshold/src/seal.rs b/tinycloud-threshold/src/seal.rs index 81bbfe1e..53defd41 100644 --- a/tinycloud-threshold/src/seal.rs +++ b/tinycloud-threshold/src/seal.rs @@ -1,3 +1,238 @@ +//! 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::< + HpkeAead, + HpkeKdf, + HpkeKem, + _, + >( + &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::*; @@ -63,11 +298,9 @@ mod tests { 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_e, pk_e) = HpkeKem::derive_keypair(&bytes(input, "ikmE")); let (sk_r, pk_r) = HpkeKem::derive_keypair(&bytes(input, "ikmR")); - assert_eq!(sk_e.to_bytes().as_slice(), bytes(output, "skEm")); assert_eq!(pk_e.to_bytes().as_slice(), bytes(output, "pkEm")); - assert_eq!(sk_r.to_bytes().as_slice(), bytes(output, "skRm")); assert_eq!(pk_r.to_bytes().as_slice(), bytes(output, "pkRm")); let mut rng = FixedIkm { bytes: bytes(input, "ikmE").try_into().unwrap(), diff --git a/tinycloud-threshold/vectors/v1/hpke-rfc9180-a2-1.json b/tinycloud-threshold/vectors/v1/hpke-rfc9180-a2-1.json index 40b50513..5257a648 100644 --- a/tinycloud-threshold/vectors/v1/hpke-rfc9180-a2-1.json +++ b/tinycloud-threshold/vectors/v1/hpke-rfc9180-a2-1.json @@ -4,6 +4,7 @@ "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", @@ -19,9 +20,9 @@ }, "outputs": { "pkEm": "1afa08d3dec047a643885163f1180476fa7ddb54c6a8029ea33f95796bf2ac4a", - "skEm": "f4ec9b33b792c372c1d2c2063507b684ef925b8c75a42dbcbf57d63ccd381600", + "skEm": "f0ec9b33b792c372c1d2c2063507b684ef925b8c75a42dbcbf57d63ccd381640", "pkRm": "4310ee97d88cc1f088a5576c77ab0cf5c3ac797f3d95139c6c84b5429c59662a", - "skRm": "8057991eef8f1f1af18f4a9491d16a1ce333f695d4db8e38da75975c4478e0fb", + "skRm": "8057991eef8f1f1af18f4a9491d16a1ce333f695d4db8e38da75975c4478e07b", "enc": "1afa08d3dec047a643885163f1180476fa7ddb54c6a8029ea33f95796bf2ac4a", "encryptions": [ { From 02c5e1816b04a9e330cacc51d8c51c6c1bb9c4b7 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:52:31 +0000 Subject: [PATCH 3/4] test(threshold): retain canonical HPKE byte checks; Details: TC-2220 --- tinycloud-threshold/src/seal.rs | 115 ++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 43 deletions(-) diff --git a/tinycloud-threshold/src/seal.rs b/tinycloud-threshold/src/seal.rs index 53defd41..f9d883fe 100644 --- a/tinycloud-threshold/src/seal.rs +++ b/tinycloud-threshold/src/seal.rs @@ -160,17 +160,12 @@ pub(crate) fn seal_dealing( if ciphertext_len > CIPHERTEXT_CAP { return Err(Error::LimitExceeded); } - let public = ::PublicKey::from_bytes(&public.0) - .map_err(|_| Error::InvalidHpkeKey)?; + 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::< - HpkeAead, - HpkeKdf, - HpkeKem, - _, - >( + let (enc, tag) = hpke::single_shot_seal_in_place_detached::( &OpModeS::Base, &public, &info, @@ -287,10 +282,8 @@ mod tests { #[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 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"]; @@ -300,8 +293,8 @@ mod tests { 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().as_slice(), bytes(output, "pkEm")); - assert_eq!(pk_r.to_bytes().as_slice(), bytes(output, "pkRm")); + 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, @@ -315,14 +308,10 @@ mod tests { ) .unwrap(); assert!(rng.consumed); - assert_eq!(enc.to_bytes().as_slice(), bytes(output, "enc")); - let mut receiver = hpke::setup_receiver::( - &OpModeR::Base, - &sk_r, - &enc, - &info, - ) - .unwrap(); + 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 { @@ -355,7 +344,9 @@ mod tests { 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(); + sender + .export(&bytes(kat, "context"), &mut sender_value) + .unwrap(); receiver .export(&bytes(kat, "context"), &mut receiver_value) .unwrap(); @@ -368,12 +359,14 @@ mod tests { bytes: bytes(input, "ikmE").try_into().unwrap(), consumed: false, }; - let (enc, tag) = hpke::single_shot_seal_in_place_detached::< - HpkeAead, - HpkeKdf, - HpkeKem, - _, - >(&OpModeS::Base, &pk_r, &info, &mut plaintext, &bytes(kat, "aad"), &mut rng) + 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()); @@ -401,7 +394,12 @@ mod tests { 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_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()); @@ -448,7 +446,10 @@ mod tests { 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(&secret, &changed, &sealed).is_err(), + "{changed:?}" + ); } assert!(open_dealing(&other_secret, &ctx, &sealed).is_err()); } @@ -508,17 +509,29 @@ mod tests { } let mut trailing = wire.clone(); trailing.push(0); - assert!(matches!(SealedDealing::decode(&trailing), Err(Error::InvalidEncoding))); + assert!(matches!( + SealedDealing::decode(&trailing), + Err(Error::InvalidEncoding) + )); let mut version = wire.clone(); version[0] = 2; - assert!(matches!(SealedDealing::decode(&version), Err(Error::InvalidEncoding))); + 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))); + 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))); + 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(); @@ -529,7 +542,10 @@ mod tests { seal_dealing(&public, &ctx, &vec![0; 65_484], &mut rng), Err(Error::LimitExceeded) )); - assert!(matches!(SealedDealing::decode(&vec![0; 65_537]), Err(Error::LimitExceeded))); + assert!(matches!( + SealedDealing::decode(&vec![0; 65_537]), + Err(Error::LimitExceeded) + )); } #[test] @@ -550,18 +566,31 @@ mod tests { 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().as_slice(), sealed.enc); + 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(); + 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_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; @@ -569,7 +598,7 @@ mod tests { 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().as_slice(), sealed.enc); + assert_eq!(&*enc.to_bytes(), sealed.enc); } proptest::proptest! { From 59983832277ad6a31efabb3b8f2459017297fa62 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:25:49 +0000 Subject: [PATCH 4/4] chore(threshold): wire sealed dealings on main Details: TC-2220 --- Cargo.lock | 21 +++++++++++++++++++++ tinycloud-threshold/Cargo.toml | 1 + tinycloud-threshold/SPEC.md | 4 ++++ tinycloud-threshold/src/error.rs | 4 ++++ tinycloud-threshold/src/lib.rs | 9 +++++++++ 5 files changed, 39 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 13497c8b..a66ab19d 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" @@ -10095,6 +10115,7 @@ version = "0.1.0" dependencies = [ "blst", "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 9281a1be..f046b3c5 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] proptest = "1.6" diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md index 0ba88630..e7dcd3c8 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 6341bf64..afcb236d 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, } diff --git a/tinycloud-threshold/src/lib.rs b/tinycloud-threshold/src/lib.rs index d93316ce..f811e77c 100644 --- a/tinycloud-threshold/src/lib.rs +++ b/tinycloud-threshold/src/lib.rs @@ -25,6 +25,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 context::{AuthorizedDerivation, KeyContext}; pub use derive::*; @@ -32,6 +40,7 @@ pub use error::{CombineError, Error, ShareError}; pub use ibe::*; pub use identity::IbeIdentity; pub use ids::*; +pub use seal::{CeremonyHpkePublicKey, CeremonyHpkeSecretKey, SealedDealing}; #[cfg(test)] mod checkpoint;