From 305606d6442b5afa553940963a06436737ba02e3 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 11:28:10 +0200 Subject: [PATCH 001/114] feat: initial scafolding to support multiple signature schemes in signcryption --- .../generate-v0.15.0/src/data_0_15.rs | 42 +- backward-compatibility/src/lib.rs | 32 + core/service/src/backup/custodian.rs | 17 +- core/service/src/backup/operator.rs | 37 +- core/service/src/cryptography/error.rs | 2 + core/service/src/cryptography/signatures.rs | 5 +- core/service/src/cryptography/signcryption.rs | 724 +++++++++++++++--- core/service/src/cryptography/signing/mod.rs | 45 ++ .../src/cryptography/signing/scheme_set.rs | 313 ++++++++ .../src/cryptography/signing/verf_key_set.rs | 195 +++++ core/service/src/engine/backup_operator.rs | 10 +- core/service/src/vault/storage/mod.rs | 10 +- .../tests/backward_compatibility_kms.rs | 46 +- core/service/tests/versioned_enum_coverage.rs | 7 +- 14 files changed, 1345 insertions(+), 140 deletions(-) create mode 100644 core/service/src/cryptography/signing/scheme_set.rs create mode 100644 core/service/src/cryptography/signing/verf_key_set.rs diff --git a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs index 49b9c70dfe..1386a4688c 100644 --- a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs +++ b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs @@ -28,7 +28,7 @@ use kms_0_15_0::cryptography::{ hybrid_ml_kem::HybridKemCt, signatures::{ compute_eip712_signature, gen_sig_keys, NodeSigningIdentity, RootSigningSeed, - SigningSchemeType, UnifiedPublicSigKey, + SigningSchemeSet, SigningSchemeType, UnifiedPublicSigKey, }, signcryption::{ Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned, @@ -114,12 +114,12 @@ use backward_compatibility::{ PrepKeygenSignedPayloadTest, PrfKeyTest, PrivDataTypeTest, PrivateSigKeyTest, PrssSetTest, PrssSetupCombinedTest, PubDataTypeTest, PublicDecSignedPayloadTest, PublicSigKeyTest, RecoveryValidationMaterialTest, ReleasePCRValuesTest, RootSigningSeedTest, SchemeDigestsTest, - ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SoftwareVersionTest, - StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataDD, TestMetadataKMS, - TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedCipherTest, - UnifiedPublicSigKeyTest, UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, - UnifiedUnsigncryptionKeyTest, UserDecSignedPayloadTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, - KMS_GRPC_MODULE_NAME, KMS_MODULE_NAME, + ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SigningSchemeSetTest, + SoftwareVersionTest, StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataDD, + TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, + UnifiedCipherTest, UnifiedPublicSigKeyTest, UnifiedSigncryptionKeyTest, + UnifiedSigncryptionTest, UnifiedUnsigncryptionKeyTest, UserDecSignedPayloadTest, + DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, KMS_MODULE_NAME, }; use hashing_0_15_0::hash_versioned; use kms_0_15_0::cryptography::signcryption::SigncryptionPayload; @@ -507,6 +507,14 @@ const UNIFIED_SIGNCRYPTION_TEST: UnifiedSigncryptionTest = UnifiedSigncryptionTe state: 202, }; +// KMS test +const SIGNING_SCHEME_SET_TEST: SigningSchemeSetTest = SigningSchemeSetTest { + test_filename: Cow::Borrowed("signing_scheme_set"), + // The composite pair custodian backup signs under, given as wire + // discriminants: Ecdsa256k1 and MlDsa87. + schemes: Cow::Borrowed(&[0, 4]), +}; + // KMS test const BACKUP_CIPHERTEXT_TEST: BackupCiphertextTest = BackupCiphertextTest { test_filename: Cow::Borrowed("backup_ciphertext"), @@ -1185,6 +1193,25 @@ impl KmsV0_15_0 { TestMetadataKMS::UnifiedSigncryption(UNIFIED_SIGNCRYPTION_TEST) } + fn gen_signing_scheme_set(dir: &PathBuf) -> TestMetadataKMS { + // Built from the declared wire discriminants rather than from a + // constructor shortcut, so the fixture freezes the set the metadata + // names and a later reordering of the enum cannot silently change it. + let schemes = SIGNING_SCHEME_SET_TEST + .schemes + .iter() + .map(|wire| { + SigningSchemeType::try_from(*wire) + .expect("the test metadata names a known signing scheme") + }) + .collect::>(); + let set = + SigningSchemeSet::new(schemes).expect("the test metadata names at least one scheme"); + + store_versioned_test!(&set, dir, &SIGNING_SCHEME_SET_TEST.test_filename); + TestMetadataKMS::SigningSchemeSet(SIGNING_SCHEME_SET_TEST) + } + fn gen_unified_cipher(dir: &PathBuf) -> TestMetadataKMS { let mut rng = AesRng::seed_from_u64(UNIFIED_CIPHER_TEST.state); let mut kem_ct = [0_u8; 32]; @@ -2179,6 +2206,7 @@ impl KMSCoreVersion for V0_15_0 { KmsV0_15_0::gen_mlkem1024_p384_public_key(&dir), KmsV0_15_0::gen_mlkem1024_p384_private_key(&dir), KmsV0_15_0::gen_unified_signcryption(&dir), + KmsV0_15_0::gen_signing_scheme_set(&dir), KmsV0_15_0::gen_backup_ciphertext(&dir), KmsV0_15_0::gen_unified_cipher(&dir), KmsV0_15_0::gen_hybrid_kem_ct(&dir), diff --git a/backward-compatibility/src/lib.rs b/backward-compatibility/src/lib.rs index c23c529068..20ef0a39ac 100644 --- a/backward-compatibility/src/lib.rs +++ b/backward-compatibility/src/lib.rs @@ -786,6 +786,37 @@ impl TestType for MlKem1024P384PrivateKeyTest { } } +/// Test metadata for SigningSchemeSet backward compatibility. +/// +/// A `SigningSchemeSet` is a field of the persisted `UnifiedSigncryption` and is +/// what a composite signature commits to, so a set written by one release has to +/// stay readable by the next. +/// +/// The set is described by the wire discriminants of its schemes rather than by +/// a seed, so the fixture states exactly which schemes it holds and the loader +/// can rebuild it without depending on any generator-side helper. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SigningSchemeSetTest { + pub test_filename: Cow<'static, str>, + /// The wire discriminants of the schemes the stored set holds, in canonical + /// order. + pub schemes: Cow<'static, [i32]>, +} + +impl TestType for SigningSchemeSetTest { + fn module(&self) -> String { + KMS_MODULE_NAME.to_string() + } + + fn target_type(&self) -> String { + "SigningSchemeSet".to_string() + } + + fn test_filename(&self) -> String { + self.test_filename.to_string() + } +} + // KMS test #[derive(Serialize, Deserialize, Clone, Debug)] pub struct UnifiedSigncryptionTest { @@ -1315,6 +1346,7 @@ pub enum TestMetadataKMS { MlKem1024P384PublicKey(MlKem1024P384PublicKeyTest), MlKem1024P384PrivateKey(MlKem1024P384PrivateKeyTest), UnifiedSigncryption(UnifiedSigncryptionTest), + SigningSchemeSet(SigningSchemeSetTest), BackupCiphertext(BackupCiphertextTest), UnifiedCipher(UnifiedCipherTest), HybridKemCt(HybridKemCtTest), diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index c3e7fc2e6c..99df6390dc 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -82,11 +82,26 @@ impl TryFrom for CustodianRecoveryOutput { type Error = anyhow::Error; fn try_from(value: InternalCustodianRecoveryOutput) -> Result { + // TODO stop gap + // `OperatorBackupOutput` has room for a single signing scheme, so a + // composite signcryption cannot be described by it. Fail rather than + // name one of its schemes and silently drop the rest. + let signing_type = value + .signcryption + .sole_signing_scheme() + .ok_or_else(|| { + anyhow::anyhow!( + "cannot represent a signcryption signed under {} in an OperatorBackupOutput, \ + which carries a single signing scheme", + value.signcryption.signing_schemes + ) + })? + .as_wire(); Ok(CustodianRecoveryOutput { backup_output: Some(OperatorBackupOutput { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, - signing_type: value.signcryption.signing_type as i32, + signing_type, }), custodian_role: value.custodian_role.one_based() as u64, }) diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index 0e59e22be0..3bc3e4956b 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -173,11 +173,11 @@ impl TryFrom for InnerOperatorBackupOutput { fn try_from(value: OperatorBackupOutput) -> Result { Ok(Self { - signcryption: UnifiedSigncryption { - payload: value.signcryption, - pke_type: value.pke_type.try_into()?, - signing_type: value.signing_type.try_into()?, - }, + signcryption: UnifiedSigncryption::new( + value.signcryption, + value.pke_type.try_into()?, + value.signing_type.try_into()?, + ), }) } } @@ -185,10 +185,25 @@ impl TryFrom for OperatorBackupOutput { type Error = anyhow::Error; fn try_from(value: InnerOperatorBackupOutput) -> Result { + // TODO stop gap + // `OperatorBackupOutput` has room for a single signing scheme, so a + // composite signcryption cannot be described by it. Fail rather than + // name one of its schemes and silently drop the rest. + let signing_type = value + .signcryption + .sole_signing_scheme() + .ok_or_else(|| { + anyhow::anyhow!( + "cannot represent a signcryption signed under {} in an OperatorBackupOutput, \ + which carries a single signing scheme", + value.signcryption.signing_schemes + ) + })? + .as_wire(); Ok(Self { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, - signing_type: value.signcryption.signing_type as i32, + signing_type, }) } } @@ -971,11 +986,11 @@ mod tests { commitments.insert(Role::indexed_from_one(3), vec![3_u8; 32]); let mut cts = BTreeMap::new(); let cts_out = InnerOperatorBackupOutput { - signcryption: UnifiedSigncryption { - payload: vec![1, 2, 3], - pke_type: BACKUP_PKE_SCHEME, - signing_type: SigningSchemeType::Ecdsa256k1, - }, + signcryption: UnifiedSigncryption::new( + vec![1, 2, 3], + BACKUP_PKE_SCHEME, + SigningSchemeType::Ecdsa256k1, + ), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); cts.insert(Role::indexed_from_one(2), cts_out.clone()); diff --git a/core/service/src/cryptography/error.rs b/core/service/src/cryptography/error.rs index 34d6e63b16..1dbaa1ffe6 100644 --- a/core/service/src/cryptography/error.rs +++ b/core/service/src/cryptography/error.rs @@ -34,6 +34,8 @@ pub enum CryptographyError { VerificationError(String), #[error(transparent)] Signing(#[from] SigningError), + #[error("unsupported signcryption format for signing schemes {0}")] + UnsupportedSigncryptionFormat(String), #[error("Other: {0}")] Other(String), } diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index bd1fe86f36..679e5f850b 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -17,8 +17,9 @@ pub use crate::cryptography::signing::ecdsa::{ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ - HasSigningScheme, SchemeVerfKeys, Signature, SigningError, SigningSchemeType, - SigningSchemeTypeVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, + HasSigningScheme, SchemeVerfKeys, Signature, SigningError, SigningSchemeSet, + SigningSchemeSetVersions, SigningSchemeType, SigningSchemeTypeVersions, UnifiedPublicSigKey, + UnifiedPublicSigKeyVersions, VerfKeySet, }; pub(crate) use crate::cryptography::signing::ecdsa::{ diff --git a/core/service/src/cryptography/signcryption.rs b/core/service/src/cryptography/signcryption.rs index 13854b165e..f5ab5abc85 100644 --- a/core/service/src/cryptography/signcryption.rs +++ b/core/service/src/cryptography/signcryption.rs @@ -9,6 +9,35 @@ //! must be compatible with EVM on-chain verification. //! //! For encryption a hybrid encryption scheme is used based on ML-KEM and AES GCM. +//! +//! # Envelope formats +//! +//! A signcryption's encrypted plaintext has a layout, and there is more than +//! one. Which one applies is a total function of the signcryption's scheme set. +//! +//! [`SigncryptionFormat::EcdsaV0`] is the original layout and is **frozen**: +//! every user-decryption ciphertext produced since 0.11 uses it, the deployed +//! browser-side verifier in `crate::client::user_decryption_wasm` parses it, and +//! because it carries no version tag of its own and is recovered by subtracting +//! two fixed-size tail fields, it cannot be made self-describing after the fact +//! either. What is locked, and by what: +//! +//! - The plaintext layout `msg ‖ sig ‖ H(sender verification key)`, by +//! `tests::ecdsa_v0_envelope_layout_is_locked`. +//! - The signed preimage `dsep ‖ msg ‖ receiver_id ‖ H(receiver enc key)`, by +//! `tests::ecdsa_v0_signed_preimage_is_locked`. +//! - The [`SigncryptionPayload`] bincode layout, by +//! `tests::test_signcryption_payload_v0_serialization_locked`. +//! - The whole artifact, including **the order in which the RNG is drawn from**, +//! by the backward-compatibility harness: `test_unified_signcryption` in +//! `core/service/tests/backward_compatibility_kms.rs` regenerates a +//! signcryption from a seeded RNG and compares it byte-for-byte against a +//! fixture frozen at 0.13.0. Moving an RNG draw in [`inner_signcryption`] +//! breaks it. +//! +//! [`SigncryptionFormat::CompositeV1`] will carry one signature per scheme, for +//! the custodian-backup chain. Not implemented yet: asking for it is a +//! [`CryptographyError::UnsupportedSigncryptionFormat`]. use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::{ @@ -18,8 +47,8 @@ use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_composite_ml_kem; use crate::cryptography::hybrid_ml_kem::{self, HybridKemCt}; use crate::cryptography::signatures::{ - HasSigningScheme, PrivateSigKey, PublicSigKey, SIG_SIZE, Signature, SigningSchemeType, - check_normalized, internal_sign, + HasSigningScheme, PrivateSigKey, PublicSigKey, SIG_SIZE, Signature, SigningSchemeSet, + SigningSchemeType, check_normalized, internal_sign, }; use crate::cryptography::zeroizing_writer::ZeroizingWriter; use ::signature::Verifier; @@ -30,7 +59,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tfhe::FheTypes; use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; -use tfhe_versionable::{Versionize, VersionsDispatch}; +use tfhe_versionable::{Upgrade, Version, Versionize, VersionsDispatch}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; const DSEP_SIGNCRYPTION: DomainSep = *b"SIGNCRYP"; @@ -165,6 +194,14 @@ impl<'a> UnifiedSigncryptionKey<'a> { receiver_id, } } + + /// The schemes this key signs under. + /// + /// A single scheme today, because the key it holds is the ECDSA + /// [`PrivateSigKey`]. + pub fn signing_schemes(&self) -> SigningSchemeSet { + SigningSchemeSet::single(self.signing_key.signing_scheme_type()) + } } impl HasPkeScheme for UnifiedSigncryptionKey<'_> { @@ -203,6 +240,14 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { } } + /// The schemes this key accepts a signature under. + /// + /// The counterpart of [`UnifiedSigncryptionKey::signing_schemes`], and a + /// single scheme for the same reason. + pub fn signing_schemes(&self) -> SigningSchemeSet { + SigningSchemeSet::single(self.sender_verf_key.signing_scheme_type()) + } + // TODO this file should be split up and this moved to signcryption } @@ -281,24 +326,100 @@ impl HasSigningScheme for UnifiedUnsigncryptionKeyOwned { #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, VersionsDispatch)] pub enum UnifiedSigncryptionVersions { - V0(UnifiedSigncryption), + V0(UnifiedSigncryptionV0), + V1(UnifiedSigncryption), } +/// A signcryption as it was stored and sent before composite signing, carrying +/// exactly one signature scheme. +/// +/// Kept so that legacy material can still be read. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Version)] +pub struct UnifiedSigncryptionV0 { + pub payload: Vec, + pub pke_type: PkeSchemeType, + pub signing_type: SigningSchemeType, +} + +impl Upgrade for UnifiedSigncryptionV0 { + type Error = std::convert::Infallible; + + fn upgrade(self) -> Result { + Ok(UnifiedSigncryption { + payload: self.payload, + pke_type: self.pke_type, + signing_schemes: SigningSchemeSet::single(self.signing_type), + }) + } +} + +/// A signcrypted message, tagged with the schemes used to protect it. +/// +/// `signing_schemes` names every scheme whose signature is inside `payload`. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Versionize)] #[versionize(UnifiedSigncryptionVersions)] pub struct UnifiedSigncryption { pub payload: Vec, pub pke_type: PkeSchemeType, - pub signing_type: SigningSchemeType, + pub signing_schemes: SigningSchemeSet, } impl UnifiedSigncryption { + /// A signcryption protected by the single scheme `signing_type`. pub fn new(payload: Vec, pke_type: PkeSchemeType, signing_type: SigningSchemeType) -> Self { Self { payload, pke_type, - signing_type, + signing_schemes: SigningSchemeSet::single(signing_type), } } + + /// A signcryption protected by every scheme in `signing_schemes`. + pub fn new_multi( + payload: Vec, + pke_type: PkeSchemeType, + signing_schemes: SigningSchemeSet, + ) -> Self { + Self { + payload, + pke_type, + signing_schemes, + } + } + + /// The one scheme protecting this signcryption, or `None` if several do. + pub fn sole_signing_scheme(&self) -> Option { + self.signing_schemes.sole() + } +} + +/// The layout of a signcryption's encrypted plaintext. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SigncryptionFormat { + /// `msg ‖ sig ‖ H(sender verification key)`, ECDSA only. Frozen; see the + /// module documentation. + EcdsaV0, + /// A self-describing, versioned, multi-signature envelope. + CompositeV1, +} + +/// The format a signcryption under `schemes` uses. +/// +/// A total function of the scheme set, deliberately. The alternative — sniffing +/// the decrypted bytes for a magic prefix — cannot work: an +/// [`SigncryptionFormat::EcdsaV0`] plaintext begins with attacker-influenced +/// message content and has no tag to find, so detection would be a heuristic, in +/// a parser sitting directly under a decryption key. +fn format_for(schemes: &SigningSchemeSet) -> SigncryptionFormat { + if schemes.as_slice() == [SigningSchemeType::Ecdsa256k1].as_slice() { + SigncryptionFormat::EcdsaV0 + } else { + SigncryptionFormat::CompositeV1 + } +} + +/// The error for a scheme set no implemented envelope format covers. +fn unsupported_format(schemes: &SigningSchemeSet) -> CryptographyError { + CryptographyError::UnsupportedSigncryptionFormat(schemes.to_string()) } #[derive(Clone, Serialize, Deserialize, Hash, PartialEq, Eq, Debug, VersionsDispatch)] @@ -398,7 +519,13 @@ impl<'a> Signcrypt for UnifiedSigncryptionKey<'a> { "Could not serialize message for signcryption: {e}", )) })?; - inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) + let schemes = self.signing_schemes(); + match format_for(&schemes) { + SigncryptionFormat::EcdsaV0 => { + inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) + } + SigncryptionFormat::CompositeV1 => Err(unsupported_format(&schemes)), + } } } @@ -439,6 +566,10 @@ impl<'a> SigncryptFHEPlaintext for UnifiedSigncryptionKey<'a> { let mut serialized_msg = ZeroizingWriter::new(); bc2wrap::serialize_into(&*signcryption_msg, &mut serialized_msg) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; + // Deliberately not routed through `format_for`: the wire type this + // produces, `TypedSigncryptedCiphertext.signcrypted_ciphertext`, is a + // bare `bytes` field with nowhere to record a format, so the only + // layout its readers can parse is the frozen one. inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) } } @@ -457,7 +588,79 @@ impl SigncryptFHEPlaintext for UnifiedSigncryptionKeyOwned { } } +/// The digest of the receiver's public encryption key, as it appears in the +/// signed preimage. +fn receiver_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Result, CryptographyError> { + match enc_key { + UnifiedPublicEncKey::MlKem512(public_enc_key) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) + .map_err(|e| CryptographyError::DeserializationError(e.to_string())) + } + UnifiedPublicEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), + UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) + .map_err(|e| CryptographyError::DeserializationError(e.to_string())) + } + } +} + +/// `receiver_id ‖ H(receiver public encryption key)`: the suffix that binds a +/// signcryption to who it was made for. +fn receiver_binding( + receiver_id: &[u8], + enc_key: &UnifiedPublicEncKey, +) -> Result, CryptographyError> { + Ok([receiver_id, receiver_enc_key_digest(enc_key)?.as_slice()].concat()) +} + +/// The digest of the sender's verification key, as it appears in the encrypted +/// plaintext's tail. +/// +/// LEGACY: this is horrible! The receiver is bound into the signed preimage by +/// its *address*, but the sender is bound here by a digest of its serialized +/// key. This should be changed to use the notion of a key id. +fn sender_verf_key_digest(verf_key: &PublicSigKey) -> Result, CryptographyError> { + serialize_hash_element(&DSEP_SIGNCRYPTION, verf_key) + .map_err(|e| CryptographyError::DeserializationError(e.to_string())) +} + +/// Encrypt `msg` under `enc_key` with the hybrid KEM/DEM matching its scheme. +fn hybrid_encrypt( + rng: &mut (impl CryptoRng + RngCore), + msg: &[u8], + enc_key: &UnifiedPublicEncKey, +) -> Result { + match enc_key { + UnifiedPublicEncKey::MlKem512(public_enc_key) => { + hybrid_ml_kem::enc::(rng, msg, &public_enc_key.0) + } + UnifiedPublicEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), + UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { + hybrid_composite_ml_kem::enc_ml_kem_1024_p384(rng, msg, public_enc_key) + } + } +} + +/// Decrypt `ct` under `dec_key` with the hybrid KEM/DEM matching its scheme. +fn hybrid_decrypt( + ct: HybridKemCt, + dec_key: &UnifiedPrivateEncKey, +) -> Result>, CryptographyError> { + match dec_key { + UnifiedPrivateEncKey::MlKem512(dec_key) => { + hybrid_ml_kem::dec::(ct, &dec_key.0) + } + UnifiedPrivateEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), + UnifiedPrivateEncKey::MlKem1024P384(dec_key) => { + hybrid_composite_ml_kem::dec_ml_kem_1024_p384(ct, dec_key) + } + } +} + // Implements the actual signcryption but without serialization +// +// This is the FROZEN `SigncryptionFormat::EcdsaV0` layout; see the module +// documentation for what depends on its bytes and on its RNG usage. fn inner_signcryption( signcrypt_key: &UnifiedSigncryptionKey, rng: &mut (impl CryptoRng + RngCore), @@ -468,21 +671,9 @@ fn inner_signcryption( // Sign msg || H(client_verf_key) || H(client_pub_key) // Note that H(client_verf_key) = client_address // Only serialize the inner structure to ensure backwards compatibility!!! - let serialized_enc_key = match &signcrypt_key.receiver_enc_key { - UnifiedPublicEncKey::MlKem512(public_enc_key) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) - .map_err(|e| CryptographyError::DeserializationError(e.to_string()))? - } - UnifiedPublicEncKey::MlKem1024(_) => { - return Err(CryptographyError::MlKem1024Unsupported); - } - UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) - .map_err(|e| CryptographyError::DeserializationError(e.to_string()))? - } - }; + let binding = receiver_binding(signcrypt_key.receiver_id, signcrypt_key.receiver_enc_key)?; // Wipe the temporary signed message after signing. - let to_sign = Zeroizing::new([msg, signcrypt_key.receiver_id, &serialized_enc_key].concat()); + let to_sign = Zeroizing::new([msg, binding.as_slice()].concat()); let sig = internal_sign(dsep, &to_sign, signcrypt_key.signing_key) .map_err(|e| CryptographyError::SigningError(e.to_string()))?; @@ -490,35 +681,31 @@ fn inner_signcryption( // OBSERVE: serialization is simply r concatenated with s. That is NOT an Ethereum compatible // signature since we preclude the v value. // The verification key is serialized based on the SEC1 standard. - let verf_key_hash = serialize_hash_element( - &DSEP_SIGNCRYPTION, - // LEGACY: this is horrible! We are using address above be here we use digest! This should be changed to use &self.signing_key.verf_key().verf_key_id(), - // Idem below - &PublicSigKey::from_sk(signcrypt_key.signing_key), - ) - .map_err(|e| CryptographyError::DeserializationError(e.to_string()))?; + let verf_key_hash = sender_verf_key_digest(&PublicSigKey::from_sk(signcrypt_key.signing_key))?; // Wipe the temporary encrypted message after encryption. let to_encrypt = Zeroizing::new([msg, sig.to_bytes().as_ref(), verf_key_hash.as_ref()].concat()); - let ciphertext = match &signcrypt_key.receiver_enc_key { - UnifiedPublicEncKey::MlKem512(public_enc_key) => { - hybrid_ml_kem::enc::(rng, &to_encrypt, &public_enc_key.0) - } - UnifiedPublicEncKey::MlKem1024(_) => { - return Err(CryptographyError::MlKem1024Unsupported); - } - UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { - hybrid_composite_ml_kem::enc_ml_kem_1024_p384(rng, &to_encrypt, public_enc_key) - } - }?; + let ciphertext = hybrid_encrypt(rng, &to_encrypt, signcrypt_key.receiver_enc_key)?; // LEGACY: approach to serialization - Ok(UnifiedSigncryption { - payload: bc2wrap::serialize(&ciphertext) + Ok(UnifiedSigncryption::new( + bc2wrap::serialize(&ciphertext) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, - pke_type: signcrypt_key.encryption_scheme_type(), - signing_type: signcrypt_key.signing_scheme_type(), - }) + signcrypt_key.encryption_scheme_type(), + signcrypt_key.signing_scheme_type(), + )) +} + +/// Open `cipher` in whichever format its scheme set names. +fn open_dispatch( + unsign_key: &UnifiedUnsigncryptionKey, + dsep: &DomainSep, + cipher: &UnifiedSigncryption, +) -> Result>, CryptographyError> { + match format_for(&cipher.signing_schemes) { + SigncryptionFormat::EcdsaV0 => inner_unsigncrypt(unsign_key, dsep, cipher), + SigncryptionFormat::CompositeV1 => Err(unsupported_format(&cipher.signing_schemes)), + } } impl<'a> Unsigncrypt for UnifiedUnsigncryptionKey<'a> { @@ -527,7 +714,7 @@ impl<'a> Unsigncrypt for UnifiedUnsigncryptionKey<'a> { dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result { - let msg_vec = inner_unsigncrypt(self, dsep, cipher)?; + let msg_vec = open_dispatch(self, dsep, cipher)?; safe_deserialize(std::io::Cursor::new(&*msg_vec), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError) } @@ -538,7 +725,7 @@ impl<'a> Unsigncrypt for UnifiedUnsigncryptionKey<'a> { signcryption: &UnifiedSigncryption, ) -> Result<(), CryptographyError> { // Since we use sign-then-encrypt, we need to decrypt first to get the message for signature verification - let _ = inner_unsigncrypt(self, dsep, signcryption).map_err(|e| { + let _ = open_dispatch(self, dsep, signcryption).map_err(|e| { CryptographyError::VerificationError(format!( "failed to decrypt signcryption for validation: {}", e @@ -575,11 +762,14 @@ impl<'a> UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKey<'a> { signcryption: &[u8], link: &[u8], ) -> Result { - let parsed_signcryption = UnifiedSigncryption { - payload: signcryption.to_owned(), - pke_type: self.encryption_key.encryption_scheme_type(), - signing_type: self.sender_verf_key.signing_scheme_type(), - }; + // The legacy user-decryption path. See the note in `signcrypt_plaintext` + // for why the format is fixed here rather than chosen: the raw bytes + // this receives have nowhere to record one. + let parsed_signcryption = UnifiedSigncryption::new( + signcryption.to_owned(), + self.encryption_key.encryption_scheme_type(), + self.sender_verf_key.signing_scheme_type(), + ); let decrypted_signcryption = inner_unsigncrypt(self, dsep, &parsed_signcryption)?; // LEGACY should be using safe_deserialization from tfhe-rs let mut signcrypted_msg: SigncryptionPayload = @@ -609,6 +799,9 @@ impl UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKeyOwned { } /// Implements the actual unsigncryption process, but without any deserialization +/// +/// This is the FROZEN `SigncryptionFormat::EcdsaV0` layout; see the module +/// documentation. fn inner_unsigncrypt( unsign_key: &UnifiedUnsigncryptionKey, dsep: &DomainSep, @@ -622,17 +815,7 @@ fn inner_unsigncrypt( // LEGACY Code: should be using safe_deserialization from tfhe-rs let deserialized_payload: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - let decrypted_plaintext = match &unsign_key.decryption_key { - UnifiedPrivateEncKey::MlKem512(dec_key) => { - hybrid_ml_kem::dec::(deserialized_payload, &dec_key.0) - } - UnifiedPrivateEncKey::MlKem1024(_) => { - return Err(CryptographyError::MlKem1024Unsupported); - } - UnifiedPrivateEncKey::MlKem1024P384(dec_key) => { - hybrid_composite_ml_kem::dec_ml_kem_1024_p384(deserialized_payload, dec_key) - } - }?; + let decrypted_plaintext = hybrid_decrypt(deserialized_payload, unsign_key.decryption_key)?; let (msg, sig) = parse_msg(decrypted_plaintext, unsign_key.sender_verf_key)?; check_format_and_signature(dsep, &msg, &sig, unsign_key)?; Ok(msg) @@ -662,10 +845,7 @@ fn parse_msg( &decrypted_plaintext[(msg_len + SIG_SIZE)..(msg_len + SIG_SIZE + DIGEST_BYTES)]; // LEGACY: this should just be based on key id. Again legacy code that could be done more proper by using the notion of an id! // Verify verification key digest - if serialize_hash_element(&DSEP_SIGNCRYPTION, server_verf_key) - .map_err(|e| CryptographyError::BincodeError(e.to_string()))? - != server_ver_key_digest - { + if sender_verf_key_digest(server_verf_key)? != server_ver_key_digest { return Err(CryptographyError::VerificationError(format!( "unexpected verification key digest {server_ver_key_digest:X?} was part of the decryption", ))); @@ -685,29 +865,12 @@ fn check_format_and_signature( unsigncryption_key: &UnifiedUnsigncryptionKey, ) -> Result<(), CryptographyError> { // What should be signed is dsep || msg || H(client_verification_key) || H(client_enc_key) - let serialized_enc_key = match &unsigncryption_key.encryption_key { - UnifiedPublicEncKey::MlKem512(public_enc_key) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) - .map_err(|e| CryptographyError::DeserializationError(e.to_string()))? - } - UnifiedPublicEncKey::MlKem1024(_) => { - return Err(CryptographyError::MlKem1024Unsupported); - } - UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) - .map_err(|e| CryptographyError::DeserializationError(e.to_string()))? - } - }; + let binding = receiver_binding( + unsigncryption_key.receiver_id, + unsigncryption_key.encryption_key, + )?; - let msg_signed = Zeroizing::new( - [ - &dsep[..], - msg, - unsigncryption_key.receiver_id, - &serialized_enc_key, - ] - .concat(), - ); + let msg_signed = Zeroizing::new([&dsep[..], msg, binding.as_slice()].concat()); check_normalized(sig)?; @@ -734,17 +897,7 @@ pub(crate) fn insecure_decrypt_ignoring_signature( // LEGACY should be using safe_deserialization from tfhe-rs let cipher: HybridKemCt = bc2wrap::deserialize_slice(cipher) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - let decrypted_plaintext = match dec_key { - UnifiedPrivateEncKey::MlKem512(dk) => { - hybrid_ml_kem::dec::(cipher.clone(), &dk.0)? - } - UnifiedPrivateEncKey::MlKem1024(_) => { - return Err(CryptographyError::MlKem1024Unsupported); - } - UnifiedPrivateEncKey::MlKem1024P384(dk) => { - hybrid_composite_ml_kem::dec_ml_kem_1024_p384(cipher, dk)? - } - }; + let decrypted_plaintext = hybrid_decrypt(cipher, dec_key)?; // strip off the signature bytes (these are ignored here) let msg_len = decrypted_plaintext.len() - DIGEST_BYTES - SIG_SIZE; @@ -1046,6 +1199,379 @@ mod tests { .unwrap_err(); } + // ============================================================================ + // Format locks and scheme-set plumbing + // ============================================================================ + + /// The two schemes the frozen layout is actually used with: ML-KEM-512 for + /// user decryption, MLKEM1024-P384 for custodian backup. + const LOCKED_SCHEMES: [PkeSchemeType; 2] = + [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384]; + + struct LockFixture { + rng: AesRng, + dec_key: UnifiedPrivateEncKey, + enc_key: UnifiedPublicEncKey, + sender_verf_key: PublicSigKey, + signing_key: PrivateSigKey, + receiver_id: Vec, + } + + fn lock_fixture(scheme: PkeSchemeType, seed: u64) -> LockFixture { + let mut rng = AesRng::seed_from_u64(seed); + let (sender_verf_key, signing_key) = gen_sig_keys(&mut rng); + let (receiver_verf_key, _) = gen_sig_keys(&mut rng); + // Scoped so that `rng` is no longer borrowed once the keys are out. + let (dec_key, enc_key) = { + let mut encryption = Encryption::new(scheme, &mut rng); + encryption.keygen().unwrap() + }; + LockFixture { + rng, + dec_key, + enc_key, + sender_verf_key, + signing_key, + receiver_id: receiver_verf_key.verf_key_id(), + } + } + + fn expected_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Vec { + match enc_key { + UnifiedPublicEncKey::MlKem512(inner) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() + } + UnifiedPublicEncKey::MlKem1024P384(inner) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() + } + _ => unreachable!("only the two locked schemes are exercised"), + } + } + + /// The signed preimage is exactly `dsep ‖ msg ‖ receiver_id ‖ H(enc key)`. + /// + /// Rebuilt here from the hashing primitive rather than from + /// [`receiver_binding`], so that a reordering of the concatenation is caught + /// at the preimage level instead of only by a whole-artifact comparison. + #[test] + fn ecdsa_v0_signed_preimage_is_locked() { + const DSEP: &DomainSep = b"ECDSAV0T"; + for scheme in LOCKED_SCHEMES { + let f = lock_fixture(scheme, 100); + let msg = b"the message a signcryption signs over"; + + let expected = [ + &DSEP[..], + msg.as_slice(), + f.receiver_id.as_slice(), + expected_enc_key_digest(&f.enc_key).as_slice(), + ] + .concat(); + + // What `inner_signcryption` signs: `dsep` (prepended by + // `internal_sign`) followed by the message and the receiver binding. + let binding = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); + let signed = [&DSEP[..], msg.as_slice(), binding.as_slice()].concat(); + assert_eq!(signed, expected, "{scheme}: signed preimage changed"); + + // ...and the verifier rebuilds exactly those bytes. + let sig = internal_sign( + DSEP, + &[msg.as_slice(), binding.as_slice()].concat(), + &f.signing_key, + ) + .unwrap(); + let unsign_key = UnifiedUnsigncryptionKey::new( + &f.dec_key, + &f.enc_key, + &f.sender_verf_key, + &f.receiver_id, + ); + check_format_and_signature(DSEP, msg, &sig, &unsign_key).unwrap(); + } + } + + /// The encrypted plaintext is exactly `msg ‖ sig(64) ‖ H(sender key)(32)`, + /// with the two tail fields fixed-size and in that order. + /// + /// This is the contract [`parse_msg`] relies on when it recovers `msg_len` + /// by subtracting from the end. Before this test, the layout was implied by + /// that arithmetic alone. + #[test] + fn ecdsa_v0_envelope_layout_is_locked() { + const DSEP: &DomainSep = b"ECDSAV0T"; + for scheme in LOCKED_SCHEMES { + let mut f = lock_fixture(scheme, 200); + let payload = TestType { i: 4711 }; + let signcrypt_key = + UnifiedSigncryptionKey::new(&f.signing_key, &f.enc_key, &f.receiver_id); + + let mut expected_msg = Vec::new(); + safe_serialize(&payload, &mut expected_msg, SAFE_SER_SIZE_LIMIT).unwrap(); + + let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); + assert_eq!(cipher.pke_type, scheme); + assert_eq!( + cipher.signing_schemes, + SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1) + ); + + let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); + let plaintext = hybrid_decrypt(kem_ct, &f.dec_key).unwrap(); + + // Exactly three fields, the last two of fixed size. + assert_eq!( + plaintext.len(), + expected_msg.len() + SIG_SIZE + DIGEST_BYTES, + "{scheme}: plaintext is not msg ‖ sig ‖ digest" + ); + let msg_len = expected_msg.len(); + assert_eq!(&plaintext[..msg_len], expected_msg.as_slice()); + + // The middle field is the ECDSA signature over the locked preimage. + let binding = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); + let signed = [expected_msg.as_slice(), binding.as_slice()].concat(); + let sig = Signature::from_ecdsa( + k256::ecdsa::Signature::from_slice(&plaintext[msg_len..msg_len + SIG_SIZE]) + .unwrap(), + ); + check_normalized(&sig).expect("the signature must be low-s normalized"); + f.sender_verf_key + .raw_verifying_key() + .verify( + &[&DSEP[..], signed.as_slice()].concat(), + &sig.ecdsa_sig().unwrap(), + ) + .expect("the middle field must sign the locked preimage"); + + // The tail field is the digest of the sender's verification key. + assert_eq!( + &plaintext[msg_len + SIG_SIZE..], + sender_verf_key_digest(&f.sender_verf_key) + .unwrap() + .as_slice(), + "{scheme}: tail is not H(sender verification key)" + ); + } + } + + /// The binding must separate recipients on *both* of its inputs, since it is + /// the only thing tying a signature to who may open it. + #[test] + fn receiver_binding_separates_recipients() { + let f = lock_fixture(PkeSchemeType::MlKem512, 500); + let other = lock_fixture(PkeSchemeType::MlKem512, 501); + + let base = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); + assert_eq!(base, receiver_binding(&f.receiver_id, &f.enc_key).unwrap()); + assert_ne!( + base, + receiver_binding(&other.receiver_id, &f.enc_key).unwrap() + ); + assert_ne!( + base, + receiver_binding(&f.receiver_id, &other.enc_key).unwrap() + ); + } + + /// Truncating the plaintext below the two fixed tail fields is a length + /// error, not a panic. The arithmetic in [`parse_msg`] is the only thing + /// standing between a short plaintext and an out-of-bounds slice. + #[test] + fn a_short_plaintext_is_a_length_error() { + let f = lock_fixture(PkeSchemeType::MlKem512, 400); + for len in 0..(SIG_SIZE + DIGEST_BYTES) { + let short = Zeroizing::new(vec![0u8; len]); + assert!( + matches!( + parse_msg(short, &f.sender_verf_key), + Err(CryptographyError::LengthError(_)) + ), + "a {len}-byte plaintext must be rejected as too short" + ); + } + } + + /// The singleton ECDSA set — and only it — selects the frozen layout. This + /// is what keeps user decryption on `EcdsaV0` without a dedicated branch. + #[test] + fn only_the_ecdsa_singleton_is_the_legacy_format() { + use strum::IntoEnumIterator; + + assert_eq!( + format_for(&SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1)), + SigncryptionFormat::EcdsaV0 + ); + + for scheme in SigningSchemeType::iter().filter(|s| *s != SigningSchemeType::Ecdsa256k1) { + assert_eq!( + format_for(&SigningSchemeSet::single(scheme)), + SigncryptionFormat::CompositeV1, + "{scheme} alone must not select the frozen ECDSA layout" + ); + } + + // Adding any scheme to ECDSA leaves the legacy format behind, which is + // what stops a composite signcryption being parsed as a legacy one. + assert_eq!( + format_for( + &SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]) + .unwrap() + ), + SigncryptionFormat::CompositeV1 + ); + } + + /// Everything produced by the pre-composite path must be tagged with the + /// singleton ECDSA set, because that is what keeps it on the frozen layout. + #[test] + fn the_legacy_path_produces_the_ecdsa_singleton() { + let (mut rng, keys) = test_setup(); + let expected = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); + + let cipher = keys + .signcrypt_key + .signcrypt(&mut rng, b"TESTTEST", &TestType { i: 7 }) + .unwrap(); + assert_eq!(cipher.signing_schemes, expected); + assert_eq!( + cipher.sole_signing_scheme(), + Some(SigningSchemeType::Ecdsa256k1) + ); + + let plaintext_cipher = keys + .signcrypt_key + .signcrypt_plaintext(&mut rng, b"TESTTEST", &[1], FheTypes::Bool, &[9u8; 4]) + .unwrap(); + assert_eq!(plaintext_cipher.signing_schemes, expected); + + assert_eq!(keys.signcrypt_key.reference().signing_schemes(), expected); + assert_eq!( + keys.unsigncryption_key.reference().signing_schemes(), + expected + ); + } + + /// A signcryption claiming a composite scheme set must be refused rather + /// than parsed as a legacy one. Until the composite envelope lands that is + /// an "unsupported format" error; once it lands, the composite opener takes + /// over and this must still never reach [`inner_unsigncrypt`]. + #[test] + fn a_composite_scheme_set_does_not_reach_the_legacy_opener() { + let (mut rng, keys) = test_setup(); + let mut cipher = keys + .signcrypt_key + .signcrypt(&mut rng, b"TESTTEST", &TestType { i: 11 }) + .unwrap(); + cipher.signing_schemes = + SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]) + .unwrap(); + + let err = keys + .unsigncryption_key + .unsigncrypt::(b"TESTTEST", &cipher) + .unwrap_err(); + assert!( + matches!(err, CryptographyError::UnsupportedSigncryptionFormat(_)), + "a composite tag must not be handled by the legacy opener: {err}" + ); + } + + /// Material written before composite signing must still read, and must mean + /// the same thing: one scheme, spelled as a set of one. + #[test] + fn v0_upgrades_to_the_singleton_set() { + use strum::IntoEnumIterator; + + for scheme in SigningSchemeType::iter() { + let v0 = UnifiedSigncryptionV0 { + payload: vec![1, 2, 3], + pke_type: PkeSchemeType::MlKem512, + signing_type: scheme, + }; + let upgraded = v0.clone().upgrade().unwrap(); + assert_eq!(upgraded.payload, v0.payload); + assert_eq!(upgraded.pke_type, v0.pke_type); + assert_eq!(upgraded.signing_schemes, SigningSchemeSet::single(scheme)); + assert_eq!(upgraded.sole_signing_scheme(), Some(scheme)); + + // The upgrade is exactly what the singleton constructor builds, so a + // regenerated artifact still compares equal to a frozen one. + assert_eq!( + upgraded, + UnifiedSigncryption::new(v0.payload, v0.pke_type, scheme) + ); + } + } + + /// Captures the frozen byte vectors the layout is locked against. + /// + /// Ignored by default because the constants below still have to be filled in + /// once, by hand: they are the output of the very code under test, so they + /// cannot be written before it has run. Run + /// + /// ```text + /// cargo test -p kms --lib \ + /// cryptography::signcryption::tests::ecdsa_v0_frozen_byte_vectors \ + /// -- --ignored --nocapture + /// ``` + /// + /// paste the printed literals into the constants, and delete the + /// `#[ignore]`. From then on this is the strongest guard here: it pins a + /// real ciphertext together with the key that opens it, so it proves current + /// code still *opens* material produced earlier. Every other test + /// establishes that only transitively, by regenerating and comparing. + #[test] + #[ignore = "golden vectors must be captured once; see the doc comment"] + fn ecdsa_v0_frozen_byte_vectors() { + const DSEP: &DomainSep = b"ECDSAV0T"; + // Hex of a `UnifiedSigncryption.payload` for ML-KEM-512, seed 200. + const FROZEN_MLKEM512: &str = ""; + // Hex of a `UnifiedSigncryption.payload` for MLKEM1024-P384, seed 200. + const FROZEN_MLKEM1024P384: &str = ""; + + for (scheme, frozen) in [ + (PkeSchemeType::MlKem512, FROZEN_MLKEM512), + (PkeSchemeType::MlKem1024P384, FROZEN_MLKEM1024P384), + ] { + let mut f = lock_fixture(scheme, 200); + let payload = TestType { i: 4711 }; + let signcrypt_key = + UnifiedSigncryptionKey::new(&f.signing_key, &f.enc_key, &f.receiver_id); + let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); + + assert!( + !frozen.is_empty(), + "{scheme}: paste this into the constant, then drop #[ignore]:\n{}", + hex::encode(&cipher.payload) + ); + + // The frozen ciphertext must still open under the same key... + let frozen_payload = hex::decode(frozen).expect("the constant must be valid hex"); + let unsign_key = UnifiedUnsigncryptionKey::new( + &f.dec_key, + &f.enc_key, + &f.sender_verf_key, + &f.receiver_id, + ); + let frozen_cipher = UnifiedSigncryption::new( + frozen_payload.clone(), + scheme, + SigningSchemeType::Ecdsa256k1, + ); + let opened: TestType = unsign_key + .unsigncrypt(DSEP, &frozen_cipher) + .expect("current code must still open the frozen ciphertext"); + assert_eq!(opened, payload, "{scheme}"); + + // ...and today's code must still produce it bit for bit. + assert_eq!( + cipher.payload, frozen_payload, + "{scheme}: the produced envelope no longer matches the frozen vector" + ); + } + } + // ============================================================================ // Backward Compatibility Tests for SigncryptionPayload // ============================================================================ diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 151157df13..e7efc90fb7 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -7,7 +7,12 @@ pub mod ecdsa; mod eddsa; pub mod identity; mod mldsa; +pub mod scheme_set; pub mod seed; +pub mod verf_key_set; + +pub use scheme_set::{SigningSchemeSet, SigningSchemeSetVersions}; +pub use verf_key_set::VerfKeySet; use alloy_primitives::Address; use ecdsa::Ecdsa256k1; @@ -89,6 +94,13 @@ pub enum SigningError { expected = SigningSchemeType::VARIANTS.join(", ") )] UnknownSchemeName(String), + /// A set of signing schemes was empty, which would make "every signature + /// verified" vacuously true. + #[error("a signing scheme set must name at least one scheme")] + EmptySchemeSet, + /// A set of signing schemes was not in its canonical encoding. + #[error("signing scheme set is not canonical: {0}")] + NonCanonicalSchemeSet(String), } /// Trait for any value that is tied to a concrete signature scheme. @@ -753,6 +765,39 @@ mod tests { ); } + /// Declaration order is ascending wire order. + /// + /// Two things depend on this and neither would fail loudly if it broke. + /// [`SigningSchemeSet`] treats "canonical" as ascending wire order, but + /// sorts with the derived [`Ord`], which follows declaration order; + /// so its digest walks the members in derived-`Ord` order + /// while the set it reports is ordered by wire value. If the two orders ever + /// disagreed, a key set's identifier would be computed over a different + /// ordering than the scheme set naming it, silently. + #[test] + fn declaration_order_is_wire_order() { + // `EnumIter` yields variants in declaration order, so the index is the + // position the derived `Ord` sorts by. + for (index, scheme) in SigningSchemeType::iter().enumerate() { + assert_eq!( + scheme.as_wire(), + index as i32, + "{scheme} is declared at position {index} but travels as {}", + scheme.as_wire() + ); + } + + // Stated once more as the property the rest of the crate relies on, + // so a failure names the consequence rather than an index mismatch. + let declared: Vec<_> = SigningSchemeType::iter().collect(); + let mut by_wire = declared.clone(); + by_wire.sort_by_key(|scheme| scheme.as_wire()); + assert_eq!( + declared, by_wire, + "sorting by wire value reorders the schemes" + ); + } + #[test] fn unified_verf_digest() { let mut rng = AesRng::seed_from_u64(505); diff --git a/core/service/src/cryptography/signing/scheme_set.rs b/core/service/src/cryptography/signing/scheme_set.rs new file mode 100644 index 0000000000..bb7b8a6409 --- /dev/null +++ b/core/service/src/cryptography/signing/scheme_set.rs @@ -0,0 +1,313 @@ +//! The set of signature schemes a composite signature is made under. +//! +//! A composite signature is only as strong as the *guarantee that every one of +//! its constituent signatures is present* hence this type needs to be persisted +//! as part of the signature or request, to avoid malleability attacks cutting +//! out one or more of the composite signature elements. + +use super::{SigningError, SigningSchemeType}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; +use tfhe_versionable::{Versionize, VersionsDispatch}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] +pub enum SigningSchemeSetVersions { + V0(SigningSchemeSet), +} + +/// A non-empty, ascending, duplicate-free set of signature schemes. +/// +/// # Canonical form +/// +/// The schemes are ordered by their wire discriminant and carry no duplicates. +/// The set is never empty. +/// +/// Ordering by wire discriminant is the same as ordering by the derived [`Ord`] +/// on [`SigningSchemeType`], which follows declaration order. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Versionize)] +#[versionize(SigningSchemeSetVersions)] +#[serde(transparent)] +pub struct SigningSchemeSet(Vec); + +impl SigningSchemeSet { + /// Creates a canonical set of `schemes`, sorting and de-duplicating the input. + /// + /// Errors when `schemes` is empty. + pub fn new(schemes: I) -> Result + where + I: IntoIterator, + { + let mut schemes: Vec = schemes.into_iter().collect(); + schemes.sort_unstable(); + schemes.dedup(); + if schemes.is_empty() { + return Err(SigningError::EmptySchemeSet); + } + Ok(Self(schemes)) + } + + /// The set containing `scheme` alone. + pub fn single(scheme: SigningSchemeType) -> Self { + Self(vec![scheme]) + } + + /// Accept `schemes` only if it is already in canonical form. + pub fn from_canonical(schemes: Vec) -> Result { + if schemes.is_empty() { + return Err(SigningError::EmptySchemeSet); + } + let canonical = Self::new(schemes.iter().copied())?; + if canonical.0 != schemes { + return Err(SigningError::NonCanonicalSchemeSet(format!( + "expected {canonical}, got {}", + Self::render(&schemes) + ))); + } + Ok(canonical) + } + + /// The schemes, in canonical order. + pub fn as_slice(&self) -> &[SigningSchemeType] { + &self.0 + } + + /// The schemes, in canonical order. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } + + /// How many schemes the set holds; never zero. + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether `scheme` is in the set. + pub fn contains(&self, scheme: SigningSchemeType) -> bool { + self.0.contains(&scheme) + } + + /// The single scheme this set holds, or `None` if it holds several. + pub fn sole(&self) -> Option { + match self.0.as_slice() { + [scheme] => Some(*scheme), + _ => None, + } + } + + /// The unambiguous byte encoding of this set, for use inside a signature's + /// or a digest's preimage. + pub fn canonical_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(4 + 4 * self.0.len()); + // The set is non-empty and bounded by the number of known schemes, so + // the cast cannot truncate. + out.extend_from_slice(&(self.0.len() as u32).to_le_bytes()); + for scheme in &self.0 { + out.extend_from_slice(&scheme.tag()); + } + out + } + + fn render(schemes: &[SigningSchemeType]) -> String { + schemes + .iter() + .map(|scheme| scheme.to_string()) + .collect::>() + .join(", ") + } +} + +impl fmt::Display for SigningSchemeSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{{{}}}", Self::render(&self.0)) + } +} + +impl<'de> Deserialize<'de> for SigningSchemeSet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let schemes = Vec::::deserialize(deserializer)?; + Self::from_canonical(schemes).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use strum::IntoEnumIterator; + + /// The set every pre-composite signature carries. + const ECDSA: SigningSchemeType = SigningSchemeType::Ecdsa256k1; + + #[test] + fn new_sorts_and_dedups() { + let set = SigningSchemeSet::new([ + SigningSchemeType::MlDsa87, + ECDSA, + SigningSchemeType::MlDsa87, + SigningSchemeType::Ed25519, + ]) + .unwrap(); + assert_eq!( + set.as_slice(), + [ + ECDSA, + SigningSchemeType::Ed25519, + SigningSchemeType::MlDsa87, + ] + .as_slice() + ); + assert_eq!(set.len(), 3); + } + + #[test] + fn empty_is_rejected() { + assert!(matches!( + SigningSchemeSet::new(std::iter::empty()), + Err(SigningError::EmptySchemeSet) + )); + assert!(matches!( + SigningSchemeSet::from_canonical(vec![]), + Err(SigningError::EmptySchemeSet) + )); + } + + /// Canonical order is the only accepted encoding. + #[test] + fn from_canonical_rejects_non_canonical() { + let reordered = vec![SigningSchemeType::MlDsa87, ECDSA]; + assert!(matches!( + SigningSchemeSet::from_canonical(reordered), + Err(SigningError::NonCanonicalSchemeSet(_)) + )); + + let duplicated = vec![ECDSA, ECDSA]; + assert!(matches!( + SigningSchemeSet::from_canonical(duplicated), + Err(SigningError::NonCanonicalSchemeSet(_)) + )); + + // ...and the canonica order is accepted. + SigningSchemeSet::from_canonical(vec![ECDSA, SigningSchemeType::MlDsa87]).unwrap(); + } + + /// A non-canonical set must not survive a serialization round-trip, because + /// that is the path an attacker-supplied encoding takes. + #[test] + fn deserialization_rejects_non_canonical() { + let canonical = SigningSchemeSet::new([ECDSA, SigningSchemeType::MlDsa87]).unwrap(); + let bytes = bc2wrap::serialize(&canonical).unwrap(); + let back: SigningSchemeSet = bc2wrap::deserialize_slice(&bytes).unwrap(); + assert_eq!(canonical, back); + + // Serialize the reversed list directly; it must not deserialize back. + let reversed = vec![SigningSchemeType::MlDsa87, ECDSA]; + let bytes = bc2wrap::serialize(&reversed).unwrap(); + assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); + } + + #[test] + fn sole_is_none_for_a_composite_set() { + assert_eq!(SigningSchemeSet::single(ECDSA).sole(), Some(ECDSA)); + assert_eq!( + SigningSchemeSet::new([ECDSA, SigningSchemeType::MlDsa87]) + .unwrap() + .sole(), + None + ); + } + + /// Distinct sets must not share a preimage, including the case where one is + /// a prefix of another — that is the collision a bare concatenation would + /// admit. + #[test] + fn canonical_bytes_separate_distinct_sets() { + let mut seen = std::collections::HashSet::new(); + for scheme in SigningSchemeType::iter() { + assert!( + seen.insert(SigningSchemeSet::single(scheme).canonical_bytes()), + "{scheme} shares a preimage with another set" + ); + } + let pair = SigningSchemeSet::new([ECDSA, SigningSchemeType::MlDsa87]).unwrap(); + assert!(seen.insert(pair.canonical_bytes())); + + // The length prefix is what separates a set from a longer one that + // starts with it. + let single = SigningSchemeSet::single(ECDSA); + assert!( + !pair + .canonical_bytes() + .starts_with(&single.canonical_bytes()) + ); + } + + /// Locks the serialized form of a [`SigningSchemeSet`]. + /// + /// The freeze-and-replay harness covers this type too + /// (`SigningSchemeSetTest`), but that catches a break only when the stored + /// fixtures are pulled and replayed. This runs in the unit suite and names + /// the exact bytes, so an encoding change is reported where it is made. The + /// same pairing exists for `SigncryptionPayload`, which has both a fixture + /// and `test_signcryption_payload_v0_serialization_locked`. + /// + /// The expected bytes follow `bc2wrap`'s bincode-v1 rules: a sequence length + /// as a little-endian `u64`, then each enum variant as a little-endian + /// `u32` discriminant. + /// + /// A change here breaks every signcryption already written under a + /// composite scheme set, so it must come with a new version of the + /// containing type rather than with an update to these bytes. + #[test] + fn versioned_encoding_is_locked() { + // One scheme: length 1, then discriminant 0 (Ecdsa256k1). + assert_eq!( + bc2wrap::serialize(&SigningSchemeSet::single(ECDSA)).unwrap(), + vec![ + 1, 0, 0, 0, 0, 0, 0, 0, // sequence length + 0, 0, 0, 0, // Ecdsa256k1 + ], + "LOCKED: the singleton encoding changed" + ); + + // Two schemes, in canonical order: discriminants 0 and 4. This is the + // pair the backward-compatibility fixture freezes. + assert_eq!( + bc2wrap::serialize( + &SigningSchemeSet::new([ECDSA, SigningSchemeType::MlDsa87]).unwrap() + ) + .unwrap(), + vec![ + 2, 0, 0, 0, 0, 0, 0, 0, // sequence length + 0, 0, 0, 0, // Ecdsa256k1 + 4, 0, 0, 0, // MlDsa87 + ], + "LOCKED: the composite encoding changed" + ); + + // The discriminants are the wire values, so the stored encoding cannot + // drift from what a peer of another release expects. + for scheme in SigningSchemeType::iter() { + let bytes = bc2wrap::serialize(&SigningSchemeSet::single(scheme)).unwrap(); + assert_eq!( + bytes[8..], + (scheme.as_wire() as u32).to_le_bytes(), + "{scheme} is not encoded as its wire discriminant" + ); + } + } + + #[test] + fn every_scheme_round_trips_as_a_singleton() { + for scheme in SigningSchemeType::iter() { + let set = SigningSchemeSet::single(scheme); + assert_eq!(set.sole(), Some(scheme)); + assert!(set.contains(scheme)); + let bytes = bc2wrap::serialize(&set).unwrap(); + let back: SigningSchemeSet = bc2wrap::deserialize_slice(&bytes).unwrap(); + assert_eq!(set, back); + } + } +} diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs new file mode 100644 index 0000000000..5a4a7f46a9 --- /dev/null +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -0,0 +1,195 @@ +//! The verification keys one party publishes, one per signature scheme. + +use super::identity::NodeSigningIdentity; +use super::scheme_set::SigningSchemeSet; +use super::{HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey}; +use hashing::{DomainSep, hash_element}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Domain separator for the digest that identifies a whole verification-key set. +const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; + +/// One party's verification keys, keyed by the scheme each belongs to. +/// +/// The counterpart of [`NodeSigningIdentity`] on the verifying side: an identity +/// signs under several schemes, so a verifier needs several keys, and needs them +/// to travel together. +/// +/// Not versioned since this is an in-memory only structure. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct VerfKeySet { + keys: BTreeMap, +} + +impl VerfKeySet { + /// A non-empty key set from `keys`, checking that each key belongs to the + /// scheme it is filed under. + pub fn new( + keys: BTreeMap, + ) -> Result { + if keys.is_empty() { + return Err(SigningError::EmptySchemeSet); + } + for (scheme, key) in &keys { + let actual = key.signing_scheme_type(); + if actual != *scheme { + return Err(SigningError::SchemeMismatch { + signature: *scheme, + key: actual, + }); + } + } + Ok(Self { keys }) + } + + /// The key set `identity` publishes for `schemes`. + pub fn from_identity( + identity: &NodeSigningIdentity, + schemes: &SigningSchemeSet, + ) -> Result { + let mut keys = BTreeMap::new(); + for scheme in schemes.iter() { + keys.insert(scheme, identity.unified_verifying_key(scheme)?); + } + Self::new(keys) + } + + /// The schemes this set holds keys for. + pub fn schemes(&self) -> Result { + SigningSchemeSet::new(self.keys.keys().copied()) + } + + /// The key for `scheme`, if the set holds one. + pub fn get(&self, scheme: SigningSchemeType) -> Option<&UnifiedPublicSigKey> { + self.keys.get(&scheme) + } + + /// The key for `scheme`, or an error naming the scheme that is missing. + pub fn require(&self, scheme: SigningSchemeType) -> Result<&UnifiedPublicSigKey, SigningError> { + self.get(scheme) + .ok_or(SigningError::NoVerificationKey(scheme)) + } + + /// The identifier of this key set. + /// + /// A digest over every member key, so changing, adding or removing one + /// changes the identity. + pub fn id(&self) -> Result, SigningError> { + Ok(hash_element(&DSEP_VERF_KEY_SET, &self.canonical_bytes()?)) + } + + /// The unambiguous byte encoding of this key set, for use inside a digest. + fn canonical_bytes(&self) -> Result, SigningError> { + let mut out = Vec::new(); + // Bounded by the number of known schemes, so the cast cannot truncate. + out.extend_from_slice(&(self.keys.len() as u32).to_le_bytes()); + // Note that this is deterministic based on the `Ord` implementation of SigningSchemeType. + for (scheme, key) in &self.keys { + out.extend_from_slice(&scheme.tag()); + let bytes = key.digest(); + out.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); + out.extend_from_slice(&bytes); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cryptography::signing::test_support::seeded_identity; + use aes_prng::AesRng; + use rand::SeedableRng; + use strum::IntoEnumIterator; + + #[test] + fn from_identity_covers_every_requested_scheme() { + let mut rng = AesRng::seed_from_u64(1); + let identity = seeded_identity(&mut rng); + let schemes = SigningSchemeSet::new(SigningSchemeType::iter()).unwrap(); + let set = VerfKeySet::from_identity(&identity, &schemes).unwrap(); + + assert_eq!(set.schemes().unwrap(), schemes); + for scheme in schemes.iter() { + let key = set.require(scheme).unwrap(); + assert_eq!(key.signing_scheme_type(), scheme); + assert_eq!(key, &identity.unified_verifying_key(scheme).unwrap()); + } + } + + /// A seedless identity can only do ECDSA, so asking it for more must fail + /// rather than yield a smaller set than requested. + #[test] + fn from_identity_fails_without_a_root_seed() { + let mut rng = AesRng::seed_from_u64(2); + let (_pk, sk) = crate::cryptography::signatures::gen_sig_keys(&mut rng); + let identity = NodeSigningIdentity::ecdsa_only(sk); + + VerfKeySet::from_identity( + &identity, + &SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + ) + .unwrap(); + + assert!(matches!( + VerfKeySet::from_identity( + &identity, + &SigningSchemeSet::new(SigningSchemeType::iter()).unwrap() + ), + Err(SigningError::MissingRootSeed(_)) + )); + } + + #[test] + fn a_key_filed_under_the_wrong_scheme_is_rejected() { + let mut rng = AesRng::seed_from_u64(3); + let identity = seeded_identity(&mut rng); + let ecdsa = identity + .unified_verifying_key(SigningSchemeType::Ecdsa256k1) + .unwrap(); + + let mut keys = BTreeMap::new(); + keys.insert(SigningSchemeType::MlDsa87, ecdsa); + assert!(matches!( + VerfKeySet::new(keys), + Err(SigningError::SchemeMismatch { .. }) + )); + + assert!(matches!( + VerfKeySet::new(BTreeMap::new()), + Err(SigningError::EmptySchemeSet) + )); + } + + /// The id names the whole set: swapping any single member key changes it. + #[test] + fn id_changes_when_any_member_key_changes() { + let mut rng = AesRng::seed_from_u64(4); + let identity = seeded_identity(&mut rng); + let other_identity = seeded_identity(&mut rng); + let schemes = SigningSchemeSet::new(SigningSchemeType::iter()).unwrap(); + + let base = VerfKeySet::from_identity(&identity, &schemes).unwrap(); + let base_id = base.id().unwrap(); + assert_eq!(base_id, base.id().unwrap(), "the id is not deterministic"); + + for scheme in schemes.iter() { + let mut swapped = base.keys.clone(); + swapped.insert(scheme, other_identity.unified_verifying_key(scheme).unwrap()); + let swapped = VerfKeySet::new(swapped).unwrap(); + assert_ne!( + base_id, + swapped.id().unwrap(), + "swapping the {scheme} key left the set id unchanged" + ); + } + + // Dropping a scheme is also a different identity, not a weaker form of + // the same one. + let mut fewer = base.keys.clone(); + fewer.remove(&SigningSchemeType::MlDsa87); + let fewer = VerfKeySet::new(fewer).unwrap(); + assert_ne!(base_id, fewer.id().unwrap()); + } +} diff --git a/core/service/src/engine/backup_operator.rs b/core/service/src/engine/backup_operator.rs index eacef82002..2485bb98e0 100644 --- a/core/service/src/engine/backup_operator.rs +++ b/core/service/src/engine/backup_operator.rs @@ -1820,11 +1820,11 @@ mod tests { InternalCustodianContext::new(custodian_context, enc_key.clone()).unwrap(); let mut cts = BTreeMap::new(); let cts_out = InnerOperatorBackupOutput { - signcryption: UnifiedSigncryption { - payload: vec![1, 2, 3], - pke_type: BACKUP_PKE_SCHEME, - signing_type: SigningSchemeType::Ecdsa256k1, - }, + signcryption: UnifiedSigncryption::new( + vec![1, 2, 3], + BACKUP_PKE_SCHEME, + SigningSchemeType::Ecdsa256k1, + ), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); cts.insert(Role::indexed_from_one(2), cts_out.clone()); diff --git a/core/service/src/vault/storage/mod.rs b/core/service/src/vault/storage/mod.rs index 19c250b051..affe175387 100644 --- a/core/service/src/vault/storage/mod.rs +++ b/core/service/src/vault/storage/mod.rs @@ -934,11 +934,11 @@ pub mod tests { }; let cts_out = InnerOperatorBackupOutput { - signcryption: UnifiedSigncryption { - payload: vec![1, 2, 3], - pke_type: BACKUP_PKE_SCHEME, - signing_type: SigningSchemeType::Ecdsa256k1, - }, + signcryption: UnifiedSigncryption::new( + vec![1, 2, 3], + BACKUP_PKE_SCHEME, + SigningSchemeType::Ecdsa256k1, + ), }; let mut cts = BTreeMap::new(); let mut commitments = BTreeMap::new(); diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index cdf117f7e9..1d9e327b4f 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -17,11 +17,11 @@ use backward_compatibility::{ MlKem1024P384PublicKeyTest, NodeInfoTest, OperatorBackupOutputTest, PrepKeygenSignedPayloadTest, PrivateSigKeyTest, PrssSetupCombinedTest, PublicDecSignedPayloadTest, PublicSigKeyTest, RecoveryValidationMaterialTest, - RootSigningSeedTest, SchemeDigestsTest, SigncryptionPayloadTest, SoftwareVersionTest, - StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataKMS, TestType, Testcase, - ThresholdFheKeysTest, TypedPlaintextTest, UnifiedCipherTest, UnifiedPublicSigKeyTest, - UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, UnifiedUnsigncryptionKeyTest, - UserDecSignedPayloadTest, data_dir, + RootSigningSeedTest, SchemeDigestsTest, SigncryptionPayloadTest, SigningSchemeSetTest, + SoftwareVersionTest, StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataKMS, + TestType, Testcase, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedCipherTest, + UnifiedPublicSigKeyTest, UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, + UnifiedUnsigncryptionKeyTest, UserDecSignedPayloadTest, data_dir, load::{DataFormat, TestFailure, TestResult, TestSuccess}, tests::{TestedModule, run_all_tests}, }; @@ -55,8 +55,8 @@ use kms_lib::{ }, hybrid_ml_kem::HybridKemCt, signatures::{ - NodeSigningIdentity, PrivateSigKey, PublicSigKey, RootSigningSeed, SigningSchemeType, - UnifiedPublicSigKey, compute_eip712_signature, gen_sig_keys, + NodeSigningIdentity, PrivateSigKey, PublicSigKey, RootSigningSeed, SigningSchemeSet, + SigningSchemeType, UnifiedPublicSigKey, compute_eip712_signature, gen_sig_keys, }, signcryption::{ Signcrypt, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, @@ -806,6 +806,35 @@ fn test_mlkem1024_p384_private_key( Ok(test.success(format)) } +/// A `SigningSchemeSet` written by an earlier release must still load, and must +/// still hold the schemes it was written with. +/// +/// The expected value is rebuilt from the wire discriminants in the test +/// metadata rather than from any constructor shortcut, so a reordering of +/// `SigningSchemeType` would be caught here rather than quietly reinterpreting +/// the stored bytes as a different set. +fn test_signing_scheme_set( + dir: &Path, + test: &SigningSchemeSetTest, + format: DataFormat, +) -> Result { + let original: SigningSchemeSet = load_and_unversionize(dir, test, format)?; + + let expected = SigningSchemeSet::new(test.schemes.iter().map(|wire| { + SigningSchemeType::try_from(*wire).expect("the test metadata names a known signing scheme") + })) + .expect("the test metadata names at least one scheme"); + + if original != expected { + Err(test.failure( + format!("Invalid SigningSchemeSet:\n Expected :\n{expected:?}\nGot:\n{original:?}"), + format, + )) + } else { + Ok(test.success(format)) + } +} + fn test_unified_signcryption( dir: &Path, test: &UnifiedSigncryptionTest, @@ -1817,6 +1846,9 @@ impl TestedModule for KMS { Self::Metadata::SigncryptionPayload(test) => { test_signcryption_payload(test_dir.as_ref(), test, format).into() } + Self::Metadata::SigningSchemeSet(test) => { + test_signing_scheme_set(test_dir.as_ref(), test, format).into() + } Self::Metadata::UnifiedSigncryptionKeyOwned(test) => { test_signcryption_keys(test_dir.as_ref(), test, format).into() } diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index 4d637e36aa..2b9309c3c6 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -53,9 +53,10 @@ const ALLOW_UNCOVERED: &[&str] = &[ // Field of UnifiedCipher. // Covered via UnifiedCipherTest. "PkeSchemeType", - // Field of UnifiedSigncryption. (PrivateSigKey / PublicSigKey expose it - // only via the HasSigningScheme trait method, not as a struct field.) - // Covered via UnifiedSigncryptionTest. + // Field of StoredTypedSignature, and of UnifiedSigncryptionV0. (PrivateSigKey / + // PublicSigKey expose it only via the HasSigningScheme trait method, not as + // a struct field.) + // Covered via StoredTypedSignatureTest, and via UnifiedSigncryptionTest. "SigningSchemeType", // Map value in RecoveryValidationMaterialPayload.cts. // Covered via RecoveryValidationMaterialTest. From d81115e499e42cac70a5e9bd239bc8598a4fc0f8 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 11:34:15 +0200 Subject: [PATCH 002/114] fix: prevent custodian downgrade attacks in backup --- ai-docs/ARCHITECTURE.md | 14 ++-- core/grpc/proto/kms-service.v1.proto | 1 + core/service/src/backup/custodian.rs | 112 ++++++++++++++++++++++++++- docs/guides/backup.md | 14 ++-- 4 files changed, 129 insertions(+), 12 deletions(-) diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index 4e23246281..edc45325d7 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -135,9 +135,10 @@ The service crate is the main surface area. Key subdirectories under chain verification). Custodian backup uses MLKEM1024-P384 for all three of its keypairs — the custodian's long-term key, the operator's ephemeral recovery key, and the operator's per-context backup vault key — selected in one place, - `backup::BACKUP_PKE_SCHEME`. Nothing rejects a peer that advertises a weaker - scheme: the signcryption carries its own `pke_type` tag, so a mixed-scheme - custodian context works. User decryption accepts ML-KEM-512 only. Randomly generated MLKEM1024-P384 + `backup::BACKUP_PKE_SCHEME`. A new custodian context is rejected unless every + custodian encryption key, and the operator's own backup key, uses that scheme + (`InternalCustodianContext::new` / `validated_nodes`). + User decryption accepts ML-KEM-512 only. Randomly generated MLKEM1024-P384 keypairs use a 256-bit-seeded CSPRNG. The custodian key derives directly from 256-bit mnemonic entropy. Signing lives under [cryptography/signing/](../core/service/src/cryptography/signing/): a scheme-tagged `Signature` plus one backend per scheme — ECDSA/secp256k1 @@ -351,13 +352,16 @@ in server config and unified behind `KeychainProxy` with this keychain already, and the keychain can only encrypt once that call has installed a context, so a node configured for it makes no backups until its first context exists. New custodian contexts are rejected unless every custodian - encryption key and every custodian verification key is unique. + encryption key and every custodian verification key is unique, and unless every + custodian encryption key uses `BACKUP_PKE_SCHEME`. Every key in this path is MLKEM1024-P384 (`backup::BACKUP_PKE_SCHEME`), and the custodian's is derived from 256 bits of seed-phrase entropy — a 24-word mnemonic — so the phrase does not cap the scheme's security level. A vault written under an older ML-KEM-512 context is not readable by a node holding a composite key, but each ciphertext carries its own `pke_type`, so a vault spanning both schemes - decrypts as long as the matching key is installed. + decrypts as long as the matching key is installed. That remains true for + material already written; what is refused is *creating* a new context under a + weaker scheme. Custodian workflows are driven through the [kms-custodian](../core/service/src/bin/kms-custodian.rs) CLI and the diff --git a/core/grpc/proto/kms-service.v1.proto b/core/grpc/proto/kms-service.v1.proto index ead22cd718..075af85328 100644 --- a/core/grpc/proto/kms-service.v1.proto +++ b/core/grpc/proto/kms-service.v1.proto @@ -474,6 +474,7 @@ service CoreServiceEndpoint { // * `context_id` in `new_context` must be present, valid, fresh and unique [`RequestId`]. // * `custodian_nodes` in `new_context` must be present and contain at least 2*`threshold` + 1 valid `CustodianSetupMessage`s. // * Every custodian encryption key and every custodian verification key must be unique within `new_context`. + // * Every custodian encryption key in `new_context` must use the backup encryption scheme (MLKEM1024-P384). // * `threshold` in `new_context` must be a positive integer satisfying 2*`threshold` < the amount of entries in `custodian_nodes`. // ## Post-condition: // * The `context_id` in `request` now represents a new custodian context. diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 99df6390dc..fc00e64691 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -1,6 +1,7 @@ +use crate::backup::BACKUP_PKE_SCHEME; use crate::backup::operator::DSEP_BACKUP_RECOVERY; use crate::cryptography::{ - encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}, + encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}, signatures::PrivateSigKey, signcryption::{ Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, @@ -35,6 +36,10 @@ const ERR_DUPLICATE_CUSTODIAN_ENCRYPTION_KEYS: &str = "Duplicate custodian encryption key found in custodian context"; const ERR_DUPLICATE_CUSTODIAN_VERIFICATION_KEYS: &str = "Duplicate custodian verification key found in custodian context"; +const ERR_WEAK_CUSTODIAN_ENCRYPTION_KEY: &str = + "Custodian encryption key does not use the backup encryption scheme"; +const ERR_WEAK_BACKUP_ENCRYPTION_KEY: &str = + "Backup encryption key does not use the backup encryption scheme"; #[derive(Clone, Serialize, Deserialize, VersionsDispatch)] pub enum InternalCustodianRecoveryOutputVersions { @@ -290,6 +295,20 @@ impl InternalCustodianContext { } let nodes = node_map.values().collect::>(); + for node in &nodes { + // Reject a custodian whose encryption key is weaker than the scheme + // this path is built around, rather than encrypting its share of the + // backup key under it anyway. + let scheme = node.public_enc_key.encryption_scheme_type(); + if scheme != BACKUP_PKE_SCHEME { + return Err(anyhow::anyhow!( + "{}: role {} published a {scheme} key, but {BACKUP_PKE_SCHEME} is required", + ERR_WEAK_CUSTODIAN_ENCRYPTION_KEY, + node.custodian_role, + )); + } + } + for (index, node) in nodes.iter().enumerate() { for previous_node in &nodes[..index] { if previous_node.public_enc_key == node.public_enc_key { @@ -318,6 +337,12 @@ impl InternalCustodianContext { custodian_context: CustodianContext, backup_enc_key: UnifiedPublicEncKey, ) -> anyhow::Result { + let backup_scheme = backup_enc_key.encryption_scheme_type(); + if backup_scheme != BACKUP_PKE_SCHEME { + return Err(anyhow::anyhow!( + "{ERR_WEAK_BACKUP_ENCRYPTION_KEY}: got {backup_scheme}, but {BACKUP_PKE_SCHEME} is required", + )); + } let node_map = Self::validated_nodes(&custodian_context)?; let context_id: RequestId = parse_optional_grpc_request_id( &custodian_context.custodian_context_id, @@ -529,7 +554,7 @@ mod tests { use super::*; use crate::backup::BACKUP_PKE_SCHEME; use crate::cryptography::{ - encryption::{Encryption, PkeScheme}, + encryption::{Encryption, PkeScheme, PkeSchemeType}, signatures::gen_sig_keys, }; use aes_prng::AesRng; @@ -706,6 +731,89 @@ mod tests { } } + /// A custodian that publishes a key weaker than [`BACKUP_PKE_SCHEME`] is + /// refused at context creation. + #[test] + fn custodian_encryption_key_below_the_backup_scheme_should_fail() { + let mut rng = AesRng::seed_from_u64(42); + let (_, backup_pk) = { + let mut enc = Encryption::new(BACKUP_PKE_SCHEME, &mut rng); + enc.keygen().unwrap() + }; + + let mut setup_messages = Vec::new(); + for role in 1..=3 { + // Role 2 publishes an ML-KEM-512 key; the others are correct, so the + // context fails on that role alone rather than on its overall shape. + let scheme = if role == 2 { + PkeSchemeType::MlKem512 + } else { + BACKUP_PKE_SCHEME + }; + let (_, public_enc_key) = { + let mut enc = Encryption::new(scheme, &mut rng); + enc.keygen().unwrap() + }; + let (public_verf_key, _) = gen_sig_keys(&mut rng); + setup_messages.push(InternalCustodianSetupMessage { + header: HEADER.to_string(), + custodian_role: Role::indexed_from_one(role), + name: format!("Custodian-{role}"), + random_value: [role as u8; 32], + timestamp: SystemTime::now(), + public_enc_key, + public_verf_key, + }); + } + + let context = CustodianContext { + custodian_nodes: setup_messages + .into_iter() + .map(|message| message.try_into().unwrap()) + .collect(), + custodian_context_id: None, + threshold: 1, + }; + + let error = InternalCustodianContext::new(context, backup_pk) + .expect_err("a custodian key below the backup scheme must be rejected"); + let error = error.to_string(); + assert!( + error.contains(ERR_WEAK_CUSTODIAN_ENCRYPTION_KEY), + "unexpected error: {error}" + ); + assert!( + error.contains("role 2"), + "the error does not name the role: {error}" + ); + } + + /// The operator's own backup key is checked too, so a future change that + /// threaded a weaker key through cannot produce a vault that looks healthy. + #[test] + fn backup_encryption_key_below_the_backup_scheme_should_fail() { + let mut rng = AesRng::seed_from_u64(43); + let (_, weak_backup_pk) = { + let mut enc = Encryption::new(PkeSchemeType::MlKem512, &mut rng); + enc.keygen().unwrap() + }; + + // The node list is irrelevant here: the backup key is checked before + // the nodes are, so this fails on the key rather than on the shape. + let context = CustodianContext { + custodian_nodes: vec![], + custodian_context_id: None, + threshold: 1, + }; + + let error = InternalCustodianContext::new(context, weak_backup_pk) + .expect_err("a backup key below the backup scheme must be rejected"); + assert!( + error.to_string().contains(ERR_WEAK_BACKUP_ENCRYPTION_KEY), + "unexpected error: {error}" + ); + } + #[test] fn internal_custodian_context_role_greater_than_nodes_should_fail() { let mut rng = AesRng::seed_from_u64(40); diff --git a/docs/guides/backup.md b/docs/guides/backup.md index 9cae019ab2..6196b64f68 100644 --- a/docs/guides/backup.md +++ b/docs/guides/backup.md @@ -99,11 +99,15 @@ The alternative backup mode — wrapping the same key under an AWS KMS CMK — i User decryption is unaffected by any of this and remains ML-KEM-512. -Nothing in the backup path rejects a peer that advertises a weaker public key -scheme. Every signcryption and ciphertext carries its own `pke_type` tag, so a -context whose custodians publish different schemes works. On the other hand, -there are no options to select a different scheme, so the backup process is -initiated using built-in tools from KMS, MLKEM1024-P384 will be used. +Creating a custodian context requires every custodian encryption key — and the +operator's own backup key — to use MLKEM1024-P384. Every signcryption and +ciphertext carries its own `pke_type` tag, so a context whose custodians +published different schemes would otherwise work. +The check is on creation only — an existing context is still readable, so a +weak backup never becomes an unrecoverable one. + +There are no options to select a different scheme: a backup initiated with the +built-in KMS tools uses MLKEM1024-P384. ### Parties From c04a0b4a221cedfb0dbbe68e9616ae0139d9c703 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 11:51:03 +0200 Subject: [PATCH 003/114] refactor: split into different signcryption modules --- .../src/cryptography/signcryption/common.rs | 220 +++++++ .../src/cryptography/signcryption/ecdsa_v0.rs | 409 ++++++++++++ .../{signcryption.rs => signcryption/mod.rs} | 597 +----------------- .../src/cryptography/signing/verf_key_set.rs | 5 +- 4 files changed, 653 insertions(+), 578 deletions(-) create mode 100644 core/service/src/cryptography/signcryption/common.rs create mode 100644 core/service/src/cryptography/signcryption/ecdsa_v0.rs rename core/service/src/cryptography/{signcryption.rs => signcryption/mod.rs} (61%) diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs new file mode 100644 index 0000000000..b8a1c3ee0f --- /dev/null +++ b/core/service/src/cryptography/signcryption/common.rs @@ -0,0 +1,220 @@ +//! Pieces shared by every signcryption envelope format. +//! +//! Signing and verifying have to build *identical* receiver bindings or nothing +//! verifies at all, and both formats encrypt the same way. Anything both +//! [`super::ecdsa_v0`] and a future composite format need lives here, so the two +//! cannot drift apart and neither has to copy it. + +use crate::cryptography::encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}; +use crate::cryptography::error::CryptographyError; +use crate::cryptography::hybrid_composite_ml_kem; +use crate::cryptography::hybrid_ml_kem::{self, HybridKemCt}; +use crate::cryptography::signatures::{SigningSchemeSet, SigningSchemeType}; +use hashing::{DomainSep, serialize_hash_element}; +use rand::{CryptoRng, RngCore}; +use zeroize::Zeroizing; + +pub(super) const DSEP_SIGNCRYPTION: DomainSep = *b"SIGNCRYP"; + +/// The layout of a signcryption's encrypted plaintext. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SigncryptionFormat { + /// `msg ‖ sig ‖ H(sender verification key)`, ECDSA only. Frozen; see the + /// module documentation. + EcdsaV0, + /// A self-describing, versioned, multi-signature envelope. + CompositeV1, +} + +/// The format a signcryption under `schemes` uses. +/// +/// A total function of the scheme set, deliberately. The alternative — sniffing +/// the decrypted bytes for a magic prefix — cannot work: an +/// [`SigncryptionFormat::EcdsaV0`] plaintext begins with attacker-influenced +/// message content and has no tag to find, so detection would be a heuristic, in +/// a parser sitting directly under a decryption key. +pub(super) fn format_for(schemes: &SigningSchemeSet) -> SigncryptionFormat { + if schemes.as_slice() == [SigningSchemeType::Ecdsa256k1].as_slice() { + SigncryptionFormat::EcdsaV0 + } else { + SigncryptionFormat::CompositeV1 + } +} + +/// The error for a scheme set no implemented envelope format covers. +pub(super) fn unsupported_format(schemes: &SigningSchemeSet) -> CryptographyError { + CryptographyError::UnsupportedSigncryptionFormat(schemes.to_string()) +} + +/// The digest of the receiver's public encryption key, as it appears in the +/// signed preimage. +fn receiver_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Result, CryptographyError> { + match enc_key { + UnifiedPublicEncKey::MlKem512(public_enc_key) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) + .map_err(|e| CryptographyError::DeserializationError(e.to_string())) + } + UnifiedPublicEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), + UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) + .map_err(|e| CryptographyError::DeserializationError(e.to_string())) + } + } +} + +/// `receiver_id ‖ H(receiver public encryption key)`: the suffix that binds a +/// signcryption to who it was made for. +pub(super) fn receiver_binding( + receiver_id: &[u8], + enc_key: &UnifiedPublicEncKey, +) -> Result, CryptographyError> { + Ok([receiver_id, receiver_enc_key_digest(enc_key)?.as_slice()].concat()) +} + +/// Encrypt `msg` under `enc_key` with the hybrid KEM/DEM matching its scheme. +pub(super) fn hybrid_encrypt( + rng: &mut (impl CryptoRng + RngCore), + msg: &[u8], + enc_key: &UnifiedPublicEncKey, +) -> Result { + match enc_key { + UnifiedPublicEncKey::MlKem512(public_enc_key) => { + hybrid_ml_kem::enc::(rng, msg, &public_enc_key.0) + } + UnifiedPublicEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), + UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { + hybrid_composite_ml_kem::enc_ml_kem_1024_p384(rng, msg, public_enc_key) + } + } +} + +/// Decrypt `ct` under `dec_key` with the hybrid KEM/DEM matching its scheme. +pub(super) fn hybrid_decrypt( + ct: HybridKemCt, + dec_key: &UnifiedPrivateEncKey, +) -> Result>, CryptographyError> { + match dec_key { + UnifiedPrivateEncKey::MlKem512(dec_key) => { + hybrid_ml_kem::dec::(ct, &dec_key.0) + } + UnifiedPrivateEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), + UnifiedPrivateEncKey::MlKem1024P384(dec_key) => { + hybrid_composite_ml_kem::dec_ml_kem_1024_p384(ct, dec_key) + } + } +} + +// Test scaffolding shared with `super::ecdsa_v0`, which locks the frozen layout +// against the same keys. Kept at module level rather than inside `tests` so both +// modules can reach it without either copying it. +#[cfg(test)] +use crate::cryptography::encryption::{Encryption, PkeScheme, PkeSchemeType}; +#[cfg(test)] +use crate::cryptography::signatures::{PrivateSigKey, PublicSigKey, gen_sig_keys}; +#[cfg(test)] +use aes_prng::AesRng; +#[cfg(test)] +use rand::SeedableRng; + +/// The two schemes the frozen layout is actually used with: ML-KEM-512 for +/// user decryption, MLKEM1024-P384 for custodian backup. +#[allow(dead_code)] +pub(super) const LOCKED_SCHEMES: [PkeSchemeType; 2] = + [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384]; + +#[allow(dead_code)] +pub(super) struct LockFixture { + rng: AesRng, + dec_key: UnifiedPrivateEncKey, + enc_key: UnifiedPublicEncKey, + sender_verf_key: PublicSigKey, + signing_key: PrivateSigKey, + receiver_id: Vec, +} + +#[allow(dead_code)] +pub(super) fn lock_fixture(scheme: PkeSchemeType, seed: u64) -> LockFixture { + let mut rng = AesRng::seed_from_u64(seed); + let (sender_verf_key, signing_key) = gen_sig_keys(&mut rng); + let (receiver_verf_key, _) = gen_sig_keys(&mut rng); + // Scoped so that `rng` is no longer borrowed once the keys are out. + let (dec_key, enc_key) = { + let mut encryption = Encryption::new(scheme, &mut rng); + encryption.keygen().unwrap() + }; + LockFixture { + rng, + dec_key, + enc_key, + sender_verf_key, + signing_key, + receiver_id: receiver_verf_key.verf_key_id(), + } +} + +#[allow(dead_code)] +pub(super) fn expected_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Vec { + match enc_key { + UnifiedPublicEncKey::MlKem512(inner) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() + } + UnifiedPublicEncKey::MlKem1024P384(inner) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() + } + _ => unreachable!("only the two locked schemes are exercised"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use strum::IntoEnumIterator; + /// The binding must separate recipients on *both* of its inputs, since it is + /// the only thing tying a signature to who may open it. + #[test] + fn receiver_binding_separates_recipients() { + let f = lock_fixture(PkeSchemeType::MlKem512, 500); + let other = lock_fixture(PkeSchemeType::MlKem512, 501); + + let base = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); + assert_eq!(base, receiver_binding(&f.receiver_id, &f.enc_key).unwrap()); + assert_ne!( + base, + receiver_binding(&other.receiver_id, &f.enc_key).unwrap() + ); + assert_ne!( + base, + receiver_binding(&f.receiver_id, &other.enc_key).unwrap() + ); + } + + /// The singleton ECDSA set — and only it — selects the frozen layout. This + /// is what keeps user decryption on `EcdsaV0` without a dedicated branch. + #[test] + fn only_the_ecdsa_singleton_is_the_legacy_format() { + use strum::IntoEnumIterator; + + assert_eq!( + format_for(&SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1)), + SigncryptionFormat::EcdsaV0 + ); + + for scheme in SigningSchemeType::iter().filter(|s| *s != SigningSchemeType::Ecdsa256k1) { + assert_eq!( + format_for(&SigningSchemeSet::single(scheme)), + SigncryptionFormat::CompositeV1, + "{scheme} alone must not select the frozen ECDSA layout" + ); + } + + // Adding any scheme to ECDSA leaves the legacy format behind, which is + // what stops a composite signcryption being parsed as a legacy one. + assert_eq!( + format_for( + &SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]) + .unwrap() + ), + SigncryptionFormat::CompositeV1 + ); + } +} diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs new file mode 100644 index 0000000000..3ef78d5cdf --- /dev/null +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -0,0 +1,409 @@ +//! The original, ECDSA-only signcryption envelope. **FROZEN.** +//! +//! Every user-decryption ciphertext produced since 0.11 uses this layout and the +//! deployed browser-side verifier parses it, so its bytes cannot change. See the +//! module documentation of [`super`] for the full list of what pins them. +//! +//! Only the layout lives here. The receiver binding and the KEM/DEM plumbing are +//! shared with every other format and live in [`super::common`]. + +use super::common::{DSEP_SIGNCRYPTION, hybrid_decrypt, hybrid_encrypt, receiver_binding}; +use super::{ + SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, +}; +use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey}; +use crate::cryptography::error::CryptographyError; +use crate::cryptography::hybrid_ml_kem::HybridKemCt; +use crate::cryptography::signatures::{ + HasSigningScheme, PublicSigKey, SIG_SIZE, Signature, check_normalized, internal_sign, +}; +use ::signature::Verifier; +use hashing::{DIGEST_BYTES, DomainSep, serialize_hash_element}; +use kms_grpc::kms::v1::TypedPlaintext; +use rand::{CryptoRng, RngCore}; +use zeroize::{Zeroize, Zeroizing}; + +/// The digest of the sender's verification key, as it appears in the encrypted +/// plaintext's tail. +/// +/// LEGACY: this is horrible! The receiver is bound into the signed preimage by +/// its *address*, but the sender is bound here by a digest of its serialized +/// key. This should be changed to use the notion of a key id. +fn sender_verf_key_digest(verf_key: &PublicSigKey) -> Result, CryptographyError> { + serialize_hash_element(&DSEP_SIGNCRYPTION, verf_key) + .map_err(|e| CryptographyError::DeserializationError(e.to_string())) +} + +// Implements the actual signcryption but without serialization +// +// This is the FROZEN `SigncryptionFormat::EcdsaV0` layout; see the module +// documentation for what depends on its bytes and on its RNG usage. +pub(super) fn inner_signcryption( + signcrypt_key: &UnifiedSigncryptionKey, + rng: &mut (impl CryptoRng + RngCore), + dsep: &DomainSep, + msg: &[u8], +) -> Result { + // Adds the hash digest of the receivers public encryption key to the message to sign + // Sign msg || H(client_verf_key) || H(client_pub_key) + // Note that H(client_verf_key) = client_address + // Only serialize the inner structure to ensure backwards compatibility!!! + let binding = receiver_binding(signcrypt_key.receiver_id, signcrypt_key.receiver_enc_key)?; + // Wipe the temporary signed message after signing. + let to_sign = Zeroizing::new([msg, binding.as_slice()].concat()); + let sig = internal_sign(dsep, &to_sign, signcrypt_key.signing_key) + .map_err(|e| CryptographyError::SigningError(e.to_string()))?; + + // Encrypt msg || sig || H(server_verification_key) || H(server_enc_pub_key) + // OBSERVE: serialization is simply r concatenated with s. That is NOT an Ethereum compatible + // signature since we preclude the v value. + // The verification key is serialized based on the SEC1 standard. + let verf_key_hash = sender_verf_key_digest(&PublicSigKey::from_sk(signcrypt_key.signing_key))?; + // Wipe the temporary encrypted message after encryption. + let to_encrypt = + Zeroizing::new([msg, sig.to_bytes().as_ref(), verf_key_hash.as_ref()].concat()); + + let ciphertext = hybrid_encrypt(rng, &to_encrypt, signcrypt_key.receiver_enc_key)?; + // LEGACY: approach to serialization + Ok(UnifiedSigncryption::new( + bc2wrap::serialize(&ciphertext) + .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, + signcrypt_key.encryption_scheme_type(), + signcrypt_key.signing_scheme_type(), + )) +} + +/// Implements the actual unsigncryption process, but without any deserialization +/// +/// This is the FROZEN `SigncryptionFormat::EcdsaV0` layout; see the module +/// documentation. +pub(super) fn inner_unsigncrypt( + unsign_key: &UnifiedUnsigncryptionKey, + dsep: &DomainSep, + cipher: &UnifiedSigncryption, +) -> Result>, CryptographyError> { + if cipher.pke_type != unsign_key.encryption_key.encryption_scheme_type() { + return Err(CryptographyError::VerificationError( + "encryption type of cipher does not match the decryption key type".to_string(), + )); + } + // LEGACY Code: should be using safe_deserialization from tfhe-rs + let deserialized_payload: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) + .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; + let decrypted_plaintext = hybrid_decrypt(deserialized_payload, unsign_key.decryption_key)?; + let (msg, sig) = parse_msg(decrypted_plaintext, unsign_key.sender_verf_key)?; + check_format_and_signature(dsep, &msg, &sig, unsign_key)?; + Ok(msg) +} + +/// Helper method for parsing a signcrypted message consisting of the _true_ msg || sig || +/// H(server_verification_key) +fn parse_msg( + decrypted_plaintext: Zeroizing>, + server_verf_key: &PublicSigKey, +) -> Result<(Zeroizing>, Signature), CryptographyError> { + // The plaintext contains msg || sig || H(server_verification_key) + let msg_len = decrypted_plaintext + .len() + .checked_sub(DIGEST_BYTES) + .and_then(|len| len.checked_sub(SIG_SIZE)) + .ok_or_else(|| + CryptographyError::LengthError( + format!("Message is too short ({} bytes) to contain sig || H(server_verification_key) ({} bytes) ", + decrypted_plaintext.len(), + DIGEST_BYTES + SIG_SIZE) + ) + )?; + let msg = &decrypted_plaintext[..msg_len]; + let sig_bytes = &decrypted_plaintext[msg_len..(msg_len + SIG_SIZE)]; + let server_ver_key_digest = + &decrypted_plaintext[(msg_len + SIG_SIZE)..(msg_len + SIG_SIZE + DIGEST_BYTES)]; + // LEGACY: this should just be based on key id. Again legacy code that could be done more proper by using the notion of an id! + // Verify verification key digest + if sender_verf_key_digest(server_verf_key)? != server_ver_key_digest { + return Err(CryptographyError::VerificationError(format!( + "unexpected verification key digest {server_ver_key_digest:X?} was part of the decryption", + ))); + } + let sig = k256::ecdsa::Signature::from_slice(sig_bytes) + .map_err(|e| CryptographyError::SerializationError(e.to_string()))?; + // Wipe the extracted message on drop. + Ok((Zeroizing::new(msg.to_vec()), Signature::from_ecdsa(sig))) +} + +/// Helper method for performing the necessary checks on a signcryption signature. +/// Returns true if the signature is ok and false otherwise +fn check_format_and_signature( + dsep: &DomainSep, + msg: &[u8], + sig: &Signature, + unsigncryption_key: &UnifiedUnsigncryptionKey, +) -> Result<(), CryptographyError> { + // What should be signed is dsep || msg || H(client_verification_key) || H(client_enc_key) + let binding = receiver_binding( + unsigncryption_key.receiver_id, + unsigncryption_key.encryption_key, + )?; + + let msg_signed = Zeroizing::new([&dsep[..], msg, binding.as_slice()].concat()); + + check_normalized(sig)?; + + unsigncryption_key + .sender_verf_key + .raw_verifying_key() + .verify( + &msg_signed, + &sig.ecdsa_sig() + .map_err(|e| CryptographyError::VerificationError(e.to_string()))?, + ) + .map_err(|e| CryptographyError::VerificationError(e.to_string())) +} + +/// Decrypt a signcrypted message and ignore the signature +/// +/// This function does *not* do any verification and is thus insecure and should be used only for +/// testing. +/// TODO hide behind flag for insecure function? +pub(crate) fn insecure_decrypt_ignoring_signature( + cipher: &[u8], + dec_key: &UnifiedPrivateEncKey, +) -> Result { + // LEGACY should be using safe_deserialization from tfhe-rs + let cipher: HybridKemCt = bc2wrap::deserialize_slice(cipher) + .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; + let decrypted_plaintext = hybrid_decrypt(cipher, dec_key)?; + + // strip off the signature bytes (these are ignored here) + let msg_len = decrypted_plaintext.len() - DIGEST_BYTES - SIG_SIZE; + let msg = &decrypted_plaintext[..msg_len]; + // LEGACY should be using safe_deserialization from tfhe-rs + let signcrypted_msg: SigncryptionPayload = bc2wrap::deserialize_slice(msg) + .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; + + Ok(signcrypted_msg.plaintext) +} + +#[cfg(test)] +mod tests { + use super::super::common::{LOCKED_SCHEMES, expected_enc_key_digest, lock_fixture}; + use super::super::{Signcrypt, Unsigncrypt}; + use super::*; + use crate::consts::SAFE_SER_SIZE_LIMIT; + use crate::cryptography::encryption::{PkeSchemeType, UnifiedPublicEncKey}; + use crate::cryptography::signatures::{SigningSchemeSet, SigningSchemeType, gen_sig_keys}; + use crate::vault::storage::tests::TestType; + use aes_prng::AesRng; + use rand::SeedableRng; + use tfhe::safe_serialization::safe_serialize; + + const DSEP: &DomainSep = b"ECDSAV0T"; + #[test] + fn incorrect_server_verf_key() { + let mut rng = AesRng::seed_from_u64(42); + let (server_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); + let to_encrypt = [0_u8; 1 + DIGEST_BYTES + SIG_SIZE]; + // Keep test input under the zeroizing ownership contract. + let res = parse_msg(Zeroizing::new(to_encrypt.to_vec()), &server_verf_key); + // unwrapping fails + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("unexpected verification key digest") + ); + } + + /// The signed preimage is exactly `dsep ‖ msg ‖ receiver_id ‖ H(enc key)`. + /// + /// Rebuilt here from the hashing primitive rather than from + /// [`receiver_binding`], so that a reordering of the concatenation is caught + /// at the preimage level instead of only by a whole-artifact comparison. + #[test] + fn ecdsa_v0_signed_preimage_is_locked() { + const DSEP: &DomainSep = b"ECDSAV0T"; + for scheme in LOCKED_SCHEMES { + let f = lock_fixture(scheme, 100); + let msg = b"the message a signcryption signs over"; + + let expected = [ + &DSEP[..], + msg.as_slice(), + f.receiver_id.as_slice(), + expected_enc_key_digest(&f.enc_key).as_slice(), + ] + .concat(); + + // What `inner_signcryption` signs: `dsep` (prepended by + // `internal_sign`) followed by the message and the receiver binding. + let binding = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); + let signed = [&DSEP[..], msg.as_slice(), binding.as_slice()].concat(); + assert_eq!(signed, expected, "{scheme}: signed preimage changed"); + + // ...and the verifier rebuilds exactly those bytes. + let sig = internal_sign( + DSEP, + &[msg.as_slice(), binding.as_slice()].concat(), + &f.signing_key, + ) + .unwrap(); + let unsign_key = UnifiedUnsigncryptionKey::new( + &f.dec_key, + &f.enc_key, + &f.sender_verf_key, + &f.receiver_id, + ); + check_format_and_signature(DSEP, msg, &sig, &unsign_key).unwrap(); + } + } + + /// The encrypted plaintext is exactly `msg ‖ sig(64) ‖ H(sender key)(32)`, + /// with the two tail fields fixed-size and in that order. + /// + /// This is the contract [`parse_msg`] relies on when it recovers `msg_len` + /// by subtracting from the end. Before this test, the layout was implied by + /// that arithmetic alone. + #[test] + fn ecdsa_v0_envelope_layout_is_locked() { + const DSEP: &DomainSep = b"ECDSAV0T"; + for scheme in LOCKED_SCHEMES { + let mut f = lock_fixture(scheme, 200); + let payload = TestType { i: 4711 }; + let signcrypt_key = + UnifiedSigncryptionKey::new(&f.signing_key, &f.enc_key, &f.receiver_id); + + let mut expected_msg = Vec::new(); + safe_serialize(&payload, &mut expected_msg, SAFE_SER_SIZE_LIMIT).unwrap(); + + let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); + assert_eq!(cipher.pke_type, scheme); + assert_eq!( + cipher.signing_schemes, + SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1) + ); + + let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); + let plaintext = hybrid_decrypt(kem_ct, &f.dec_key).unwrap(); + + // Exactly three fields, the last two of fixed size. + assert_eq!( + plaintext.len(), + expected_msg.len() + SIG_SIZE + DIGEST_BYTES, + "{scheme}: plaintext is not msg ‖ sig ‖ digest" + ); + let msg_len = expected_msg.len(); + assert_eq!(&plaintext[..msg_len], expected_msg.as_slice()); + + // The middle field is the ECDSA signature over the locked preimage. + let binding = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); + let signed = [expected_msg.as_slice(), binding.as_slice()].concat(); + let sig = Signature::from_ecdsa( + k256::ecdsa::Signature::from_slice(&plaintext[msg_len..msg_len + SIG_SIZE]) + .unwrap(), + ); + check_normalized(&sig).expect("the signature must be low-s normalized"); + f.sender_verf_key + .raw_verifying_key() + .verify( + &[&DSEP[..], signed.as_slice()].concat(), + &sig.ecdsa_sig().unwrap(), + ) + .expect("the middle field must sign the locked preimage"); + + // The tail field is the digest of the sender's verification key. + assert_eq!( + &plaintext[msg_len + SIG_SIZE..], + sender_verf_key_digest(&f.sender_verf_key) + .unwrap() + .as_slice(), + "{scheme}: tail is not H(sender verification key)" + ); + } + } + + /// Truncating the plaintext below the two fixed tail fields is a length + /// error, not a panic. The arithmetic in [`parse_msg`] is the only thing + /// standing between a short plaintext and an out-of-bounds slice. + #[test] + fn a_short_plaintext_is_a_length_error() { + let f = lock_fixture(PkeSchemeType::MlKem512, 400); + for len in 0..(SIG_SIZE + DIGEST_BYTES) { + let short = Zeroizing::new(vec![0u8; len]); + assert!( + matches!( + parse_msg(short, &f.sender_verf_key), + Err(CryptographyError::LengthError(_)) + ), + "a {len}-byte plaintext must be rejected as too short" + ); + } + } + + /// Captures the frozen byte vectors the layout is locked against. + /// + /// Ignored by default because the constants below still have to be filled in + /// once, by hand: they are the output of the very code under test, so they + /// cannot be written before it has run. Run + /// + /// ```text + /// cargo test -p kms --lib \ + /// cryptography::signcryption::tests::ecdsa_v0_frozen_byte_vectors \ + /// -- --ignored --nocapture + /// ``` + /// + /// paste the printed literals into the constants, and delete the + /// `#[ignore]`. From then on this is the strongest guard here: it pins a + /// real ciphertext together with the key that opens it, so it proves current + /// code still *opens* material produced earlier. Every other test + /// establishes that only transitively, by regenerating and comparing. + #[test] + #[ignore = "golden vectors must be captured once; see the doc comment"] + fn ecdsa_v0_frozen_byte_vectors() { + const DSEP: &DomainSep = b"ECDSAV0T"; + // Hex of a `UnifiedSigncryption.payload` for ML-KEM-512, seed 200. + const FROZEN_MLKEM512: &str = ""; + // Hex of a `UnifiedSigncryption.payload` for MLKEM1024-P384, seed 200. + const FROZEN_MLKEM1024P384: &str = ""; + + for (scheme, frozen) in [ + (PkeSchemeType::MlKem512, FROZEN_MLKEM512), + (PkeSchemeType::MlKem1024P384, FROZEN_MLKEM1024P384), + ] { + let mut f = lock_fixture(scheme, 200); + let payload = TestType { i: 4711 }; + let signcrypt_key = + UnifiedSigncryptionKey::new(&f.signing_key, &f.enc_key, &f.receiver_id); + let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); + + assert!( + !frozen.is_empty(), + "{scheme}: paste this into the constant, then drop #[ignore]:\n{}", + hex::encode(&cipher.payload) + ); + + // The frozen ciphertext must still open under the same key... + let frozen_payload = hex::decode(frozen).expect("the constant must be valid hex"); + let unsign_key = UnifiedUnsigncryptionKey::new( + &f.dec_key, + &f.enc_key, + &f.sender_verf_key, + &f.receiver_id, + ); + let frozen_cipher = UnifiedSigncryption::new( + frozen_payload.clone(), + scheme, + SigningSchemeType::Ecdsa256k1, + ); + let opened: TestType = unsign_key + .unsigncrypt(DSEP, &frozen_cipher) + .expect("current code must still open the frozen ciphertext"); + assert_eq!(opened, payload, "{scheme}"); + + // ...and today's code must still produce it bit for bit. + assert_eq!( + cipher.payload, frozen_payload, + "{scheme}: the produced envelope no longer matches the frozen vector" + ); + } + } +} diff --git a/core/service/src/cryptography/signcryption.rs b/core/service/src/cryptography/signcryption/mod.rs similarity index 61% rename from core/service/src/cryptography/signcryption.rs rename to core/service/src/cryptography/signcryption/mod.rs index f5ab5abc85..55555c7fdc 100644 --- a/core/service/src/cryptography/signcryption.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -39,20 +39,21 @@ //! the custodian-backup chain. Not implemented yet: asking for it is a //! [`CryptographyError::UnsupportedSigncryptionFormat`]. +mod common; +mod ecdsa_v0; + +pub(crate) use ecdsa_v0::insecure_decrypt_ignoring_signature; + use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::{ HasPkeScheme, PkeSchemeType, UnifiedPrivateEncKey, UnifiedPublicEncKey, }; use crate::cryptography::error::CryptographyError; -use crate::cryptography::hybrid_composite_ml_kem; -use crate::cryptography::hybrid_ml_kem::{self, HybridKemCt}; use crate::cryptography::signatures::{ - HasSigningScheme, PrivateSigKey, PublicSigKey, SIG_SIZE, Signature, SigningSchemeSet, - SigningSchemeType, check_normalized, internal_sign, + HasSigningScheme, PrivateSigKey, PublicSigKey, SigningSchemeSet, SigningSchemeType, }; use crate::cryptography::zeroizing_writer::ZeroizingWriter; -use ::signature::Verifier; -use hashing::{DIGEST_BYTES, DomainSep, serialize_hash_element}; +use hashing::DomainSep; use kms_grpc::kms::v1::TypedPlaintext; use rand::{CryptoRng, RngCore}; use serde::de::DeserializeOwned; @@ -62,8 +63,6 @@ use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; use tfhe_versionable::{Upgrade, Version, Versionize, VersionsDispatch}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; -const DSEP_SIGNCRYPTION: DomainSep = *b"SIGNCRYP"; - pub trait Signcrypt { /// Signcrypt a message of type T with a specified domain separator. fn signcrypt( @@ -392,36 +391,6 @@ impl UnifiedSigncryption { } } -/// The layout of a signcryption's encrypted plaintext. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SigncryptionFormat { - /// `msg ‖ sig ‖ H(sender verification key)`, ECDSA only. Frozen; see the - /// module documentation. - EcdsaV0, - /// A self-describing, versioned, multi-signature envelope. - CompositeV1, -} - -/// The format a signcryption under `schemes` uses. -/// -/// A total function of the scheme set, deliberately. The alternative — sniffing -/// the decrypted bytes for a magic prefix — cannot work: an -/// [`SigncryptionFormat::EcdsaV0`] plaintext begins with attacker-influenced -/// message content and has no tag to find, so detection would be a heuristic, in -/// a parser sitting directly under a decryption key. -fn format_for(schemes: &SigningSchemeSet) -> SigncryptionFormat { - if schemes.as_slice() == [SigningSchemeType::Ecdsa256k1].as_slice() { - SigncryptionFormat::EcdsaV0 - } else { - SigncryptionFormat::CompositeV1 - } -} - -/// The error for a scheme set no implemented envelope format covers. -fn unsupported_format(schemes: &SigningSchemeSet) -> CryptographyError { - CryptographyError::UnsupportedSigncryptionFormat(schemes.to_string()) -} - #[derive(Clone, Serialize, Deserialize, Hash, PartialEq, Eq, Debug, VersionsDispatch)] pub enum SigncryptionPayloadVersions { V0(SigncryptionPayload), @@ -520,11 +489,11 @@ impl<'a> Signcrypt for UnifiedSigncryptionKey<'a> { )) })?; let schemes = self.signing_schemes(); - match format_for(&schemes) { - SigncryptionFormat::EcdsaV0 => { - inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) + match common::format_for(&schemes) { + common::SigncryptionFormat::EcdsaV0 => { + ecdsa_v0::inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) } - SigncryptionFormat::CompositeV1 => Err(unsupported_format(&schemes)), + common::SigncryptionFormat::CompositeV1 => Err(common::unsupported_format(&schemes)), } } } @@ -570,7 +539,7 @@ impl<'a> SigncryptFHEPlaintext for UnifiedSigncryptionKey<'a> { // produces, `TypedSigncryptedCiphertext.signcrypted_ciphertext`, is a // bare `bytes` field with nowhere to record a format, so the only // layout its readers can parse is the frozen one. - inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) + ecdsa_v0::inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) } } @@ -588,123 +557,19 @@ impl SigncryptFHEPlaintext for UnifiedSigncryptionKeyOwned { } } -/// The digest of the receiver's public encryption key, as it appears in the -/// signed preimage. -fn receiver_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Result, CryptographyError> { - match enc_key { - UnifiedPublicEncKey::MlKem512(public_enc_key) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) - .map_err(|e| CryptographyError::DeserializationError(e.to_string())) - } - UnifiedPublicEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), - UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) - .map_err(|e| CryptographyError::DeserializationError(e.to_string())) - } - } -} - -/// `receiver_id ‖ H(receiver public encryption key)`: the suffix that binds a -/// signcryption to who it was made for. -fn receiver_binding( - receiver_id: &[u8], - enc_key: &UnifiedPublicEncKey, -) -> Result, CryptographyError> { - Ok([receiver_id, receiver_enc_key_digest(enc_key)?.as_slice()].concat()) -} - -/// The digest of the sender's verification key, as it appears in the encrypted -/// plaintext's tail. -/// -/// LEGACY: this is horrible! The receiver is bound into the signed preimage by -/// its *address*, but the sender is bound here by a digest of its serialized -/// key. This should be changed to use the notion of a key id. -fn sender_verf_key_digest(verf_key: &PublicSigKey) -> Result, CryptographyError> { - serialize_hash_element(&DSEP_SIGNCRYPTION, verf_key) - .map_err(|e| CryptographyError::DeserializationError(e.to_string())) -} - -/// Encrypt `msg` under `enc_key` with the hybrid KEM/DEM matching its scheme. -fn hybrid_encrypt( - rng: &mut (impl CryptoRng + RngCore), - msg: &[u8], - enc_key: &UnifiedPublicEncKey, -) -> Result { - match enc_key { - UnifiedPublicEncKey::MlKem512(public_enc_key) => { - hybrid_ml_kem::enc::(rng, msg, &public_enc_key.0) - } - UnifiedPublicEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), - UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { - hybrid_composite_ml_kem::enc_ml_kem_1024_p384(rng, msg, public_enc_key) - } - } -} - -/// Decrypt `ct` under `dec_key` with the hybrid KEM/DEM matching its scheme. -fn hybrid_decrypt( - ct: HybridKemCt, - dec_key: &UnifiedPrivateEncKey, -) -> Result>, CryptographyError> { - match dec_key { - UnifiedPrivateEncKey::MlKem512(dec_key) => { - hybrid_ml_kem::dec::(ct, &dec_key.0) - } - UnifiedPrivateEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), - UnifiedPrivateEncKey::MlKem1024P384(dec_key) => { - hybrid_composite_ml_kem::dec_ml_kem_1024_p384(ct, dec_key) - } - } -} - -// Implements the actual signcryption but without serialization -// -// This is the FROZEN `SigncryptionFormat::EcdsaV0` layout; see the module -// documentation for what depends on its bytes and on its RNG usage. -fn inner_signcryption( - signcrypt_key: &UnifiedSigncryptionKey, - rng: &mut (impl CryptoRng + RngCore), - dsep: &DomainSep, - msg: &[u8], -) -> Result { - // Adds the hash digest of the receivers public encryption key to the message to sign - // Sign msg || H(client_verf_key) || H(client_pub_key) - // Note that H(client_verf_key) = client_address - // Only serialize the inner structure to ensure backwards compatibility!!! - let binding = receiver_binding(signcrypt_key.receiver_id, signcrypt_key.receiver_enc_key)?; - // Wipe the temporary signed message after signing. - let to_sign = Zeroizing::new([msg, binding.as_slice()].concat()); - let sig = internal_sign(dsep, &to_sign, signcrypt_key.signing_key) - .map_err(|e| CryptographyError::SigningError(e.to_string()))?; - - // Encrypt msg || sig || H(server_verification_key) || H(server_enc_pub_key) - // OBSERVE: serialization is simply r concatenated with s. That is NOT an Ethereum compatible - // signature since we preclude the v value. - // The verification key is serialized based on the SEC1 standard. - let verf_key_hash = sender_verf_key_digest(&PublicSigKey::from_sk(signcrypt_key.signing_key))?; - // Wipe the temporary encrypted message after encryption. - let to_encrypt = - Zeroizing::new([msg, sig.to_bytes().as_ref(), verf_key_hash.as_ref()].concat()); - - let ciphertext = hybrid_encrypt(rng, &to_encrypt, signcrypt_key.receiver_enc_key)?; - // LEGACY: approach to serialization - Ok(UnifiedSigncryption::new( - bc2wrap::serialize(&ciphertext) - .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, - signcrypt_key.encryption_scheme_type(), - signcrypt_key.signing_scheme_type(), - )) -} - /// Open `cipher` in whichever format its scheme set names. fn open_dispatch( unsign_key: &UnifiedUnsigncryptionKey, dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { - match format_for(&cipher.signing_schemes) { - SigncryptionFormat::EcdsaV0 => inner_unsigncrypt(unsign_key, dsep, cipher), - SigncryptionFormat::CompositeV1 => Err(unsupported_format(&cipher.signing_schemes)), + match common::format_for(&cipher.signing_schemes) { + common::SigncryptionFormat::EcdsaV0 => { + ecdsa_v0::inner_unsigncrypt(unsign_key, dsep, cipher) + } + common::SigncryptionFormat::CompositeV1 => { + Err(common::unsupported_format(&cipher.signing_schemes)) + } } } @@ -770,7 +635,7 @@ impl<'a> UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKey<'a> { self.encryption_key.encryption_scheme_type(), self.sender_verf_key.signing_scheme_type(), ); - let decrypted_signcryption = inner_unsigncrypt(self, dsep, &parsed_signcryption)?; + let decrypted_signcryption = ecdsa_v0::inner_unsigncrypt(self, dsep, &parsed_signcryption)?; // LEGACY should be using safe_deserialization from tfhe-rs let mut signcrypted_msg: SigncryptionPayload = bc2wrap::deserialize_slice(&decrypted_signcryption) @@ -798,117 +663,6 @@ impl UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKeyOwned { } } -/// Implements the actual unsigncryption process, but without any deserialization -/// -/// This is the FROZEN `SigncryptionFormat::EcdsaV0` layout; see the module -/// documentation. -fn inner_unsigncrypt( - unsign_key: &UnifiedUnsigncryptionKey, - dsep: &DomainSep, - cipher: &UnifiedSigncryption, -) -> Result>, CryptographyError> { - if cipher.pke_type != unsign_key.encryption_key.encryption_scheme_type() { - return Err(CryptographyError::VerificationError( - "encryption type of cipher does not match the decryption key type".to_string(), - )); - } - // LEGACY Code: should be using safe_deserialization from tfhe-rs - let deserialized_payload: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) - .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - let decrypted_plaintext = hybrid_decrypt(deserialized_payload, unsign_key.decryption_key)?; - let (msg, sig) = parse_msg(decrypted_plaintext, unsign_key.sender_verf_key)?; - check_format_and_signature(dsep, &msg, &sig, unsign_key)?; - Ok(msg) -} - -/// Helper method for parsing a signcrypted message consisting of the _true_ msg || sig || -/// H(server_verification_key) -fn parse_msg( - decrypted_plaintext: Zeroizing>, - server_verf_key: &PublicSigKey, -) -> Result<(Zeroizing>, Signature), CryptographyError> { - // The plaintext contains msg || sig || H(server_verification_key) - let msg_len = decrypted_plaintext - .len() - .checked_sub(DIGEST_BYTES) - .and_then(|len| len.checked_sub(SIG_SIZE)) - .ok_or_else(|| - CryptographyError::LengthError( - format!("Message is too short ({} bytes) to contain sig || H(server_verification_key) ({} bytes) ", - decrypted_plaintext.len(), - DIGEST_BYTES + SIG_SIZE) - ) - )?; - let msg = &decrypted_plaintext[..msg_len]; - let sig_bytes = &decrypted_plaintext[msg_len..(msg_len + SIG_SIZE)]; - let server_ver_key_digest = - &decrypted_plaintext[(msg_len + SIG_SIZE)..(msg_len + SIG_SIZE + DIGEST_BYTES)]; - // LEGACY: this should just be based on key id. Again legacy code that could be done more proper by using the notion of an id! - // Verify verification key digest - if sender_verf_key_digest(server_verf_key)? != server_ver_key_digest { - return Err(CryptographyError::VerificationError(format!( - "unexpected verification key digest {server_ver_key_digest:X?} was part of the decryption", - ))); - } - let sig = k256::ecdsa::Signature::from_slice(sig_bytes) - .map_err(|e| CryptographyError::SerializationError(e.to_string()))?; - // Wipe the extracted message on drop. - Ok((Zeroizing::new(msg.to_vec()), Signature::from_ecdsa(sig))) -} - -/// Helper method for performing the necessary checks on a signcryption signature. -/// Returns true if the signature is ok and false otherwise -fn check_format_and_signature( - dsep: &DomainSep, - msg: &[u8], - sig: &Signature, - unsigncryption_key: &UnifiedUnsigncryptionKey, -) -> Result<(), CryptographyError> { - // What should be signed is dsep || msg || H(client_verification_key) || H(client_enc_key) - let binding = receiver_binding( - unsigncryption_key.receiver_id, - unsigncryption_key.encryption_key, - )?; - - let msg_signed = Zeroizing::new([&dsep[..], msg, binding.as_slice()].concat()); - - check_normalized(sig)?; - - unsigncryption_key - .sender_verf_key - .raw_verifying_key() - .verify( - &msg_signed, - &sig.ecdsa_sig() - .map_err(|e| CryptographyError::VerificationError(e.to_string()))?, - ) - .map_err(|e| CryptographyError::VerificationError(e.to_string())) -} - -/// Decrypt a signcrypted message and ignore the signature -/// -/// This function does *not* do any verification and is thus insecure and should be used only for -/// testing. -/// TODO hide behind flag for insecure function? -pub(crate) fn insecure_decrypt_ignoring_signature( - cipher: &[u8], - dec_key: &UnifiedPrivateEncKey, -) -> Result { - // LEGACY should be using safe_deserialization from tfhe-rs - let cipher: HybridKemCt = bc2wrap::deserialize_slice(cipher) - .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - let decrypted_plaintext = hybrid_decrypt(cipher, dec_key)?; - - // strip off the signature bytes (these are ignored here) - let msg_len = decrypted_plaintext.len() - DIGEST_BYTES - SIG_SIZE; - let msg = &decrypted_plaintext[..msg_len]; - // LEGACY should be using safe_deserialization from tfhe-rs - let signcrypted_msg: SigncryptionPayload = bc2wrap::deserialize_slice(msg) - .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - - Ok(signcrypted_msg.plaintext) -} - /// Helper method for what the client is supposed to do when generating ephemeral keys linked to the /// client's blockchain signing key #[cfg(test)] @@ -1168,22 +922,6 @@ mod tests { assert_eq!(msg, decrypted_msg); } - #[test] - fn incorrect_server_verf_key() { - let mut rng = AesRng::seed_from_u64(42); - let (server_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); - let to_encrypt = [0_u8; 1 + DIGEST_BYTES + SIG_SIZE]; - // Keep test input under the zeroizing ownership contract. - let res = parse_msg(Zeroizing::new(to_encrypt.to_vec()), &server_verf_key); - // unwrapping fails - assert!(res.is_err()); - assert!( - res.unwrap_err() - .to_string() - .contains("unexpected verification key digest") - ); - } - #[test] fn signcryption_with_bad_link() { let (mut rng, client_signcryption_keys) = test_setup(); @@ -1199,229 +937,6 @@ mod tests { .unwrap_err(); } - // ============================================================================ - // Format locks and scheme-set plumbing - // ============================================================================ - - /// The two schemes the frozen layout is actually used with: ML-KEM-512 for - /// user decryption, MLKEM1024-P384 for custodian backup. - const LOCKED_SCHEMES: [PkeSchemeType; 2] = - [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384]; - - struct LockFixture { - rng: AesRng, - dec_key: UnifiedPrivateEncKey, - enc_key: UnifiedPublicEncKey, - sender_verf_key: PublicSigKey, - signing_key: PrivateSigKey, - receiver_id: Vec, - } - - fn lock_fixture(scheme: PkeSchemeType, seed: u64) -> LockFixture { - let mut rng = AesRng::seed_from_u64(seed); - let (sender_verf_key, signing_key) = gen_sig_keys(&mut rng); - let (receiver_verf_key, _) = gen_sig_keys(&mut rng); - // Scoped so that `rng` is no longer borrowed once the keys are out. - let (dec_key, enc_key) = { - let mut encryption = Encryption::new(scheme, &mut rng); - encryption.keygen().unwrap() - }; - LockFixture { - rng, - dec_key, - enc_key, - sender_verf_key, - signing_key, - receiver_id: receiver_verf_key.verf_key_id(), - } - } - - fn expected_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Vec { - match enc_key { - UnifiedPublicEncKey::MlKem512(inner) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() - } - UnifiedPublicEncKey::MlKem1024P384(inner) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() - } - _ => unreachable!("only the two locked schemes are exercised"), - } - } - - /// The signed preimage is exactly `dsep ‖ msg ‖ receiver_id ‖ H(enc key)`. - /// - /// Rebuilt here from the hashing primitive rather than from - /// [`receiver_binding`], so that a reordering of the concatenation is caught - /// at the preimage level instead of only by a whole-artifact comparison. - #[test] - fn ecdsa_v0_signed_preimage_is_locked() { - const DSEP: &DomainSep = b"ECDSAV0T"; - for scheme in LOCKED_SCHEMES { - let f = lock_fixture(scheme, 100); - let msg = b"the message a signcryption signs over"; - - let expected = [ - &DSEP[..], - msg.as_slice(), - f.receiver_id.as_slice(), - expected_enc_key_digest(&f.enc_key).as_slice(), - ] - .concat(); - - // What `inner_signcryption` signs: `dsep` (prepended by - // `internal_sign`) followed by the message and the receiver binding. - let binding = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); - let signed = [&DSEP[..], msg.as_slice(), binding.as_slice()].concat(); - assert_eq!(signed, expected, "{scheme}: signed preimage changed"); - - // ...and the verifier rebuilds exactly those bytes. - let sig = internal_sign( - DSEP, - &[msg.as_slice(), binding.as_slice()].concat(), - &f.signing_key, - ) - .unwrap(); - let unsign_key = UnifiedUnsigncryptionKey::new( - &f.dec_key, - &f.enc_key, - &f.sender_verf_key, - &f.receiver_id, - ); - check_format_and_signature(DSEP, msg, &sig, &unsign_key).unwrap(); - } - } - - /// The encrypted plaintext is exactly `msg ‖ sig(64) ‖ H(sender key)(32)`, - /// with the two tail fields fixed-size and in that order. - /// - /// This is the contract [`parse_msg`] relies on when it recovers `msg_len` - /// by subtracting from the end. Before this test, the layout was implied by - /// that arithmetic alone. - #[test] - fn ecdsa_v0_envelope_layout_is_locked() { - const DSEP: &DomainSep = b"ECDSAV0T"; - for scheme in LOCKED_SCHEMES { - let mut f = lock_fixture(scheme, 200); - let payload = TestType { i: 4711 }; - let signcrypt_key = - UnifiedSigncryptionKey::new(&f.signing_key, &f.enc_key, &f.receiver_id); - - let mut expected_msg = Vec::new(); - safe_serialize(&payload, &mut expected_msg, SAFE_SER_SIZE_LIMIT).unwrap(); - - let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); - assert_eq!(cipher.pke_type, scheme); - assert_eq!( - cipher.signing_schemes, - SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1) - ); - - let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); - let plaintext = hybrid_decrypt(kem_ct, &f.dec_key).unwrap(); - - // Exactly three fields, the last two of fixed size. - assert_eq!( - plaintext.len(), - expected_msg.len() + SIG_SIZE + DIGEST_BYTES, - "{scheme}: plaintext is not msg ‖ sig ‖ digest" - ); - let msg_len = expected_msg.len(); - assert_eq!(&plaintext[..msg_len], expected_msg.as_slice()); - - // The middle field is the ECDSA signature over the locked preimage. - let binding = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); - let signed = [expected_msg.as_slice(), binding.as_slice()].concat(); - let sig = Signature::from_ecdsa( - k256::ecdsa::Signature::from_slice(&plaintext[msg_len..msg_len + SIG_SIZE]) - .unwrap(), - ); - check_normalized(&sig).expect("the signature must be low-s normalized"); - f.sender_verf_key - .raw_verifying_key() - .verify( - &[&DSEP[..], signed.as_slice()].concat(), - &sig.ecdsa_sig().unwrap(), - ) - .expect("the middle field must sign the locked preimage"); - - // The tail field is the digest of the sender's verification key. - assert_eq!( - &plaintext[msg_len + SIG_SIZE..], - sender_verf_key_digest(&f.sender_verf_key) - .unwrap() - .as_slice(), - "{scheme}: tail is not H(sender verification key)" - ); - } - } - - /// The binding must separate recipients on *both* of its inputs, since it is - /// the only thing tying a signature to who may open it. - #[test] - fn receiver_binding_separates_recipients() { - let f = lock_fixture(PkeSchemeType::MlKem512, 500); - let other = lock_fixture(PkeSchemeType::MlKem512, 501); - - let base = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); - assert_eq!(base, receiver_binding(&f.receiver_id, &f.enc_key).unwrap()); - assert_ne!( - base, - receiver_binding(&other.receiver_id, &f.enc_key).unwrap() - ); - assert_ne!( - base, - receiver_binding(&f.receiver_id, &other.enc_key).unwrap() - ); - } - - /// Truncating the plaintext below the two fixed tail fields is a length - /// error, not a panic. The arithmetic in [`parse_msg`] is the only thing - /// standing between a short plaintext and an out-of-bounds slice. - #[test] - fn a_short_plaintext_is_a_length_error() { - let f = lock_fixture(PkeSchemeType::MlKem512, 400); - for len in 0..(SIG_SIZE + DIGEST_BYTES) { - let short = Zeroizing::new(vec![0u8; len]); - assert!( - matches!( - parse_msg(short, &f.sender_verf_key), - Err(CryptographyError::LengthError(_)) - ), - "a {len}-byte plaintext must be rejected as too short" - ); - } - } - - /// The singleton ECDSA set — and only it — selects the frozen layout. This - /// is what keeps user decryption on `EcdsaV0` without a dedicated branch. - #[test] - fn only_the_ecdsa_singleton_is_the_legacy_format() { - use strum::IntoEnumIterator; - - assert_eq!( - format_for(&SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1)), - SigncryptionFormat::EcdsaV0 - ); - - for scheme in SigningSchemeType::iter().filter(|s| *s != SigningSchemeType::Ecdsa256k1) { - assert_eq!( - format_for(&SigningSchemeSet::single(scheme)), - SigncryptionFormat::CompositeV1, - "{scheme} alone must not select the frozen ECDSA layout" - ); - } - - // Adding any scheme to ECDSA leaves the legacy format behind, which is - // what stops a composite signcryption being parsed as a legacy one. - assert_eq!( - format_for( - &SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]) - .unwrap() - ), - SigncryptionFormat::CompositeV1 - ); - } - /// Everything produced by the pre-composite path must be tagged with the /// singleton ECDSA set, because that is what keeps it on the frozen layout. #[test] @@ -1504,78 +1019,6 @@ mod tests { } } - /// Captures the frozen byte vectors the layout is locked against. - /// - /// Ignored by default because the constants below still have to be filled in - /// once, by hand: they are the output of the very code under test, so they - /// cannot be written before it has run. Run - /// - /// ```text - /// cargo test -p kms --lib \ - /// cryptography::signcryption::tests::ecdsa_v0_frozen_byte_vectors \ - /// -- --ignored --nocapture - /// ``` - /// - /// paste the printed literals into the constants, and delete the - /// `#[ignore]`. From then on this is the strongest guard here: it pins a - /// real ciphertext together with the key that opens it, so it proves current - /// code still *opens* material produced earlier. Every other test - /// establishes that only transitively, by regenerating and comparing. - #[test] - #[ignore = "golden vectors must be captured once; see the doc comment"] - fn ecdsa_v0_frozen_byte_vectors() { - const DSEP: &DomainSep = b"ECDSAV0T"; - // Hex of a `UnifiedSigncryption.payload` for ML-KEM-512, seed 200. - const FROZEN_MLKEM512: &str = ""; - // Hex of a `UnifiedSigncryption.payload` for MLKEM1024-P384, seed 200. - const FROZEN_MLKEM1024P384: &str = ""; - - for (scheme, frozen) in [ - (PkeSchemeType::MlKem512, FROZEN_MLKEM512), - (PkeSchemeType::MlKem1024P384, FROZEN_MLKEM1024P384), - ] { - let mut f = lock_fixture(scheme, 200); - let payload = TestType { i: 4711 }; - let signcrypt_key = - UnifiedSigncryptionKey::new(&f.signing_key, &f.enc_key, &f.receiver_id); - let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); - - assert!( - !frozen.is_empty(), - "{scheme}: paste this into the constant, then drop #[ignore]:\n{}", - hex::encode(&cipher.payload) - ); - - // The frozen ciphertext must still open under the same key... - let frozen_payload = hex::decode(frozen).expect("the constant must be valid hex"); - let unsign_key = UnifiedUnsigncryptionKey::new( - &f.dec_key, - &f.enc_key, - &f.sender_verf_key, - &f.receiver_id, - ); - let frozen_cipher = UnifiedSigncryption::new( - frozen_payload.clone(), - scheme, - SigningSchemeType::Ecdsa256k1, - ); - let opened: TestType = unsign_key - .unsigncrypt(DSEP, &frozen_cipher) - .expect("current code must still open the frozen ciphertext"); - assert_eq!(opened, payload, "{scheme}"); - - // ...and today's code must still produce it bit for bit. - assert_eq!( - cipher.payload, frozen_payload, - "{scheme}: the produced envelope no longer matches the frozen vector" - ); - } - } - - // ============================================================================ - // Backward Compatibility Tests for SigncryptionPayload - // ============================================================================ - /// This test locks the binary serialization format of SigncryptionPayload. /// /// If this test fails, you have made a BREAKING CHANGE to SigncryptionPayload diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 5a4a7f46a9..03938b9600 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -176,7 +176,10 @@ mod tests { for scheme in schemes.iter() { let mut swapped = base.keys.clone(); - swapped.insert(scheme, other_identity.unified_verifying_key(scheme).unwrap()); + swapped.insert( + scheme, + other_identity.unified_verifying_key(scheme).unwrap(), + ); let swapped = VerfKeySet::new(swapped).unwrap(); assert_ne!( base_id, From dc91fe7ffc6332e654c54d295a32d58be7542216 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 12:08:16 +0200 Subject: [PATCH 004/114] fix: versioning issue --- core/service/src/cryptography/signatures.rs | 9 +++------ core/service/src/cryptography/signing/mod.rs | 2 +- .../src/cryptography/signing/scheme_set.rs | 17 +++++++++-------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index 679e5f850b..1cbbace464 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -17,11 +17,8 @@ pub use crate::cryptography::signing::ecdsa::{ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ - HasSigningScheme, SchemeVerfKeys, Signature, SigningError, SigningSchemeSet, - SigningSchemeSetVersions, SigningSchemeType, SigningSchemeTypeVersions, UnifiedPublicSigKey, - UnifiedPublicSigKeyVersions, VerfKeySet, + HasSigningScheme, SchemeVerfKeys, Signature, SigningError, SigningSchemeSet, SigningSchemeType, + SigningSchemeTypeVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, }; -pub(crate) use crate::cryptography::signing::ecdsa::{ - check_normalized, internal_sign, internal_verify_sig, -}; +pub(crate) use crate::cryptography::signing::ecdsa::{internal_sign, internal_verify_sig}; diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index e7efc90fb7..f21f58c1e4 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -11,7 +11,7 @@ pub mod scheme_set; pub mod seed; pub mod verf_key_set; -pub use scheme_set::{SigningSchemeSet, SigningSchemeSetVersions}; +pub use scheme_set::SigningSchemeSet; pub use verf_key_set::VerfKeySet; use alloy_primitives::Address; diff --git a/core/service/src/cryptography/signing/scheme_set.rs b/core/service/src/cryptography/signing/scheme_set.rs index bb7b8a6409..f320a28423 100644 --- a/core/service/src/cryptography/signing/scheme_set.rs +++ b/core/service/src/cryptography/signing/scheme_set.rs @@ -6,14 +6,9 @@ //! out one or more of the composite signature elements. use super::{SigningError, SigningSchemeType}; +use crate::impl_generic_versionize; use serde::{Deserialize, Deserializer, Serialize}; use std::fmt; -use tfhe_versionable::{Versionize, VersionsDispatch}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] -pub enum SigningSchemeSetVersions { - V0(SigningSchemeSet), -} /// A non-empty, ascending, duplicate-free set of signature schemes. /// @@ -24,11 +19,17 @@ pub enum SigningSchemeSetVersions { /// /// Ordering by wire discriminant is the same as ordering by the derived [`Ord`] /// on [`SigningSchemeType`], which follows declaration order. -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Versionize)] -#[versionize(SigningSchemeSetVersions)] +/// +/// # Versioning +/// +/// Versioned through [`impl_generic_versionize`] rather than a dispatch enum of +/// its own, because `Vec` is not `Versionize`. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] #[serde(transparent)] pub struct SigningSchemeSet(Vec); +impl_generic_versionize!(SigningSchemeSet); + impl SigningSchemeSet { /// Creates a canonical set of `schemes`, sorting and de-duplicating the input. /// From c9a723340ec3ab93eec32c5afd4366dbe9faf54d Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 12:26:19 +0200 Subject: [PATCH 005/114] fix: more fixes --- core/service/src/cryptography/signatures.rs | 4 +- .../src/cryptography/signcryption/common.rs | 41 ++++++++----------- .../src/cryptography/signcryption/ecdsa_v0.rs | 8 ++-- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index 1cbbace464..eb971a6a8f 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -21,4 +21,6 @@ pub use crate::cryptography::signing::{ SigningSchemeTypeVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, }; -pub(crate) use crate::cryptography::signing::ecdsa::{internal_sign, internal_verify_sig}; +pub(crate) use crate::cryptography::signing::ecdsa::{ + check_normalized, internal_sign, internal_verify_sig, +}; diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index b8a1c3ee0f..81d8f691d3 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -104,9 +104,22 @@ pub(super) fn hybrid_decrypt( } } +#[allow(dead_code)] +pub(super) fn expected_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Vec { + match enc_key { + UnifiedPublicEncKey::MlKem512(inner) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() + } + UnifiedPublicEncKey::MlKem1024P384(inner) => { + serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() + } + _ => unreachable!("only the two locked schemes are exercised"), + } +} + // Test scaffolding shared with `super::ecdsa_v0`, which locks the frozen layout -// against the same keys. Kept at module level rather than inside `tests` so both -// modules can reach it without either copying it. +// against the same keys. At module level rather than inside `tests` so that +// `ecdsa_v0`'s tests can reach it without a copy. #[cfg(test)] use crate::cryptography::encryption::{Encryption, PkeScheme, PkeSchemeType}; #[cfg(test)] @@ -116,13 +129,7 @@ use aes_prng::AesRng; #[cfg(test)] use rand::SeedableRng; -/// The two schemes the frozen layout is actually used with: ML-KEM-512 for -/// user decryption, MLKEM1024-P384 for custodian backup. -#[allow(dead_code)] -pub(super) const LOCKED_SCHEMES: [PkeSchemeType; 2] = - [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384]; - -#[allow(dead_code)] +#[cfg(test)] pub(super) struct LockFixture { rng: AesRng, dec_key: UnifiedPrivateEncKey, @@ -132,7 +139,7 @@ pub(super) struct LockFixture { receiver_id: Vec, } -#[allow(dead_code)] +#[cfg(test)] pub(super) fn lock_fixture(scheme: PkeSchemeType, seed: u64) -> LockFixture { let mut rng = AesRng::seed_from_u64(seed); let (sender_verf_key, signing_key) = gen_sig_keys(&mut rng); @@ -152,23 +159,11 @@ pub(super) fn lock_fixture(scheme: PkeSchemeType, seed: u64) -> LockFixture { } } -#[allow(dead_code)] -pub(super) fn expected_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Vec { - match enc_key { - UnifiedPublicEncKey::MlKem512(inner) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() - } - UnifiedPublicEncKey::MlKem1024P384(inner) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() - } - _ => unreachable!("only the two locked schemes are exercised"), - } -} - #[cfg(test)] mod tests { use super::*; use strum::IntoEnumIterator; + /// The binding must separate recipients on *both* of its inputs, since it is /// the only thing tying a signature to who may open it. #[test] diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 3ef78d5cdf..794f4e24e3 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -21,7 +21,7 @@ use ::signature::Verifier; use hashing::{DIGEST_BYTES, DomainSep, serialize_hash_element}; use kms_grpc::kms::v1::TypedPlaintext; use rand::{CryptoRng, RngCore}; -use zeroize::{Zeroize, Zeroizing}; +use zeroize::Zeroizing; /// The digest of the sender's verification key, as it appears in the encrypted /// plaintext's tail. @@ -186,7 +186,7 @@ pub(crate) fn insecure_decrypt_ignoring_signature( #[cfg(test)] mod tests { - use super::super::common::{LOCKED_SCHEMES, expected_enc_key_digest, lock_fixture}; + use super::super::common::{expected_enc_key_digest, lock_fixture}; use super::super::{Signcrypt, Unsigncrypt}; use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; @@ -222,7 +222,7 @@ mod tests { #[test] fn ecdsa_v0_signed_preimage_is_locked() { const DSEP: &DomainSep = b"ECDSAV0T"; - for scheme in LOCKED_SCHEMES { + for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { let f = lock_fixture(scheme, 100); let msg = b"the message a signcryption signs over"; @@ -266,7 +266,7 @@ mod tests { #[test] fn ecdsa_v0_envelope_layout_is_locked() { const DSEP: &DomainSep = b"ECDSAV0T"; - for scheme in LOCKED_SCHEMES { + for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { let mut f = lock_fixture(scheme, 200); let payload = TestType { i: 4711 }; let signcrypt_key = From 85bdd6be1b2012b52c722b2f5f605cc5938af956 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 13:33:52 +0200 Subject: [PATCH 006/114] fix: imports --- core-client/src/backup.rs | 13 ++------ .../src/cryptography/signcryption/common.rs | 13 ++++---- .../src/cryptography/signcryption/ecdsa_v0.rs | 2 +- .../tests/backward_compatibility_kms.rs | 30 +++++++++---------- 4 files changed, 25 insertions(+), 33 deletions(-) diff --git a/core-client/src/backup.rs b/core-client/src/backup.rs index 36bb875d1a..5f12ef77f4 100644 --- a/core-client/src/backup.rs +++ b/core-client/src/backup.rs @@ -7,7 +7,7 @@ use kms_grpc::{ kms::v1::{ CustodianContext, CustodianRecoveryInitRequest, CustodianRecoveryOutput, CustodianRecoveryRequest, DestroyCustodianContextRequest, Empty, - NewCustodianContextRequest, OperatorBackupOutput, + NewCustodianContextRequest, }, kms_service::v1::core_service_endpoint_client::CoreServiceEndpointClient, }; @@ -178,15 +178,8 @@ pub(crate) async fn do_custodian_backup_recovery( } let proto_outputs: Vec = custodian_recovery_outputs .into_iter() - .map(|out| CustodianRecoveryOutput { - backup_output: Some(OperatorBackupOutput { - signcryption: out.signcryption.payload, - pke_type: out.signcryption.pke_type as i32, - signing_type: out.signcryption.signing_type as i32, - }), - custodian_role: out.custodian_role.one_based() as u64, - }) - .collect(); + .map(CustodianRecoveryOutput::try_from) + .collect::>>()?; let mut req_tasks = JoinSet::new(); for ce in core_endpoints.values() { diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index 81d8f691d3..48421cfaa7 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -131,12 +131,12 @@ use rand::SeedableRng; #[cfg(test)] pub(super) struct LockFixture { - rng: AesRng, - dec_key: UnifiedPrivateEncKey, - enc_key: UnifiedPublicEncKey, - sender_verf_key: PublicSigKey, - signing_key: PrivateSigKey, - receiver_id: Vec, + pub(super) rng: AesRng, + pub(super) dec_key: UnifiedPrivateEncKey, + pub(super) enc_key: UnifiedPublicEncKey, + pub(super) sender_verf_key: PublicSigKey, + pub(super) signing_key: PrivateSigKey, + pub(super) receiver_id: Vec, } #[cfg(test)] @@ -162,7 +162,6 @@ pub(super) fn lock_fixture(scheme: PkeSchemeType, seed: u64) -> LockFixture { #[cfg(test)] mod tests { use super::*; - use strum::IntoEnumIterator; /// The binding must separate recipients on *both* of its inputs, since it is /// the only thing tying a signature to who may open it. diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 794f4e24e3..138f0b1dcb 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -190,7 +190,7 @@ mod tests { use super::super::{Signcrypt, Unsigncrypt}; use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; - use crate::cryptography::encryption::{PkeSchemeType, UnifiedPublicEncKey}; + use crate::cryptography::encryption::PkeSchemeType; use crate::cryptography::signatures::{SigningSchemeSet, SigningSchemeType, gen_sig_keys}; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index 1d9e327b4f..e21ea00674 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -1175,11 +1175,11 @@ fn test_recovery_material( let mut payload = [0_u8; 32]; rng.fill_bytes(&mut payload); let cts_out = InnerOperatorBackupOutput { - signcryption: UnifiedSigncryption { - payload: payload.to_vec(), - pke_type: BACKUP_PKE_SCHEME, - signing_type: SigningSchemeType::Ecdsa256k1, - }, + signcryption: UnifiedSigncryption::new( + payload.to_vec(), + BACKUP_PKE_SCHEME, + SigningSchemeType::Ecdsa256k1, + ), }; cts.insert(cus_role, cts_out.clone()); } @@ -1220,11 +1220,11 @@ fn test_internal_recovery_request( let cur_role = Role::indexed_from_one(role_j as usize); let mut payload = [0_u8; 32]; rng.fill_bytes(&mut payload); - let signcryption = UnifiedSigncryption { - payload: payload.to_vec(), - pke_type: BACKUP_PKE_SCHEME, - signing_type: SigningSchemeType::Ecdsa256k1, - }; + let signcryption = UnifiedSigncryption::new( + payload.to_vec(), + BACKUP_PKE_SCHEME, + SigningSchemeType::Ecdsa256k1, + ); cts.insert(cur_role, InnerOperatorBackupOutput { signcryption }); } let new_versionized = InternalRecoveryRequest::new(enc_key, verification_key, cts).unwrap(); @@ -1307,11 +1307,11 @@ fn test_internal_custodian_recovery_output( let mut rng = AesRng::seed_from_u64(test.state); let mut buf = [0u8; 100]; rng.fill_bytes(&mut buf); - let signcryption = UnifiedSigncryption { - payload: buf.to_vec(), - pke_type: BACKUP_PKE_SCHEME, - signing_type: SigningSchemeType::Ecdsa256k1, - }; + let signcryption = UnifiedSigncryption::new( + buf.to_vec(), + BACKUP_PKE_SCHEME, + SigningSchemeType::Ecdsa256k1, + ); let new_versionized = InternalCustodianRecoveryOutput { signcryption, From 8cac7f5ada0163a8b16c2e8d2c6c3e08a584b359 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 13:57:35 +0200 Subject: [PATCH 007/114] fix: lint --- core/service/src/cryptography/signcryption/ecdsa_v0.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 138f0b1dcb..5ca4c9a9f4 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -197,7 +197,6 @@ mod tests { use rand::SeedableRng; use tfhe::safe_serialization::safe_serialize; - const DSEP: &DomainSep = b"ECDSAV0T"; #[test] fn incorrect_server_verf_key() { let mut rng = AesRng::seed_from_u64(42); From 64063f5924981cd2e1979f1b336f9826f7f0730f Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 14:36:01 +0200 Subject: [PATCH 008/114] fix: bad merge --- core/service/src/backup/custodian.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index a34e999c75..7eb6c7fb80 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -1,3 +1,4 @@ +use crate::backup::BACKUP_PKE_SCHEME; use crate::backup::operator::DSEP_BACKUP_MATERIAL; use crate::cryptography::{ encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}, From 2f5147314564d773e7831d55325492604c015e0c Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 14:59:16 +0200 Subject: [PATCH 009/114] fix: backward tests --- .../internal_custodian_recovery_output.bcode | 4 ++-- .../kms/internal_recovery_request.bcode | 4 ++-- .../0_15_0/kms/operator_backup_output.bcode | 4 ++-- .../data/0_15_0/kms/recovery_material.bcode | 4 ++-- .../data/0_15_0/kms/signing_scheme_set.bcode | 3 +++ .../0_15_0/kms/unified_signcryption.bcode | 4 ++-- backward-compatibility/data/kms.ron | 11 +++++++++ .../generate-v0.15.0/Cargo.lock | 24 +++++++++---------- .../generate-v0.15.0/Cargo.toml | 16 ++++++------- .../generate-v0.15.0/src/data_0_15.rs | 9 ++++--- 10 files changed, 48 insertions(+), 35 deletions(-) create mode 100644 backward-compatibility/data/0_15_0/kms/signing_scheme_set.bcode diff --git a/backward-compatibility/data/0_15_0/kms/internal_custodian_recovery_output.bcode b/backward-compatibility/data/0_15_0/kms/internal_custodian_recovery_output.bcode index eddd4d5e3e..07be28e5a9 100644 --- a/backward-compatibility/data/0_15_0/kms/internal_custodian_recovery_output.bcode +++ b/backward-compatibility/data/0_15_0/kms/internal_custodian_recovery_output.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2218d1b8d39e31e0e2fe392ee97890da4fb008ec2ffacce21a5ceee5413fdc77 -size 144 +oid sha256:35ff134a4b07e9b1f04fe44e29c846de82c486380c5c65cc62484871b00838bd +size 148 diff --git a/backward-compatibility/data/0_15_0/kms/internal_recovery_request.bcode b/backward-compatibility/data/0_15_0/kms/internal_recovery_request.bcode index 795d81b624..6eeab97980 100644 --- a/backward-compatibility/data/0_15_0/kms/internal_recovery_request.bcode +++ b/backward-compatibility/data/0_15_0/kms/internal_recovery_request.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f29a31d11d5d1db30cc915c441e855b3df7e1cc2ef5b80cc4ecdf7ad26e6999 -size 2502 +oid sha256:b2ad2d4412629302157f9ebaf4b9cff51e25ab83eee01214b2d2d17135956c6e +size 2542 diff --git a/backward-compatibility/data/0_15_0/kms/operator_backup_output.bcode b/backward-compatibility/data/0_15_0/kms/operator_backup_output.bcode index 812232dc6e..f5a4be9ff3 100644 --- a/backward-compatibility/data/0_15_0/kms/operator_backup_output.bcode +++ b/backward-compatibility/data/0_15_0/kms/operator_backup_output.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15a83d608e05ec26ce6d67a7b3deb15138aa2d1aad336db70a7e465a96cab11d -size 2189 +oid sha256:7faf82f144ba9c4a55bf3366240a5ba6bee6d28769851a4f21df98092d933221 +size 2193 diff --git a/backward-compatibility/data/0_15_0/kms/recovery_material.bcode b/backward-compatibility/data/0_15_0/kms/recovery_material.bcode index 101cc0283c..a6ea5e4c1a 100644 --- a/backward-compatibility/data/0_15_0/kms/recovery_material.bcode +++ b/backward-compatibility/data/0_15_0/kms/recovery_material.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d03ccc249066f0edb90e8d9d68034e54ccf5c849d14839d5c2ab53b59875f05c -size 11716 +oid sha256:53da6c06104cf530009ff4cf69e4a8083ec71062532d5d55f56c08dca5fe0a8f +size 11736 diff --git a/backward-compatibility/data/0_15_0/kms/signing_scheme_set.bcode b/backward-compatibility/data/0_15_0/kms/signing_scheme_set.bcode new file mode 100644 index 0000000000..a08dd1bad5 --- /dev/null +++ b/backward-compatibility/data/0_15_0/kms/signing_scheme_set.bcode @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a77e66f46f595d4ab0f3e2dd152ca40a8692f04ce1a7cdc16c80a88615b05276 +size 16 diff --git a/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode b/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode index d266d880db..3eff3447dd 100644 --- a/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode +++ b/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cc25de14fd2b8ff3b269e9dfe391c5e7a9bb2a73849d3a38edff65af417cf167 -size 1027 +oid sha256:2de496fa1aa3f7dc42a9f2c5d52338e21666f4c571787c918e1b5135f10e38d3 +size 1031 diff --git a/backward-compatibility/data/kms.ron b/backward-compatibility/data/kms.ron index 0bf7539ccb..bb5938a836 100644 --- a/backward-compatibility/data/kms.ron +++ b/backward-compatibility/data/kms.ron @@ -1803,6 +1803,17 @@ state: 202, )), ), + ( + kms_core_version_min: "0.15.0", + kms_core_module: "kms", + metadata: SigningSchemeSet(( + test_filename: "signing_scheme_set", + schemes: [ + 0, + 4, + ], + )), + ), ( kms_core_version_min: "0.15.0", kms_core_module: "kms", diff --git a/backward-compatibility/generate-v0.15.0/Cargo.lock b/backward-compatibility/generate-v0.15.0/Cargo.lock index 4613e4903c..6e28263496 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.lock +++ b/backward-compatibility/generate-v0.15.0/Cargo.lock @@ -1832,7 +1832,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bc2wrap" version = "2.0.1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "bincode 2.0.1", "serde", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "error-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "anyhow", "tracing", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "kms" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "aes 0.9.3", "aes-gcm 0.11.1", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "kms-grpc" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -4633,7 +4633,7 @@ dependencies = [ [[package]] name = "observability" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "anyhow", "axum", @@ -6811,7 +6811,7 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "anyhow", "bc2wrap", @@ -6987,7 +6987,7 @@ dependencies = [ [[package]] name = "thread-handles" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "anyhow", "error-utils", @@ -7017,7 +7017,7 @@ dependencies = [ [[package]] name = "threshold-algebra" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "anyhow", "error-utils", @@ -7038,7 +7038,7 @@ dependencies = [ [[package]] name = "threshold-execution" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "aes 0.9.3", "aes-prng", @@ -7080,7 +7080,7 @@ dependencies = [ [[package]] name = "threshold-hashing" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "anyhow", "bc2wrap", @@ -7093,7 +7093,7 @@ dependencies = [ [[package]] name = "threshold-networking" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "anyhow", "async-trait", @@ -7130,7 +7130,7 @@ dependencies = [ [[package]] name = "threshold-types" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=cfab830dd2db0490aa3e0b81de37a1916152a200#cfab830dd2db0490aa3e0b81de37a1916152a200" +source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" dependencies = [ "anyhow", "async-trait", diff --git a/backward-compatibility/generate-v0.15.0/Cargo.toml b/backward-compatibility/generate-v0.15.0/Cargo.toml index 7cffbf059e..4220d0ca10 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.toml +++ b/backward-compatibility/generate-v0.15.0/Cargo.toml @@ -9,7 +9,7 @@ license = "BSD-3-Clause-Clear" [dependencies] # Import the base backward-compatibility crate for shared types and utilities backward-compatibility = { path = ".." } -bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "cfab830dd2db0490aa3e0b81de37a1916152a200" } +bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } # NOTE: Some dependencies below are duplicated from ../Cargo.toml # This is intentional - these crates are excluded from the workspace for isolation. @@ -19,13 +19,13 @@ bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev # NOTE: v0.15.0 is not tagged yet, so we pin a commit rev on `main`. Bump this rev to a # commit that contains every type this generator serializes, otherwise the generator will # not compile. -kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "cfab830dd2db0490aa3e0b81de37a1916152a200" } -kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "cfab830dd2db0490aa3e0b81de37a1916152a200" } -algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "cfab830dd2db0490aa3e0b81de37a1916152a200" } -threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "cfab830dd2db0490aa3e0b81de37a1916152a200", features = ["testing"] } -threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "cfab830dd2db0490aa3e0b81de37a1916152a200" } -threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "cfab830dd2db0490aa3e0b81de37a1916152a200" } -hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "cfab830dd2db0490aa3e0b81de37a1916152a200" } +kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } +kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } +algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } +threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f", features = ["testing"] } +threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } +threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } +hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } #error-utils_0_15_0 = { path = "./core/error-utils", default-features = false } #thread-handles_0_15_0 = { path = "./core/thread-handles" } diff --git a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs index 1386a4688c..c929e28ae1 100644 --- a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs +++ b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs @@ -11,13 +11,12 @@ use kms_0_15_0::backup::custodian::{ Custodian, CustodianContextAnchor, CustodianSetupMessagePayload, InternalCustodianContext, }; use kms_0_15_0::backup::{ - BACKUP_PKE_SCHEME, custodian::{InternalCustodianRecoveryOutput, InternalCustodianSetupMessage}, operator::{ BackupMaterial, InnerOperatorBackupOutput, InternalRecoveryRequest, Operator, RecoveryValidationMaterial, DSEP_BACKUP_COMMITMENT, }, - BackupCiphertext, + BackupCiphertext, BACKUP_PKE_SCHEME, }; use kms_0_15_0::consts::SAFE_SER_SIZE_LIMIT; use kms_0_15_0::cryptography::{ @@ -1448,7 +1447,7 @@ impl KmsV0_15_0 { signcryption: UnifiedSigncryption { payload: payload.to_vec(), pke_type: BACKUP_PKE_SCHEME, - signing_type: SigningSchemeType::Ecdsa256k1, + signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), }, }; cts.insert(cus_role, cts_out); @@ -1522,7 +1521,7 @@ impl KmsV0_15_0 { let signcryption = UnifiedSigncryption { payload: payload.to_vec(), pke_type: BACKUP_PKE_SCHEME, - signing_type: SigningSchemeType::Ecdsa256k1, + signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), }; cts.insert(cur_role, InnerOperatorBackupOutput { signcryption }); } @@ -1788,7 +1787,7 @@ impl KmsV0_15_0 { let signcryption = UnifiedSigncryption { payload: buf.to_vec(), pke_type: BACKUP_PKE_SCHEME, - signing_type: SigningSchemeType::Ecdsa256k1, + signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), }; let icro = InternalCustodianRecoveryOutput { signcryption, From b176712b5682a678dd9d0cb8eb3fcdad5a235761 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 15:09:06 +0200 Subject: [PATCH 010/114] refactor: simpified constructors --- core/service/src/backup/custodian.rs | 7 +++++-- core/service/src/backup/mod.rs | 5 +++-- core/service/src/backup/operator.rs | 6 +++--- .../src/cryptography/signcryption/ecdsa_v0.rs | 6 +++--- core/service/src/cryptography/signcryption/mod.rs | 15 +++------------ core/service/src/engine/backup_operator.rs | 4 ++-- core/service/src/vault/storage/mod.rs | 4 ++-- core/service/tests/backward_compatibility_kms.rs | 6 +++--- 8 files changed, 24 insertions(+), 29 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 7eb6c7fb80..dc907b664e 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -9,7 +9,10 @@ use crate::cryptography::{ }, }; use crate::engine::validation::{RequestIdParsingErr, parse_optional_grpc_request_id}; -use crate::{consts::SAFE_SER_SIZE_LIMIT, cryptography::signatures::PublicSigKey}; +use crate::{ + consts::SAFE_SER_SIZE_LIMIT, + cryptography::signatures::{PublicSigKey, SigningSchemeSet}, +}; use hashing::DomainSep; use kms_grpc::RequestId; use kms_grpc::kms::v1::{ @@ -76,7 +79,7 @@ impl TryFrom for InternalCustodianRecoveryOutput { signcryption: UnifiedSigncryption::new( backup_output.signcryption.clone(), backup_output.pke_type.try_into()?, - backup_output.signing_type.try_into()?, + SigningSchemeSet::single(backup_output.signing_type.try_into()?), ), custodian_role: Role::indexed_from_one(value.custodian_role as usize), }) diff --git a/core/service/src/backup/mod.rs b/core/service/src/backup/mod.rs index 5a9b5eea1b..84594995bb 100644 --- a/core/service/src/backup/mod.rs +++ b/core/service/src/backup/mod.rs @@ -8,6 +8,7 @@ pub mod error; pub mod operator; pub mod secretsharing; pub mod seed_phrase; +use crate::cryptography::signatures::SigningSchemeSet; use crate::cryptography::signcryption::UnifiedSigncryption; use kms_grpc::RequestId; use kms_grpc::kms::v1::OperatorBackupOutput; @@ -67,7 +68,7 @@ impl TryFrom for UnifiedSigncryption { Ok(UnifiedSigncryption::new( value.signcryption, pke_type, - signing_type, + SigningSchemeSet::single(signing_type), )) } } @@ -81,7 +82,7 @@ impl TryFrom<&OperatorBackupOutput> for UnifiedSigncryption { Ok(UnifiedSigncryption::new( value.signcryption.clone(), pke_type, - signing_type, + SigningSchemeSet::single(signing_type), )) } } diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index cf50869058..de5e3c0669 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -7,7 +7,7 @@ use crate::{ anyhow_error_and_log, consts::SAFE_SER_SIZE_LIMIT, cryptography::encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}, - cryptography::signatures::{PrivateSigKey, PublicSigKey, Signature}, + cryptography::signatures::{PrivateSigKey, PublicSigKey, Signature, SigningSchemeSet}, cryptography::signcryption::{ Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt, @@ -181,7 +181,7 @@ impl TryFrom for InnerOperatorBackupOutput { signcryption: UnifiedSigncryption::new( value.signcryption, value.pke_type.try_into()?, - value.signing_type.try_into()?, + SigningSchemeSet::single(value.signing_type.try_into()?), ), }) } @@ -994,7 +994,7 @@ mod tests { signcryption: UnifiedSigncryption::new( vec![1, 2, 3], BACKUP_PKE_SCHEME, - SigningSchemeType::Ecdsa256k1, + SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), ), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 5ca4c9a9f4..76c09a5d9f 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -15,7 +15,7 @@ use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey}; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; use crate::cryptography::signatures::{ - HasSigningScheme, PublicSigKey, SIG_SIZE, Signature, check_normalized, internal_sign, + PublicSigKey, SIG_SIZE, Signature, check_normalized, internal_sign, }; use ::signature::Verifier; use hashing::{DIGEST_BYTES, DomainSep, serialize_hash_element}; @@ -69,7 +69,7 @@ pub(super) fn inner_signcryption( bc2wrap::serialize(&ciphertext) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, signcrypt_key.encryption_scheme_type(), - signcrypt_key.signing_scheme_type(), + signcrypt_key.signing_schemes(), )) } @@ -391,7 +391,7 @@ mod tests { let frozen_cipher = UnifiedSigncryption::new( frozen_payload.clone(), scheme, - SigningSchemeType::Ecdsa256k1, + SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), ); let opened: TestType = unsign_key .unsigncrypt(DSEP, &frozen_cipher) diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 2e3cd65501..397cf43ee1 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -363,17 +363,8 @@ pub struct UnifiedSigncryption { pub signing_schemes: SigningSchemeSet, } impl UnifiedSigncryption { - /// A signcryption protected by the single scheme `signing_type`. - pub fn new(payload: Vec, pke_type: PkeSchemeType, signing_type: SigningSchemeType) -> Self { - Self { - payload, - pke_type, - signing_schemes: SigningSchemeSet::single(signing_type), - } - } - /// A signcryption protected by every scheme in `signing_schemes`. - pub fn new_multi( + pub fn new( payload: Vec, pke_type: PkeSchemeType, signing_schemes: SigningSchemeSet, @@ -633,7 +624,7 @@ impl<'a> UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKey<'a> { let parsed_signcryption = UnifiedSigncryption::new( signcryption.to_owned(), self.encryption_key.encryption_scheme_type(), - self.sender_verf_key.signing_scheme_type(), + self.signing_schemes(), ); let decrypted_signcryption = ecdsa_v0::inner_unsigncrypt(self, dsep, &parsed_signcryption)?; // LEGACY should be using safe_deserialization from tfhe-rs @@ -1014,7 +1005,7 @@ mod tests { // regenerated artifact still compares equal to a frozen one. assert_eq!( upgraded, - UnifiedSigncryption::new(v0.payload, v0.pke_type, scheme) + UnifiedSigncryption::new(v0.payload, v0.pke_type, SigningSchemeSet::single(scheme)) ); } } diff --git a/core/service/src/engine/backup_operator.rs b/core/service/src/engine/backup_operator.rs index 2485bb98e0..87b7390394 100644 --- a/core/service/src/engine/backup_operator.rs +++ b/core/service/src/engine/backup_operator.rs @@ -1455,7 +1455,7 @@ mod tests { use crate::{ backup::custodian::{CustodianSetupMessagePayload, HEADER, InternalCustodianContext}, cryptography::{ - signatures::{SigningSchemeType, gen_sig_keys}, + signatures::{SigningSchemeSet, SigningSchemeType, gen_sig_keys}, signcryption::UnifiedSigncryption, }, engine::base::derive_request_id, @@ -1823,7 +1823,7 @@ mod tests { signcryption: UnifiedSigncryption::new( vec![1, 2, 3], BACKUP_PKE_SCHEME, - SigningSchemeType::Ecdsa256k1, + SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), ), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); diff --git a/core/service/src/vault/storage/mod.rs b/core/service/src/vault/storage/mod.rs index d32533a213..f52879481d 100644 --- a/core/service/src/vault/storage/mod.rs +++ b/core/service/src/vault/storage/mod.rs @@ -858,7 +858,7 @@ pub mod tests { consts::{DEFAULT_MPC_CONTEXT, SAFE_SER_SIZE_LIMIT}, cryptography::{ encryption::{Encryption, PkeScheme, UnifiedPublicEncKey}, - signatures::{PrivateSigKey, SigningSchemeType, gen_sig_keys}, + signatures::{PrivateSigKey, SigningSchemeSet, SigningSchemeType, gen_sig_keys}, signcryption::UnifiedSigncryption, }, }; @@ -936,7 +936,7 @@ pub mod tests { signcryption: UnifiedSigncryption::new( vec![1, 2, 3], BACKUP_PKE_SCHEME, - SigningSchemeType::Ecdsa256k1, + SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), ), }; let mut cts = BTreeMap::new(); diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index e21ea00674..623826ac4a 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -1178,7 +1178,7 @@ fn test_recovery_material( signcryption: UnifiedSigncryption::new( payload.to_vec(), BACKUP_PKE_SCHEME, - SigningSchemeType::Ecdsa256k1, + SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), ), }; cts.insert(cus_role, cts_out.clone()); @@ -1223,7 +1223,7 @@ fn test_internal_recovery_request( let signcryption = UnifiedSigncryption::new( payload.to_vec(), BACKUP_PKE_SCHEME, - SigningSchemeType::Ecdsa256k1, + SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), ); cts.insert(cur_role, InnerOperatorBackupOutput { signcryption }); } @@ -1310,7 +1310,7 @@ fn test_internal_custodian_recovery_output( let signcryption = UnifiedSigncryption::new( buf.to_vec(), BACKUP_PKE_SCHEME, - SigningSchemeType::Ecdsa256k1, + SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), ); let new_versionized = InternalCustodianRecoveryOutput { From e19a6a14c1acfd5d8c2600bdb8db0aca77abef6e Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 16:25:40 +0200 Subject: [PATCH 011/114] refactor: removing redundant code --- core/service/src/cryptography/signatures.rs | 5 +- .../cryptography/signcryption/composite_v1.rs | 476 ++++++++++++++++++ .../src/cryptography/signcryption/mod.rs | 2 + .../src/cryptography/signing/composite.rs | 262 ++++++++++ core/service/src/cryptography/signing/mod.rs | 20 + 5 files changed, 763 insertions(+), 2 deletions(-) create mode 100644 core/service/src/cryptography/signcryption/composite_v1.rs create mode 100644 core/service/src/cryptography/signing/composite.rs diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index eb971a6a8f..bc80035064 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -17,8 +17,9 @@ pub use crate::cryptography::signing::ecdsa::{ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ - HasSigningScheme, SchemeVerfKeys, Signature, SigningError, SigningSchemeSet, SigningSchemeType, - SigningSchemeTypeVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, + CompositeSignature, CompositeSignatureVersions, HasSigningScheme, SchemeVerfKeys, Signature, + SigningError, SigningSchemeSet, SigningSchemeType, SigningSchemeTypeVersions, + UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, }; pub(crate) use crate::cryptography::signing::ecdsa::{ diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs new file mode 100644 index 0000000000..6c75f8352a --- /dev/null +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -0,0 +1,476 @@ +//! The composite signcryption envelope: one signature per scheme. + +use super::common::{ + SigncryptionFormat, format_for, hybrid_decrypt, hybrid_encrypt, receiver_binding, + unsupported_format, +}; +use super::{Signcrypt, UnifiedSigncryption, Unsigncrypt}; +use crate::consts::SAFE_SER_SIZE_LIMIT; +use crate::cryptography::encryption::{ + HasPkeScheme, PkeSchemeType, UnifiedPrivateEncKey, UnifiedPublicEncKey, +}; +use crate::cryptography::error::CryptographyError; +use crate::cryptography::hybrid_ml_kem::HybridKemCt; +use crate::cryptography::signatures::{ + CompositeSignature, NodeSigningIdentity, SigningSchemeSet, VerfKeySet, +}; +use crate::cryptography::zeroizing_writer::ZeroizingWriter; +use hashing::DomainSep; +use rand::{CryptoRng, RngCore}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use tfhe::named::Named; +use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; +use tfhe_versionable::{Versionize, VersionsDispatch}; +use zeroize::Zeroizing; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] +pub enum CompositeEnvelopeVersions { + V0(CompositeEnvelope), +} + +/// The plaintext of a composite signcryption, before encryption. +/// +/// `signature` carries its own scheme set, so this struct does not repeat it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Versionize)] +#[versionize(CompositeEnvelopeVersions)] +pub struct CompositeEnvelope { + pub msg: Vec, + pub signature: CompositeSignature, +} + +impl Named for CompositeEnvelope { + const NAME: &'static str = "signcryption::CompositeEnvelope"; +} + +/// The signing half of a composite signcryption. +/// +/// Holds a [`NodeSigningIdentity`] rather than a single key, and is not +/// persisted — deliberately, since the underlying identity is a in-memory structure. +#[derive(Clone, Debug)] +pub struct CompositeSigncryptionKey<'a> { + pub identity: &'a NodeSigningIdentity, + pub schemes: &'a SigningSchemeSet, + pub receiver_enc_key: &'a UnifiedPublicEncKey, + pub receiver_id: &'a [u8], +} + +impl<'a> CompositeSigncryptionKey<'a> { + pub fn new( + identity: &'a NodeSigningIdentity, + schemes: &'a SigningSchemeSet, + receiver_enc_key: &'a UnifiedPublicEncKey, + receiver_id: &'a [u8], + ) -> Self { + Self { + identity, + schemes, + receiver_enc_key, + receiver_id, + } + } +} + +impl HasPkeScheme for CompositeSigncryptionKey<'_> { + fn encryption_scheme_type(&self) -> PkeSchemeType { + self.receiver_enc_key.encryption_scheme_type() + } +} + +/// The verifying half of a composite signcryption. +/// +/// `expected_schemes` is not optional, and it is the verifier's policy rather +/// than anything read off the message. +/// +/// The set inside the envelope is authenticated, so it says truthfully which +/// schemes signed. +#[derive(Clone, Debug)] +pub struct CompositeUnsigncryptionKey<'a> { + pub decryption_key: &'a UnifiedPrivateEncKey, + pub encryption_key: &'a UnifiedPublicEncKey, + pub sender_keys: &'a VerfKeySet, + pub expected_schemes: &'a SigningSchemeSet, + pub receiver_id: &'a [u8], +} + +impl<'a> CompositeUnsigncryptionKey<'a> { + pub fn new( + decryption_key: &'a UnifiedPrivateEncKey, + encryption_key: &'a UnifiedPublicEncKey, + sender_keys: &'a VerfKeySet, + expected_schemes: &'a SigningSchemeSet, + receiver_id: &'a [u8], + ) -> Self { + Self { + decryption_key, + encryption_key, + sender_keys, + expected_schemes, + receiver_id, + } + } +} + +impl HasPkeScheme for CompositeUnsigncryptionKey<'_> { + fn encryption_scheme_type(&self) -> PkeSchemeType { + self.encryption_key.encryption_scheme_type() + } +} + +/// Signcrypt `msg` in the composite layout. +/// +/// Sign-then-encrypt, as in the frozen layout: each signature covers +/// `dsep ‖ schemes ‖ msg ‖ receiver_id ‖ H(receiver enc key)`, and the whole +/// envelope is then encrypted to the receiver. +#[cfg(feature = "non-wasm")] +pub(super) fn seal( + signcrypt_key: &CompositeSigncryptionKey, + rng: &mut (impl CryptoRng + RngCore), + dsep: &DomainSep, + msg: &[u8], +) -> Result { + // Refuse to produce a composite envelope under a scheme set that selects the + // frozen layout: it would be tagged `EcdsaV0` and handed to a parser that + // cannot read it. + if format_for(signcrypt_key.schemes) != SigncryptionFormat::CompositeV1 { + return Err(unsupported_format(signcrypt_key.schemes)); + } + + let binding = receiver_binding(signcrypt_key.receiver_id, signcrypt_key.receiver_enc_key)?; + let signed = Zeroizing::new([msg, binding.as_slice()].concat()); + let signature = + CompositeSignature::sign(signcrypt_key.identity, signcrypt_key.schemes, dsep, &signed)?; + + let envelope = CompositeEnvelope { + msg: msg.to_vec(), + signature, + }; + let mut plaintext = Vec::new(); + safe_serialize(&envelope, &mut plaintext, SAFE_SER_SIZE_LIMIT) + .map_err(CryptographyError::SerializationError)?; + let plaintext = Zeroizing::new(plaintext); + + let ciphertext = hybrid_encrypt(rng, &plaintext, signcrypt_key.receiver_enc_key)?; + Ok(UnifiedSigncryption::new( + bc2wrap::serialize(&ciphertext) + .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, + signcrypt_key.encryption_scheme_type(), + signcrypt_key.schemes.clone(), + )) +} + +/// Open a composite signcryption, returning the message only once every +/// constituent signature has verified. +pub(super) fn open( + unsign_key: &CompositeUnsigncryptionKey, + dsep: &DomainSep, + cipher: &UnifiedSigncryption, +) -> Result>, CryptographyError> { + if cipher.pke_type != unsign_key.encryption_key.encryption_scheme_type() { + return Err(CryptographyError::VerificationError( + "encryption type of cipher does not match the decryption key type".to_string(), + )); + } + let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) + .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; + let plaintext = hybrid_decrypt(kem_ct, unsign_key.decryption_key)?; + let envelope: CompositeEnvelope = + safe_deserialize(std::io::Cursor::new(&*plaintext), SAFE_SER_SIZE_LIMIT) + .map_err(CryptographyError::SerializationError)?; + + let binding = receiver_binding(unsign_key.receiver_id, unsign_key.encryption_key)?; + let signed = Zeroizing::new([envelope.msg.as_slice(), binding.as_slice()].concat()); + envelope + .signature + .verify( + unsign_key.sender_keys, + unsign_key.expected_schemes, + dsep, + &signed, + ) + .map_err(|e| CryptographyError::VerificationError(e.to_string()))?; + + Ok(Zeroizing::new(envelope.msg)) +} + +/// Serialize `msg` and signcrypt it in the composite layout. +/// +/// The same `Signcrypt` contract the frozen path implements, so a caller is +/// polymorphic over the two and the format follows from the key type it holds. +#[cfg(feature = "non-wasm")] +impl<'a> Signcrypt for CompositeSigncryptionKey<'a> { + #[allow(unknown_lints)] + // We allow modifying the rng before return + #[allow(non_local_effect_before_error_return)] + fn signcrypt( + &self, + rng: &mut (impl CryptoRng + RngCore), + dsep: &DomainSep, + msg: &T, + ) -> Result + where + T: Serialize + tfhe::Versionize + tfhe::named::Named, + { + let mut serialized_msg = ZeroizingWriter::new(); + safe_serialize(msg, &mut serialized_msg, SAFE_SER_SIZE_LIMIT).map_err(|e| { + CryptographyError::SerializationError(format!( + "Could not serialize message for signcryption: {e}", + )) + })?; + seal(self, rng, dsep, serialized_msg.as_slice()) + } +} + +impl<'a> Unsigncrypt for CompositeUnsigncryptionKey<'a> { + fn unsigncrypt( + &self, + dsep: &DomainSep, + cipher: &UnifiedSigncryption, + ) -> Result { + let msg = open(self, dsep, cipher)?; + safe_deserialize(std::io::Cursor::new(&*msg), SAFE_SER_SIZE_LIMIT) + .map_err(CryptographyError::SerializationError) + } + + fn validate_signcryption( + &self, + dsep: &DomainSep, + signcryption: &UnifiedSigncryption, + ) -> Result<(), CryptographyError> { + // Sign-then-encrypt, so authenticity is only established by decrypting. + let _ = open(self, dsep, signcryption).map_err(|e| { + CryptographyError::VerificationError(format!( + "failed to decrypt signcryption for validation: {e}" + )) + })?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::super::common::lock_fixture; + use super::super::{UnifiedSigncryptionKey, UnifiedUnsigncryptionKey}; + use super::*; + use crate::cryptography::encryption::PkeSchemeType; + use crate::cryptography::signatures::SigningSchemeType; + use crate::cryptography::signing::test_support::seeded_identity; + use crate::vault::storage::tests::TestType; + use aes_prng::AesRng; + use rand::SeedableRng; + + const DSEP: &DomainSep = b"COMPV1TT"; + + fn pair() -> SigningSchemeSet { + SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]).unwrap() + } + + struct Fixture { + rng: AesRng, + identity: NodeSigningIdentity, + keys: VerfKeySet, + schemes: SigningSchemeSet, + dec_key: UnifiedPrivateEncKey, + enc_key: UnifiedPublicEncKey, + receiver_id: Vec, + } + + fn fixture(scheme: PkeSchemeType, seed: u64) -> Fixture { + let base = lock_fixture(scheme, seed); + let mut rng = AesRng::seed_from_u64(seed ^ 0xC0FFEE); + let identity = seeded_identity(&mut rng); + let schemes = pair(); + let keys = VerfKeySet::from_identity(&identity, &schemes).unwrap(); + Fixture { + rng: base.rng, + identity, + keys, + schemes, + dec_key: base.dec_key, + enc_key: base.enc_key, + receiver_id: base.receiver_id, + } + } + + fn seal_msg(f: &mut Fixture, msg: &[u8]) -> UnifiedSigncryption { + let key = + CompositeSigncryptionKey::new(&f.identity, &f.schemes, &f.enc_key, &f.receiver_id); + seal(&key, &mut f.rng, DSEP, msg).unwrap() + } + + fn unsign_key<'a>( + f: &'a Fixture, + expected: &'a SigningSchemeSet, + ) -> CompositeUnsigncryptionKey<'a> { + CompositeUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &f.keys, expected, &f.receiver_id) + } + + /// Round-trips for both PKE schemes the backup and user-decryption paths use. + #[test] + fn round_trip() { + for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { + let mut f = fixture(scheme, 100); + let cipher = seal_msg(&mut f, b"a composite message"); + assert_eq!(cipher.pke_type, scheme); + assert_eq!(cipher.signing_schemes, pair()); + + let schemes = pair(); + let opened = open(&unsign_key(&f, &schemes), DSEP, &cipher).unwrap(); + assert_eq!(&*opened, b"a composite message", "{scheme}"); + } + } + + /// The headline property at the envelope level: a verifier that demands the + /// pair rejects a signcryption claiming only ECDSA, before decrypting. + #[test] + fn a_signature_under_another_scheme_set_is_rejected() { + let mut f = fixture(PkeSchemeType::MlKem512, 200); + + // The sender signs under a weaker pair; using MlDsa44 instead of MlDsa87. + let weaker = + SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa44]) + .unwrap(); + let key = CompositeSigncryptionKey::new(&f.identity, &weaker, &f.enc_key, &f.receiver_id); + let cipher = seal(&key, &mut f.rng, DSEP, b"downgrade me").unwrap(); + + let required = pair(); + let err = open(&unsign_key(&f, &required), DSEP, &cipher).unwrap_err(); + assert!( + matches!(err, CryptographyError::VerificationError(_)), + "{err}" + ); + + // ...and the same signcryption opens for a verifier that asked for + // exactly what was signed, confirming the rejection above is the policy + // check and not an unrelated failure. + let weaker_keys = VerfKeySet::from_identity(&f.identity, &weaker).unwrap(); + let permissive = CompositeUnsigncryptionKey::new( + &f.dec_key, + &f.enc_key, + &weaker_keys, + &weaker, + &f.receiver_id, + ); + assert_eq!(&*open(&permissive, DSEP, &cipher).unwrap(), b"downgrade me"); + } + + /// The outer scheme tag is unauthenticated so altering it changes nothing: + /// the authoritative set is the one inside the signed envelope. + #[test] + fn the_outer_scheme_tag_is_not_load_bearing() { + let mut f = fixture(PkeSchemeType::MlKem512, 250); + let mut cipher = seal_msg(&mut f, b"tag is only a hint"); + cipher.signing_schemes = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); + + let schemes = pair(); + assert_eq!( + &*open(&unsign_key(&f, &schemes), DSEP, &cipher).unwrap(), + b"tag is only a hint" + ); + } + + /// A composite envelope must not be openable by the frozen ECDSA reader, and + /// the frozen layout must not be openable as composite. + #[test] + fn the_two_formats_do_not_cross() { + let mut f = fixture(PkeSchemeType::MlKem512, 300); + let composite = seal_msg(&mut f, b"composite payload"); + + // Legacy reader, handed a composite signcryption: refused by dispatch. + let legacy_verf = f + .keys + .require(SigningSchemeType::Ecdsa256k1) + .unwrap() + .clone(); + let legacy_ecdsa = match &legacy_verf { + crate::cryptography::signatures::UnifiedPublicSigKey::Ecdsa256k1(k) => k.clone(), + _ => unreachable!("the ECDSA member of the set is an ECDSA key"), + }; + let legacy_key = + UnifiedUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &legacy_ecdsa, &f.receiver_id); + assert!( + legacy_key + .unsigncrypt::(DSEP, &composite) + .is_err(), + "the frozen reader accepted a composite envelope" + ); + + // Composite reader, handed a frozen-layout signcryption: it decrypts, + // but the plaintext is `msg ‖ sig ‖ digest` rather than a serialized + // `CompositeEnvelope`, so deserialization refuses it. + let base = lock_fixture(PkeSchemeType::MlKem512, 300); + let mut rng = base.rng; + let ecdsa_key = + UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id); + let frozen = ecdsa_key + .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) + .unwrap(); + let schemes = pair(); + assert!( + open(&unsign_key(&f, &schemes), DSEP, &frozen).is_err(), + "the composite reader accepted a frozen envelope" + ); + } + + /// Sealing under a set that selects the frozen layout is refused, rather + /// than producing an envelope no reader can open. + #[test] + fn sealing_under_the_ecdsa_singleton_is_refused() { + let mut f = fixture(PkeSchemeType::MlKem512, 400); + let single = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); + let key = CompositeSigncryptionKey::new(&f.identity, &single, &f.enc_key, &f.receiver_id); + assert!(matches!( + seal(&key, &mut f.rng, DSEP, b"nope"), + Err(CryptographyError::UnsupportedSigncryptionFormat(_)) + )); + } + + /// A sender whose key set differs from the one the receiver holds is + /// detected, even though the ciphertext decrypts. There is no sender + /// identifier in the envelope to compare. + #[test] + fn a_different_sender_key_set_is_rejected() { + let mut f = fixture(PkeSchemeType::MlKem512, 500); + let cipher = seal_msg(&mut f, b"whose message is this"); + + let mut rng = AesRng::seed_from_u64(999); + let other = seeded_identity(&mut rng); + let other_keys = VerfKeySet::from_identity(&other, &pair()).unwrap(); + let schemes = pair(); + let key = CompositeUnsigncryptionKey::new( + &f.dec_key, + &f.enc_key, + &other_keys, + &schemes, + &f.receiver_id, + ); + assert!(open(&key, DSEP, &cipher).is_err()); + } + + /// The receiver binding is covered by the signature, so opening with a + /// different receiver id fails. + #[test] + fn a_different_receiver_id_is_rejected() { + let mut f = fixture(PkeSchemeType::MlKem512, 600); + let cipher = seal_msg(&mut f, b"bound to a receiver"); + + let schemes = pair(); + let other_id = b"a different receiver".to_vec(); + let key = + CompositeUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &f.keys, &schemes, &other_id); + assert!(open(&key, DSEP, &cipher).is_err()); + } + + #[test] + fn a_tampered_ciphertext_or_dsep_fails() { + let mut f = fixture(PkeSchemeType::MlKem512, 700); + let cipher = seal_msg(&mut f, b"tamper with me"); + let schemes = pair(); + + let mut flipped = cipher.clone(); + flipped.payload[0] ^= 0x01; + assert!(open(&unsign_key(&f, &schemes), DSEP, &flipped).is_err()); + + assert!(open(&unsign_key(&f, &schemes), b"OTHERDSP", &cipher).is_err()); + } +} diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 397cf43ee1..2ec24c5765 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -40,8 +40,10 @@ //! [`CryptographyError::UnsupportedSigncryptionFormat`]. mod common; +pub mod composite_v1; mod ecdsa_v0; +pub use composite_v1::{CompositeSigncryptionKey, CompositeUnsigncryptionKey}; pub(crate) use ecdsa_v0::insecure_decrypt_ignoring_signature; use crate::consts::SAFE_SER_SIZE_LIMIT; diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs new file mode 100644 index 0000000000..baee900ca9 --- /dev/null +++ b/core/service/src/cryptography/signing/composite.rs @@ -0,0 +1,262 @@ +//! A signature made under several schemes at once. +//! +//! A composite signature is worth no more than the guarantee that *every* one of +//! its parts is present. Dropping the post-quantum half of an ECDSA+ML-DSA pair +//! must not leave something a verifier accepts, or the hedge the composite was +//! built for is gone. +//! +//! What makes that hold is that the scheme set is inside the bytes each +//! signature covers. A signature produced under `{A, B}` attests to that set, so +//! it cannot be re-presented as a complete signature under `{A}`.≠ + +use super::identity::NodeSigningIdentity; +use super::scheme_set::SigningSchemeSet; +use super::verf_key_set::VerfKeySet; +use super::{Signature, SigningError, unified_verify}; +use hashing::DomainSep; +use serde::{Deserialize, Serialize}; +use tfhe_versionable::{Versionize, VersionsDispatch}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] +pub enum CompositeSignatureVersions { + V0(CompositeSignature), +} + +/// One signature per scheme, over a message that commits to the scheme set. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Versionize)] +#[versionize(CompositeSignatureVersions)] +pub struct CompositeSignature { + schemes: SigningSchemeSet, + signatures: Vec>, +} + +impl CompositeSignature { + /// The bytes every constituent signature is made over. + pub fn preimage(schemes: &SigningSchemeSet, msg: &[u8]) -> Vec { + // Note that the scheme should comes first and is length-prefixed + [schemes.canonical_bytes().as_slice(), msg].concat() + } + + /// Sign `msg` under every scheme in `schemes`. + #[cfg(feature = "non-wasm")] + pub fn sign( + identity: &NodeSigningIdentity, + schemes: &SigningSchemeSet, + dsep: &DomainSep, + msg: &[u8], + ) -> Result { + identity.ensure_supported(schemes.as_slice())?; + let preimage = Self::preimage(schemes, msg); + let signatures = schemes + .iter() + .map(|scheme| { + identity + .unified_sign_with(scheme, dsep, &preimage) + .map(|signature| signature.to_bytes()) + }) + .collect::, _>>()?; + Ok(Self { + schemes: schemes.clone(), + signatures, + }) + } + + /// Check every signature against `keys`, having first checked that this + /// signature was made under exactly `expected`. + /// + /// Every signature must verify. + pub fn verify( + &self, + keys: &VerfKeySet, + expected: &SigningSchemeSet, + dsep: &DomainSep, + msg: &[u8], + ) -> Result<(), SigningError> { + // The scheme-set comparison happens before any cryptography, + // so a composite signature presented with one of its parts removed is + // rejected for being the wrong shape + if self.schemes != *expected { + return Err(SigningError::UnexpectedSchemeSet { + expected: expected.to_string(), + actual: self.schemes.to_string(), + }); + } + if self.schemes.len() != self.signatures.len() { + return Err(SigningError::SignatureCountMismatch { + schemes: self.schemes.len(), + signatures: self.signatures.len(), + }); + } + let preimage = Self::preimage(&self.schemes, msg); + // Note that we have just ensured `schemes` and `signatures` have same cardinality. + for (scheme, bytes) in self.schemes.iter().zip(&self.signatures) { + let signature = Signature::new(scheme, bytes.clone()); + unified_verify(dsep, &preimage, &signature, keys.require(scheme)?)?; + } + Ok(()) + } + + /// The schemes this signature was made under. + pub fn schemes(&self) -> &SigningSchemeSet { + &self.schemes + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cryptography::signatures::{SigningSchemeType, gen_sig_keys}; + use crate::cryptography::signing::test_support::seeded_identity; + use aes_prng::AesRng; + use rand::SeedableRng; + + const DSEP: &DomainSep = b"COMPSIGT"; + const MSG: &[u8] = b"a message signed under several schemes at once"; + + fn pair() -> SigningSchemeSet { + SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]).unwrap() + } + + fn setup(seed: u64) -> (NodeSigningIdentity, VerfKeySet, SigningSchemeSet) { + let mut rng = AesRng::seed_from_u64(seed); + let identity = seeded_identity(&mut rng); + let schemes = pair(); + let keys = VerfKeySet::from_identity(&identity, &schemes).unwrap(); + (identity, keys, schemes) + } + + #[test] + fn round_trip_sunshine() { + let (identity, keys, schemes) = setup(1); + let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + assert_eq!(sig.schemes(), &schemes); + sig.verify(&keys, &schemes, DSEP, MSG).unwrap(); + } + + /// Removing a signature must not leave something that verifies under the remaining scheme. + #[test] + fn a_stripped_signature_is_rejected() { + let (identity, keys, schemes) = setup(2); + let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + + // Drop the ML-DSA half and relabel the set as ECDSA-only + let stripped = CompositeSignature { + schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + signatures: vec![sig.signatures[0].clone()], + }; + + // Against the original policy it is the wrong scheme set... + assert!(matches!( + stripped.verify(&keys, &schemes, DSEP, MSG), + Err(SigningError::UnexpectedSchemeSet { .. }) + )); + + // ...and even if a verifier were talked into asking only for ECDSA, the + // surviving signature covers a preimage naming the *pair*, so it does + // not verify against the single-scheme preimage either. + let ecdsa_only = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); + assert!(stripped.verify(&keys, &ecdsa_only, DSEP, MSG).is_err()); + } + + /// A signature list that disagrees with the scheme set in length is refused + /// before any verification happens. + #[test] + fn a_signature_count_mismatch_is_rejected() { + let (identity, keys, schemes) = setup(3); + let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + + let mut short = sig.clone(); + short.signatures.pop(); + assert!(matches!( + short.verify(&keys, &schemes, DSEP, MSG), + Err(SigningError::SignatureCountMismatch { .. }) + )); + + let mut long = sig; + long.signatures.push(vec![0u8; 64]); + assert!(matches!( + long.verify(&keys, &schemes, DSEP, MSG), + Err(SigningError::SignatureCountMismatch { .. }) + )); + } + + /// Every constituent signature has to verify; one bad one fails the whole. + #[test] + fn one_tampered_signature_fails_the_composite() { + let (identity, keys, schemes) = setup(4); + let base = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + + for index in 0..base.signatures.len() { + let mut tampered = base.clone(); + tampered.signatures[index][0] ^= 0x01; + assert!( + tampered.verify(&keys, &schemes, DSEP, MSG).is_err(), + "tampering with signature {index} was not detected" + ); + } + } + + #[test] + fn a_tampered_message_or_dsep_fails() { + let (identity, keys, schemes) = setup(5); + let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + assert!( + sig.verify(&keys, &schemes, DSEP, b"a different message") + .is_err() + ); + assert!(sig.verify(&keys, &schemes, b"OTHERDSP", MSG).is_err()); + } + + /// Signatures are checked against the key set presented, so another party's + /// keys do not verify them. + #[test] + fn another_partys_keys_do_not_verify() { + let (identity, keys, schemes) = setup(6); + let (_, other_keys, _) = setup(7); + let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + + sig.verify(&keys, &schemes, DSEP, MSG).unwrap(); + assert!(sig.verify(&other_keys, &schemes, DSEP, MSG).is_err()); + } + + /// A key set missing one of the schemes cannot verify, rather than skipping + /// the scheme it has no key for. + #[test] + fn a_key_set_missing_a_scheme_is_rejected() { + let (identity, _keys, schemes) = setup(8); + let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + + let ecdsa_only = VerfKeySet::from_identity( + &identity, + &SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + ) + .unwrap(); + assert!(matches!( + sig.verify(&ecdsa_only, &schemes, DSEP, MSG), + Err(SigningError::NoVerificationKey(_)) + )); + } + + /// An identity with no root seed can only do ECDSA, so asking it for the + /// composite pair fails rather than producing a one-scheme signature. + #[test] + fn a_seedless_identity_cannot_sign_the_composite() { + let mut rng = AesRng::seed_from_u64(9); + let identity = NodeSigningIdentity::ecdsa_only(gen_sig_keys(&mut rng).1); + assert!(matches!( + CompositeSignature::sign(&identity, &pair(), DSEP, MSG), + Err(SigningError::MissingRootSeed(_)) + )); + } + + /// Distinct scheme sets give distinct preimages, which is what stops a + /// signature crossing between them. + #[test] + fn preimages_separate_scheme_sets() { + let single = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); + assert_ne!( + CompositeSignature::preimage(&single, MSG), + CompositeSignature::preimage(&pair(), MSG) + ); + } +} diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 4ad0b22b77..ad84dba435 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -3,6 +3,7 @@ //! Every backend signs `dsep ‖ msg` and applies its own normalization/encoding //! internally. +pub mod composite; pub mod ecdsa; mod eddsa; pub mod identity; @@ -11,6 +12,7 @@ pub mod scheme_set; pub mod seed; pub mod verf_key_set; +pub use composite::{CompositeSignature, CompositeSignatureVersions}; pub use scheme_set::SigningSchemeSet; pub use verf_key_set::VerfKeySet; @@ -101,6 +103,24 @@ pub enum SigningError { /// A set of signing schemes was not in its canonical encoding. #[error("signing scheme set is not canonical: {0}")] NonCanonicalSchemeSet(String), + /// A composite signature carried a different set of schemes than the + /// verifier required. + #[error("signature was made under schemes {actual}, but {expected} were required")] + UnexpectedSchemeSet { + /// The set the verifier demanded. + expected: String, + /// The set the signature claims. + actual: String, + }, + /// A composite signature carried a number of signatures that does not match + /// its scheme set. + #[error("composite signature has {schemes} schemes but {signatures} signatures")] + SignatureCountMismatch { + /// How many schemes the signature names. + schemes: usize, + /// How many signature blobs it carries. + signatures: usize, + }, } /// Trait for any value that is tied to a concrete signature scheme. From fa268e973ece1e4d4066d36d05fb513f1db589dd Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 22 Sep 2026 16:28:50 +0200 Subject: [PATCH 012/114] fix: lint --- core/service/src/cryptography/signcryption/composite_v1.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 6c75f8352a..d938fcfa7a 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -147,7 +147,7 @@ pub(super) fn seal( }; let mut plaintext = Vec::new(); safe_serialize(&envelope, &mut plaintext, SAFE_SER_SIZE_LIMIT) - .map_err(CryptographyError::SerializationError)?; + .map_err(|e| CryptographyError::SerializationError(e.to_string()))?; let plaintext = Zeroizing::new(plaintext); let ciphertext = hybrid_encrypt(rng, &plaintext, signcrypt_key.receiver_enc_key)?; From b548392a1bc991e686906b1b2d2f62a5417bb01c Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 11:13:25 +0200 Subject: [PATCH 013/114] refactor: simplified structure --- core/service/src/cryptography/signatures.rs | 3 +- .../cryptography/signcryption/composite_v1.rs | 10 +- .../src/cryptography/signing/composite.rs | 259 +++++++++++++----- core/service/src/cryptography/signing/mod.rs | 11 +- .../cryptography/signing/typed_signature.rs | 42 +++ core/service/src/engine/base.rs | 171 +----------- .../src/engine/centralized/central_kms.rs | 2 +- .../tests/backward_compatibility_kms.rs | 5 +- 8 files changed, 253 insertions(+), 250 deletions(-) create mode 100644 core/service/src/cryptography/signing/typed_signature.rs diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index bc80035064..a0d5e90135 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -19,7 +19,8 @@ pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVer pub use crate::cryptography::signing::{ CompositeSignature, CompositeSignatureVersions, HasSigningScheme, SchemeVerfKeys, Signature, SigningError, SigningSchemeSet, SigningSchemeType, SigningSchemeTypeVersions, - UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, + StoredTypedSignature, StoredTypedSignatureVersions, UnifiedPublicSigKey, + UnifiedPublicSigKeyVersions, VerfKeySet, }; pub(crate) use crate::cryptography::signing::ecdsa::{ diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index d938fcfa7a..1fa9eb6d27 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -138,8 +138,12 @@ pub(super) fn seal( let binding = receiver_binding(signcrypt_key.receiver_id, signcrypt_key.receiver_enc_key)?; let signed = Zeroizing::new([msg, binding.as_slice()].concat()); - let signature = - CompositeSignature::sign(signcrypt_key.identity, signcrypt_key.schemes, dsep, &signed)?; + let signature = CompositeSignature::sign_uniform( + signcrypt_key.identity, + signcrypt_key.schemes, + dsep, + &signed, + )?; let envelope = CompositeEnvelope { msg: msg.to_vec(), @@ -182,7 +186,7 @@ pub(super) fn open( let signed = Zeroizing::new([envelope.msg.as_slice(), binding.as_slice()].concat()); envelope .signature - .verify( + .verify_uniform( unsign_key.sender_keys, unsign_key.expected_schemes, dsep, diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index baee900ca9..f211958555 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -11,8 +11,9 @@ use super::identity::NodeSigningIdentity; use super::scheme_set::SigningSchemeSet; +use super::typed_signature::StoredTypedSignature; use super::verf_key_set::VerfKeySet; -use super::{Signature, SigningError, unified_verify}; +use super::{Signature, SigningError, SigningSchemeType, unified_verify}; use hashing::DomainSep; use serde::{Deserialize, Serialize}; use tfhe_versionable::{Versionize, VersionsDispatch}; @@ -23,23 +24,50 @@ pub enum CompositeSignatureVersions { } /// One signature per scheme, over a message that commits to the scheme set. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Versionize)] +/// +/// Entries are ordered by scheme and carry no duplicate scheme. +/// +/// The [`Deserialize`] impl *rejects* a non-canonical list rather than sorting it: +/// a value arriving from storage or the wire is attacker-chosen, and silently +/// reordering it would give one signature more than one encoding. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Versionize)] #[versionize(CompositeSignatureVersions)] -pub struct CompositeSignature { - schemes: SigningSchemeSet, - signatures: Vec>, +#[serde(transparent)] +pub struct CompositeSignature(Vec); + +impl<'de> Deserialize<'de> for CompositeSignature { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let entries = Vec::::deserialize(deserializer)?; + Self::from_canonical(entries).map_err(serde::de::Error::custom) + } } impl CompositeSignature { + /// Accept `entries` only if they are already ordered by scheme, carry no + /// duplicate scheme, and are non-empty. + pub fn from_canonical(entries: Vec) -> Result { + let schemes = + SigningSchemeSet::from_canonical(entries.iter().map(|entry| entry.scheme).collect())?; + debug_assert_eq!(schemes.len(), entries.len()); + Ok(Self(entries)) + } + + /// The entries, ordered by scheme. + pub fn entries(&self) -> &[StoredTypedSignature] { + &self.0 + } /// The bytes every constituent signature is made over. pub fn preimage(schemes: &SigningSchemeSet, msg: &[u8]) -> Vec { // Note that the scheme should comes first and is length-prefixed [schemes.canonical_bytes().as_slice(), msg].concat() } - /// Sign `msg` under every scheme in `schemes`. + /// Sign `msg` under every scheme in `schemes`, each over the same bytes. #[cfg(feature = "non-wasm")] - pub fn sign( + pub fn sign_uniform( identity: &NodeSigningIdentity, schemes: &SigningSchemeSet, dsep: &DomainSep, @@ -47,25 +75,26 @@ impl CompositeSignature { ) -> Result { identity.ensure_supported(schemes.as_slice())?; let preimage = Self::preimage(schemes, msg); - let signatures = schemes + let entries = schemes .iter() .map(|scheme| { identity .unified_sign_with(scheme, dsep, &preimage) - .map(|signature| signature.to_bytes()) + .map(|signature| StoredTypedSignature { + scheme, + signature: signature.to_bytes(), + }) }) .collect::, _>>()?; - Ok(Self { - schemes: schemes.clone(), - signatures, - }) + // `schemes` is canonical, so the entries built from it are too. + Ok(Self(entries)) } /// Check every signature against `keys`, having first checked that this /// signature was made under exactly `expected`. /// /// Every signature must verify. - pub fn verify( + pub fn verify_uniform( &self, keys: &VerfKeySet, expected: &SigningSchemeSet, @@ -75,33 +104,70 @@ impl CompositeSignature { // The scheme-set comparison happens before any cryptography, // so a composite signature presented with one of its parts removed is // rejected for being the wrong shape - if self.schemes != *expected { + let schemes = self.schemes()?; + if schemes != *expected { return Err(SigningError::UnexpectedSchemeSet { expected: expected.to_string(), - actual: self.schemes.to_string(), - }); - } - if self.schemes.len() != self.signatures.len() { - return Err(SigningError::SignatureCountMismatch { - schemes: self.schemes.len(), - signatures: self.signatures.len(), + actual: schemes.to_string(), }); } - let preimage = Self::preimage(&self.schemes, msg); - // Note that we have just ensured `schemes` and `signatures` have same cardinality. - for (scheme, bytes) in self.schemes.iter().zip(&self.signatures) { - let signature = Signature::new(scheme, bytes.clone()); - unified_verify(dsep, &preimage, &signature, keys.require(scheme)?)?; + let preimage = Self::preimage(&schemes, msg); + for entry in &self.0 { + let signature = Signature::new(entry.scheme, entry.signature.clone()); + unified_verify(dsep, &preimage, &signature, keys.require(entry.scheme)?)?; } Ok(()) } - /// The schemes this signature was made under. - pub fn schemes(&self) -> &SigningSchemeSet { - &self.schemes + /// The schemes this signature was made under, derived from its entries. + pub fn schemes(&self) -> Result { + SigningSchemeSet::new(self.0.iter().map(|entry| entry.scheme)) } } +/// The per-scheme signatures of a *result*: a keygen, CRS, preprocessing or +/// decryption response. +/// +/// ECDSA signs `eip712_hash` recoverably, producing the signature the fhevm +/// contracts verify on chain. Every other scheme signs `dsep ‖ payload_bytes`. +/// +/// Returns the entries rather than a [`CompositeSignature`] because a result may +/// legitimately request no schemes at all, which that type refuses to represent. +/// +/// WARNING: the non-ECDSA entries here are *not* yet bound to the scheme set, so a +/// verifier must still check the schemes it receives against the ones it asked +/// for! +#[cfg(feature = "non-wasm")] +pub fn sign_result_entries( + identity: &NodeSigningIdentity, + schemes: &[SigningSchemeType], + dsep: &DomainSep, + eip712_hash: &[u8], + payload_bytes: &[u8], +) -> Result, SigningError> { + schemes + .iter() + .map(|&scheme| { + let signature = match scheme { + SigningSchemeType::Ecdsa256k1 => { + let hash = alloy_primitives::B256::try_from(eip712_hash).map_err(|_| { + SigningError::Sign(format!( + "EIP-712 signing hash must be 32 bytes, got {}", + eip712_hash.len() + )) + })?; + crate::cryptography::signing::ecdsa::eip712_sign_hash(identity.ecdsa(), &hash) + .map_err(|e| SigningError::Sign(e.to_string()))? + } + _ => identity + .unified_sign_with(scheme, dsep, payload_bytes)? + .to_bytes(), + }; + Ok(StoredTypedSignature { scheme, signature }) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -128,26 +194,23 @@ mod tests { #[test] fn round_trip_sunshine() { let (identity, keys, schemes) = setup(1); - let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); - assert_eq!(sig.schemes(), &schemes); - sig.verify(&keys, &schemes, DSEP, MSG).unwrap(); + let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + assert_eq!(sig.schemes().unwrap(), schemes); + sig.verify_uniform(&keys, &schemes, DSEP, MSG).unwrap(); } /// Removing a signature must not leave something that verifies under the remaining scheme. #[test] fn a_stripped_signature_is_rejected() { let (identity, keys, schemes) = setup(2); - let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); // Drop the ML-DSA half and relabel the set as ECDSA-only - let stripped = CompositeSignature { - schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), - signatures: vec![sig.signatures[0].clone()], - }; + let stripped = CompositeSignature::from_canonical(vec![sig.entries()[0].clone()]).unwrap(); // Against the original policy it is the wrong scheme set... assert!(matches!( - stripped.verify(&keys, &schemes, DSEP, MSG), + stripped.verify_uniform(&keys, &schemes, DSEP, MSG), Err(SigningError::UnexpectedSchemeSet { .. }) )); @@ -155,42 +218,43 @@ mod tests { // surviving signature covers a preimage naming the *pair*, so it does // not verify against the single-scheme preimage either. let ecdsa_only = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); - assert!(stripped.verify(&keys, &ecdsa_only, DSEP, MSG).is_err()); + assert!( + stripped + .verify_uniform(&keys, &ecdsa_only, DSEP, MSG) + .is_err() + ); } - /// A signature list that disagrees with the scheme set in length is refused - /// before any verification happens. + /// A list that is not ordered by scheme, or repeats one, is refused on + /// deserialization rather than normalised. #[test] - fn a_signature_count_mismatch_is_rejected() { - let (identity, keys, schemes) = setup(3); - let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + fn a_non_canonical_entry_list_is_rejected() { + let (identity, _keys, schemes) = setup(3); + let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + let entries = sig.entries().to_vec(); - let mut short = sig.clone(); - short.signatures.pop(); - assert!(matches!( - short.verify(&keys, &schemes, DSEP, MSG), - Err(SigningError::SignatureCountMismatch { .. }) - )); + let mut reversed = entries.clone(); + reversed.reverse(); + assert!(CompositeSignature::from_canonical(reversed).is_err()); - let mut long = sig; - long.signatures.push(vec![0u8; 64]); - assert!(matches!( - long.verify(&keys, &schemes, DSEP, MSG), - Err(SigningError::SignatureCountMismatch { .. }) - )); + let duplicated = vec![entries[0].clone(), entries[0].clone()]; + assert!(CompositeSignature::from_canonical(duplicated).is_err()); + + assert!(CompositeSignature::from_canonical(Vec::new()).is_err()); } /// Every constituent signature has to verify; one bad one fails the whole. #[test] fn one_tampered_signature_fails_the_composite() { let (identity, keys, schemes) = setup(4); - let base = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + let base = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - for index in 0..base.signatures.len() { - let mut tampered = base.clone(); - tampered.signatures[index][0] ^= 0x01; + for index in 0..base.entries().len() { + let mut entries = base.entries().to_vec(); + entries[index].signature[0] ^= 0x01; + let tampered = CompositeSignature::from_canonical(entries).unwrap(); assert!( - tampered.verify(&keys, &schemes, DSEP, MSG).is_err(), + tampered.verify_uniform(&keys, &schemes, DSEP, MSG).is_err(), "tampering with signature {index} was not detected" ); } @@ -199,12 +263,15 @@ mod tests { #[test] fn a_tampered_message_or_dsep_fails() { let (identity, keys, schemes) = setup(5); - let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); assert!( - sig.verify(&keys, &schemes, DSEP, b"a different message") + sig.verify_uniform(&keys, &schemes, DSEP, b"a different message") + .is_err() + ); + assert!( + sig.verify_uniform(&keys, &schemes, b"OTHERDSP", MSG) .is_err() ); - assert!(sig.verify(&keys, &schemes, b"OTHERDSP", MSG).is_err()); } /// Signatures are checked against the key set presented, so another party's @@ -213,10 +280,13 @@ mod tests { fn another_partys_keys_do_not_verify() { let (identity, keys, schemes) = setup(6); let (_, other_keys, _) = setup(7); - let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - sig.verify(&keys, &schemes, DSEP, MSG).unwrap(); - assert!(sig.verify(&other_keys, &schemes, DSEP, MSG).is_err()); + sig.verify_uniform(&keys, &schemes, DSEP, MSG).unwrap(); + assert!( + sig.verify_uniform(&other_keys, &schemes, DSEP, MSG) + .is_err() + ); } /// A key set missing one of the schemes cannot verify, rather than skipping @@ -224,7 +294,7 @@ mod tests { #[test] fn a_key_set_missing_a_scheme_is_rejected() { let (identity, _keys, schemes) = setup(8); - let sig = CompositeSignature::sign(&identity, &schemes, DSEP, MSG).unwrap(); + let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); let ecdsa_only = VerfKeySet::from_identity( &identity, @@ -232,7 +302,7 @@ mod tests { ) .unwrap(); assert!(matches!( - sig.verify(&ecdsa_only, &schemes, DSEP, MSG), + sig.verify_uniform(&ecdsa_only, &schemes, DSEP, MSG), Err(SigningError::NoVerificationKey(_)) )); } @@ -244,11 +314,58 @@ mod tests { let mut rng = AesRng::seed_from_u64(9); let identity = NodeSigningIdentity::ecdsa_only(gen_sig_keys(&mut rng).1); assert!(matches!( - CompositeSignature::sign(&identity, &pair(), DSEP, MSG), + CompositeSignature::sign_uniform(&identity, &pair(), DSEP, MSG), Err(SigningError::MissingRootSeed(_)) )); } + /// The result shape splits by scheme: ECDSA signs the EIP-712 hash, every + /// other scheme signs the payload. + #[test] + fn result_entries_split_ecdsa_from_the_rest() { + let mut rng = AesRng::seed_from_u64(20); + let identity = seeded_identity(&mut rng); + let schemes = [ + SigningSchemeType::Ecdsa256k1, + SigningSchemeType::Ed25519, + SigningSchemeType::MlDsa65, + ]; + let eip712_hash = [0x11u8; 32]; + let payload = b"the serialized result payload"; + + let entries = + sign_result_entries(&identity, &schemes, DSEP, &eip712_hash, payload).unwrap(); + assert_eq!(entries.len(), 3); + + for entry in &entries { + if entry.scheme == SigningSchemeType::Ecdsa256k1 { + // Recoverable EIP-712 signature: 65 bytes, and not a signature + // over the payload. + assert_eq!(entry.signature.len(), 65); + continue; + } + let vk = identity.unified_verifying_key(entry.scheme).unwrap(); + let sig = Signature::new(entry.scheme, entry.signature.clone()); + unified_verify(DSEP, payload, &sig, &vk) + .unwrap_or_else(|e| panic!("{:?} should sign the payload: {e}", entry.scheme)); + // ...and not the EIP-712 hash. + assert!(unified_verify(DSEP, &eip712_hash, &sig, &vk).is_err()); + } + } + + /// No schemes requested means no per-scheme entries, which is why this + /// returns a plain list rather than a `CompositeSignature`. + #[test] + fn result_entries_tolerate_an_empty_request() { + let mut rng = AesRng::seed_from_u64(21); + let identity = seeded_identity(&mut rng); + assert!( + sign_result_entries(&identity, &[], DSEP, &[0u8; 32], b"payload") + .unwrap() + .is_empty() + ); + } + /// Distinct scheme sets give distinct preimages, which is what stops a /// signature crossing between them. #[test] diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index ad84dba435..5780b6f007 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -10,10 +10,12 @@ pub mod identity; mod mldsa; pub mod scheme_set; pub mod seed; +pub mod typed_signature; pub mod verf_key_set; pub use composite::{CompositeSignature, CompositeSignatureVersions}; pub use scheme_set::SigningSchemeSet; +pub use typed_signature::{StoredTypedSignature, StoredTypedSignatureVersions}; pub use verf_key_set::VerfKeySet; use alloy_primitives::Address; @@ -112,15 +114,6 @@ pub enum SigningError { /// The set the signature claims. actual: String, }, - /// A composite signature carried a number of signatures that does not match - /// its scheme set. - #[error("composite signature has {schemes} schemes but {signatures} signatures")] - SignatureCountMismatch { - /// How many schemes the signature names. - schemes: usize, - /// How many signature blobs it carries. - signatures: usize, - }, } /// Trait for any value that is tied to a concrete signature scheme. diff --git a/core/service/src/cryptography/signing/typed_signature.rs b/core/service/src/cryptography/signing/typed_signature.rs new file mode 100644 index 0000000000..c4da9efb5b --- /dev/null +++ b/core/service/src/cryptography/signing/typed_signature.rs @@ -0,0 +1,42 @@ +//! A signature together with the scheme that produced it. + +use super::SigningSchemeType; +use kms_grpc::kms::v1::TypedSignature; +use serde::{Deserialize, Serialize}; +use tfhe_versionable::{Versionize, VersionsDispatch}; + +#[derive(Clone, Serialize, Deserialize, VersionsDispatch)] +pub enum StoredTypedSignatureVersions { + V0(StoredTypedSignature), +} + +/// A single KMS signature together with the scheme that produced it, in the +/// form persisted inside result metadata. +/// +/// This is the stored twin of the gRPC [`TypedSignature`]. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Versionize)] +#[versionize(StoredTypedSignatureVersions)] +pub struct StoredTypedSignature { + pub scheme: SigningSchemeType, + pub signature: Vec, +} + +impl StoredTypedSignature { + /// The `signatures` list of a result that carries nothing but its + /// ECDSA/EIP-712 signature. + pub fn ecdsa_only(external_signature: Vec) -> Vec { + vec![StoredTypedSignature { + scheme: SigningSchemeType::Ecdsa256k1, + signature: external_signature, + }] + } +} + +impl From<&StoredTypedSignature> for TypedSignature { + fn from(value: &StoredTypedSignature) -> Self { + TypedSignature { + scheme: value.scheme.as_wire(), + signature: value.signature.clone(), + } + } +} diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index ce3ac9eedd..dc730438dd 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -9,6 +9,7 @@ use crate::cryptography::signatures::internal_sign; use crate::cryptography::signatures::{PublicSigKey, Signature}; use crate::cryptography::signing::SigningSchemeType; use crate::cryptography::signing::identity::NodeSigningIdentity; +use crate::cryptography::signing::typed_signature::StoredTypedSignature; use crate::engine::rng_source::RngSource; use crate::engine::traits::PrivateKeyMaterialMetadata; use crate::util::key_setup::FhePrivateKey; @@ -246,43 +247,6 @@ pub fn derive_request_id(name: &str) -> anyhow::Result { Ok(RequestId::from_str(&res_hex)?) } -/// A single KMS signature together with the scheme that produced it, in the -/// form persisted inside result metadata. -/// -/// This is the stored twin of the gRPC [`TypedSignature`]. -#[derive(Clone, Serialize, Deserialize, VersionsDispatch)] -pub enum StoredTypedSignatureVersions { - V0(StoredTypedSignature), -} - -/// A single KMS signature together with the scheme that produced it. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Versionize)] -#[versionize(StoredTypedSignatureVersions)] -pub struct StoredTypedSignature { - pub scheme: SigningSchemeType, - pub signature: Vec, -} - -impl StoredTypedSignature { - /// The `signatures` list of a result that carries nothing but its - /// ECDSA/EIP-712 signature. - pub fn ecdsa_only(external_signature: Vec) -> Vec { - vec![StoredTypedSignature { - scheme: SigningSchemeType::Ecdsa256k1, - signature: external_signature, - }] - } -} - -impl From<&StoredTypedSignature> for TypedSignature { - fn from(value: &StoredTypedSignature) -> Self { - TypedSignature { - scheme: value.scheme.as_wire(), - signature: value.signature.clone(), - } - } -} - /// The result payload that every non-ECDSA scheme signs for a preprocessing /// result. #[derive(Clone, Serialize, Deserialize, VersionsDispatch)] @@ -528,82 +492,6 @@ pub(crate) fn stored_scheme_signatures_to_proto( signatures.iter().map(TypedSignature::from).collect() } -/// One scheme's contribution to a result's `signatures` list: the message that -/// scheme signs. -struct SchemeSigningJob { - pub scheme: SigningSchemeType, - pub message: Vec, -} - -/// Sign a result under each requested `(scheme, message)` job, returning the -/// per-scheme signatures to persist in result metadata. -fn compute_result_signatures( - identity: &NodeSigningIdentity, - dsep: &DomainSep, - jobs: &[SchemeSigningJob], -) -> anyhow::Result> { - jobs.iter() - .map(|job| { - let signature = match job.scheme { - SigningSchemeType::Ecdsa256k1 => { - let hash = - alloy_primitives::B256::try_from(job.message.as_slice()).map_err(|_| { - anyhow::anyhow!( - "EIP-712 signing hash must be 32 bytes, got {}", - job.message.len() - ) - })?; - crate::cryptography::signatures::eip712_sign_hash(identity.ecdsa(), &hash)? - } - // Raw primitive signature over `dsep ‖ message`. - scheme @ (SigningSchemeType::Ed25519 - | SigningSchemeType::MlDsa44 - | SigningSchemeType::MlDsa65 - | SigningSchemeType::MlDsa87) => identity - .unified_sign_with(scheme, dsep, &job.message)? - .to_bytes(), - }; - Ok(StoredTypedSignature { - scheme: job.scheme, - signature, - }) - }) - .collect() -} - -/// Build the per-scheme signing jobs for a result's `signatures` list. -/// -/// **A scheme determines what its signature covers.** This mapping is the contract -/// every verifier relies on, so it lives here alone: -/// -/// - [`SigningSchemeType::Ecdsa256k1`] signs `eip712_hash`, producing the -/// recoverable, on-chain-verifiable signature the fhevm contracts verify. It is -/// byte-identical to the result's deprecated `external_signature`, so that -/// `signatures` still carries it once that field goes away. -/// - Every other scheme signs `payload_bytes`, the serialized result payload, -/// because EIP-712 is an EVM and secp256k1 construction that a post-quantum -/// scheme has no reason to be bound to. -/// -/// Each job carries its own message, so such a scheme is added here without -/// touching callers or [`compute_result_signatures`]. -fn scheme_signing_jobs( - schemes: &[SigningSchemeType], - eip712_hash: &[u8], - payload_bytes: &[u8], -) -> Vec { - schemes - .iter() - .map(|&scheme| SchemeSigningJob { - scheme, - message: if scheme == SigningSchemeType::Ecdsa256k1 { - eip712_hash.to_vec() - } else { - payload_bytes.to_vec() - }, - }) - .collect() -} - /// Sign a public result: the deprecated ECDSA/EIP-712 `external_signature`, and — /// independently — the per-scheme `signatures` for exactly the schemes the /// client requested. @@ -621,8 +509,13 @@ fn sign_result( let eip712_hash = sol_type.eip712_signing_hash(domain); let external_signature = crate::cryptography::signatures::eip712_sign_hash(identity.ecdsa(), &eip712_hash)?; - let jobs = scheme_signing_jobs(schemes, eip712_hash.as_slice(), payload_bytes); - let signatures = compute_result_signatures(identity, dsep, &jobs)?; + let signatures = crate::cryptography::signing::composite::sign_result_entries( + identity, + schemes, + dsep, + eip712_hash.as_slice(), + payload_bytes, + )?; Ok((external_signature, signatures)) } @@ -2125,54 +2018,6 @@ pub(crate) mod tests { } } - /// Each job carries its own message, so schemes can be given distinct - /// serializations of the same result. This is what lets a future - /// scheme-specific encoding be introduced in [`super::scheme_signing_jobs`] - /// without changing [`super::compute_result_signatures`]. - #[test] - fn scheme_signatures_honour_per_scheme_messages() { - use super::SchemeSigningJob; - - let mut rng = AesRng::seed_from_u64(0x9E11); - let (_pk, sk) = gen_sig_keys(&mut rng); - let sk = NodeSigningIdentity::new(sk, RootSigningSeed::random(&mut rng)); - let dsep = b"PERSCHEM"; - - let ed_msg = b"serialization chosen for ed25519".to_vec(); - let mldsa_msg = b"a different serialization chosen for ml-dsa".to_vec(); - - let jobs = vec![ - SchemeSigningJob { - scheme: SigningSchemeType::Ed25519, - message: ed_msg.clone(), - }, - SchemeSigningJob { - scheme: SigningSchemeType::MlDsa65, - message: mldsa_msg.clone(), - }, - ]; - - let sigs = super::compute_result_signatures(&sk, dsep, &jobs).unwrap(); - assert_eq!(sigs.len(), 2); - - for (job, scheme_sig) in jobs.iter().zip(&sigs) { - let vk = sk.unified_verifying_key(job.scheme).unwrap(); - let sig = Signature::new(job.scheme, scheme_sig.signature.clone()); - - // Each signature verifies against *its own* message... - unified_verify(dsep, &job.message, &sig, &vk) - .unwrap_or_else(|e| panic!("{:?} should verify its own message: {e}", job.scheme)); - - // ...and not against the other job's message. - let other = if job.message == ed_msg { - &mldsa_msg - } else { - &ed_msg - }; - assert!(unified_verify(dsep, other, &sig, &vk).is_err()); - } - } - /// `external_signature` is always the EIP-712 signature, independent of the /// requested schemes, while `signatures` holds exactly the schemes requested: /// ECDSA carries the EIP-712 signature verbatim and every other scheme signs diff --git a/core/service/src/engine/centralized/central_kms.rs b/core/service/src/engine/centralized/central_kms.rs index 7555c97312..12475cdb95 100644 --- a/core/service/src/engine/centralized/central_kms.rs +++ b/core/service/src/engine/centralized/central_kms.rs @@ -7,6 +7,7 @@ use crate::consts::{DEC_CAPACITY, MIN_DEC_CACHE}; use crate::cryptography::attestation::SecurityModuleProxy; use crate::cryptography::decompression; use crate::cryptography::encryption::UnifiedPublicEncKey; +use crate::cryptography::signatures::StoredTypedSignature; use crate::cryptography::signatures::{PrivateSigKey, PublicSigKey, Signature}; use crate::cryptography::signcryption::SigncryptFHEPlaintext; use crate::cryptography::signcryption::UnifiedSigncryptionKey; @@ -15,7 +16,6 @@ use crate::cryptography::signing::identity::NodeSigningIdentity; use crate::engine::Shutdown; use crate::engine::backup_operator::RealBackupOperator; use crate::engine::base::CrsGenMetadata; -use crate::engine::base::StoredTypedSignature; use crate::engine::base::sign_user_decryption_result; use crate::engine::base::{BaseKmsStruct, KmsFheKeyHandles}; use crate::engine::base::{KeyGenMetadata, PubDecCallValues, UserDecryptCallValues}; diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index 623826ac4a..d8f4ca18dd 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -56,7 +56,8 @@ use kms_lib::{ hybrid_ml_kem::HybridKemCt, signatures::{ NodeSigningIdentity, PrivateSigKey, PublicSigKey, RootSigningSeed, SigningSchemeSet, - SigningSchemeType, UnifiedPublicSigKey, compute_eip712_signature, gen_sig_keys, + SigningSchemeType, StoredTypedSignature, UnifiedPublicSigKey, compute_eip712_signature, + gen_sig_keys, }, signcryption::{ Signcrypt, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, @@ -68,7 +69,7 @@ use kms_lib::{ CrsGenMetadata, CrsGenMetadataInner, CrsGenMetadataInnerV2, CrsSignedPayload, KeyGenMetadata, KeyGenMetadataInner, KeygenSignedPayload, KmsFheKeyHandles, PrepKeygenSignedPayload, PublicDecSignedPayload, StoredEip712Domain, - StoredTypedSignature, UserDecSignedPayload, + UserDecSignedPayload, }, context::{ContextInfo, NodeInfo, SchemeDigests, SignerAddress, SoftwareVersion}, threshold::service::{ From 9b5402e77788510b8a06b51e8242110cb427cb19 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 11:48:43 +0200 Subject: [PATCH 014/114] chore: tightening --- .../src/cryptography/signing/composite.rs | 21 ++++++++++++++- .../engine/storage_material_verification.rs | 11 +++++++- core/service/src/engine/validation_wasm.rs | 27 ++++++++++++++----- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index f211958555..ebda73c898 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -125,6 +125,19 @@ impl CompositeSignature { } } +/// The bytes a non-ECDSA result signature covers: the payload, prefixed by the +/// canonical scheme set. +/// +/// `schemes` is canonicalised here, so callers may pass it in any order and +/// still agree on the bytes. +pub fn result_signed_bytes( + schemes: &[SigningSchemeType], + payload_bytes: &[u8], +) -> Result, SigningError> { + let schemes = SigningSchemeSet::new(schemes.iter().copied())?; + Ok(CompositeSignature::preimage(&schemes, payload_bytes)) +} + /// The per-scheme signatures of a *result*: a keygen, CRS, preprocessing or /// decryption response. /// @@ -145,6 +158,12 @@ pub fn sign_result_entries( eip712_hash: &[u8], payload_bytes: &[u8], ) -> Result, SigningError> { + if schemes.is_empty() { + return Ok(Vec::new()); + } + // Every non-ECDSA entry commits to the scheme set, so one cannot be lifted + // out of a larger response and presented as a complete smaller one. + let signed = result_signed_bytes(schemes, payload_bytes)?; schemes .iter() .map(|&scheme| { @@ -160,7 +179,7 @@ pub fn sign_result_entries( .map_err(|e| SigningError::Sign(e.to_string()))? } _ => identity - .unified_sign_with(scheme, dsep, payload_bytes)? + .unified_sign_with(scheme, dsep, &signed)? .to_bytes(), }; Ok(StoredTypedSignature { scheme, signature }) diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index 1470525124..44718ee1dc 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -541,6 +541,15 @@ fn verify_metadata_signatures( )?; } + // The set is derived from the stored entries rather than requested from + // outside: at boot there is no request, and the question is whether the + // stored list is intact. + let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); + let signed_bytes = crate::cryptography::signing::composite::result_signed_bytes( + &stored_schemes, + payload_bytes, + )?; + for stored in signatures { match stored.scheme { SigningSchemeType::Ecdsa256k1 => { @@ -561,7 +570,7 @@ fn verify_metadata_signatures( ) })?; let signature = Signature::new(scheme, stored.signature.clone()); - unified_verify(dsep, payload_bytes, &signature, &verf_key).map_err(|e| { + unified_verify(dsep, &signed_bytes, &signature, &verf_key).map_err(|e| { anyhow::anyhow!( "Invalid {scheme} signature in private {metadata_kind} metadata for id={metadata_id}: {e}" ) diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 1b9e535a29..f84a501d22 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -200,7 +200,7 @@ pub(crate) fn verify_scheme_entry( scheme: SigningSchemeType, signature: &[u8], dsep: &DomainSep, - payload_bytes: &[u8], + signed_bytes: &[u8], ) -> anyhow::Result<()> { debug_assert_ne!( scheme, @@ -214,7 +214,7 @@ pub(crate) fn verify_scheme_entry( )) })?; let signature = Signature::new(scheme, signature.to_vec()); - unified_verify(dsep, payload_bytes, &signature, verf_key).map_err(|e| { + unified_verify(dsep, signed_bytes, &signature, verf_key).map_err(|e| { anyhow_tracked(format!( "the {scheme} signature of party {party_id} did not verify: {e}" )) @@ -354,6 +354,7 @@ fn attribute_scheme_entry( signature: &[u8], scheme: SigningSchemeType, payloads: &SignedPayloads, + signed_bytes: &[u8], expected: &ExpectedSigner, keys: &SchemeVerfKeys, signer: Option<(u32, Address)>, @@ -365,7 +366,7 @@ fn attribute_scheme_entry( scheme, signature, payloads.dsep, - payloads.payload_bytes, + signed_bytes, ) }; match expected { @@ -387,8 +388,7 @@ fn attribute_scheme_entry( keys.iter() .find_map(|(party_id, party_keys)| { let verf_key = party_keys.get(&scheme)?; - unified_verify(payloads.dsep, payloads.payload_bytes, &parsed, verf_key) - .ok()?; + unified_verify(payloads.dsep, signed_bytes, &parsed, verf_key).ok()?; addresses.get(party_id).map(|address| (*party_id, *address)) }) .ok_or_else(|| { @@ -443,6 +443,13 @@ pub(crate) fn verify_response_signatures( .to_string(), )); } + // Bound to the set this verifier requested, so an entry lifted from a + // response signed under a larger set does not verify here. + let signed_bytes = crate::cryptography::signing::composite::result_signed_bytes( + requested, + payloads.payload_bytes, + ) + .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))?; let mut verified: Vec = Vec::with_capacity(sigs.list.len() + 1); let mut signer: Option<(u32, Address)> = None; @@ -528,7 +535,15 @@ pub(crate) fn verify_response_signatures( }; expected.attribute(recover_address_from_eip712_hash(hash, &typed.signature)?)? } else { - attribute_scheme_entry(&typed.signature, scheme, payloads, expected, keys, signer)? + attribute_scheme_entry( + &typed.signature, + scheme, + payloads, + &signed_bytes, + expected, + keys, + signer, + )? }; signer = Some(agree(signer, found)?); push_once(&mut verified, scheme); From 0e83399d78b27c954e746e0b4c2b9357ab5b4603 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 14:00:55 +0200 Subject: [PATCH 015/114] refactor: removed unneeded code --- .../data/0_15_0/kms/signing_scheme_set.bcode | 3 - backward-compatibility/data/kms.ron | 11 - .../generate-v0.15.0/src/data_0_15.rs | 30 +- backward-compatibility/src/lib.rs | 32 -- core/service/src/backup/custodian.rs | 22 +- core/service/src/backup/mod.rs | 37 +- core/service/src/backup/operator.rs | 23 +- core/service/src/cryptography/signatures.rs | 6 +- .../src/cryptography/signcryption/common.rs | 61 +-- .../cryptography/signcryption/composite_v1.rs | 365 +++++++----------- .../src/cryptography/signcryption/ecdsa_v0.rs | 13 +- .../src/cryptography/signcryption/mod.rs | 208 ++++++---- .../src/cryptography/signing/composite.rs | 179 +++++++-- core/service/src/cryptography/signing/mod.rs | 16 +- .../src/cryptography/signing/scheme_set.rs | 314 --------------- .../src/cryptography/signing/verf_key_set.rs | 34 +- core/service/src/engine/backup_operator.rs | 6 +- core/service/src/vault/storage/mod.rs | 6 +- .../tests/backward_compatibility_kms.rs | 46 +-- core/service/tests/versioned_enum_coverage.rs | 3 + 20 files changed, 499 insertions(+), 916 deletions(-) delete mode 100644 backward-compatibility/data/0_15_0/kms/signing_scheme_set.bcode delete mode 100644 core/service/src/cryptography/signing/scheme_set.rs diff --git a/backward-compatibility/data/0_15_0/kms/signing_scheme_set.bcode b/backward-compatibility/data/0_15_0/kms/signing_scheme_set.bcode deleted file mode 100644 index a08dd1bad5..0000000000 --- a/backward-compatibility/data/0_15_0/kms/signing_scheme_set.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a77e66f46f595d4ab0f3e2dd152ca40a8692f04ce1a7cdc16c80a88615b05276 -size 16 diff --git a/backward-compatibility/data/kms.ron b/backward-compatibility/data/kms.ron index bb5938a836..0bf7539ccb 100644 --- a/backward-compatibility/data/kms.ron +++ b/backward-compatibility/data/kms.ron @@ -1803,17 +1803,6 @@ state: 202, )), ), - ( - kms_core_version_min: "0.15.0", - kms_core_module: "kms", - metadata: SigningSchemeSet(( - test_filename: "signing_scheme_set", - schemes: [ - 0, - 4, - ], - )), - ), ( kms_core_version_min: "0.15.0", kms_core_module: "kms", diff --git a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs index c929e28ae1..acf3fb262e 100644 --- a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs +++ b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs @@ -113,7 +113,7 @@ use backward_compatibility::{ PrepKeygenSignedPayloadTest, PrfKeyTest, PrivDataTypeTest, PrivateSigKeyTest, PrssSetTest, PrssSetupCombinedTest, PubDataTypeTest, PublicDecSignedPayloadTest, PublicSigKeyTest, RecoveryValidationMaterialTest, ReleasePCRValuesTest, RootSigningSeedTest, SchemeDigestsTest, - ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SigningSchemeSetTest, + ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SoftwareVersionTest, StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataDD, TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedCipherTest, UnifiedPublicSigKeyTest, UnifiedSigncryptionKeyTest, @@ -506,14 +506,6 @@ const UNIFIED_SIGNCRYPTION_TEST: UnifiedSigncryptionTest = UnifiedSigncryptionTe state: 202, }; -// KMS test -const SIGNING_SCHEME_SET_TEST: SigningSchemeSetTest = SigningSchemeSetTest { - test_filename: Cow::Borrowed("signing_scheme_set"), - // The composite pair custodian backup signs under, given as wire - // discriminants: Ecdsa256k1 and MlDsa87. - schemes: Cow::Borrowed(&[0, 4]), -}; - // KMS test const BACKUP_CIPHERTEXT_TEST: BackupCiphertextTest = BackupCiphertextTest { test_filename: Cow::Borrowed("backup_ciphertext"), @@ -1192,25 +1184,6 @@ impl KmsV0_15_0 { TestMetadataKMS::UnifiedSigncryption(UNIFIED_SIGNCRYPTION_TEST) } - fn gen_signing_scheme_set(dir: &PathBuf) -> TestMetadataKMS { - // Built from the declared wire discriminants rather than from a - // constructor shortcut, so the fixture freezes the set the metadata - // names and a later reordering of the enum cannot silently change it. - let schemes = SIGNING_SCHEME_SET_TEST - .schemes - .iter() - .map(|wire| { - SigningSchemeType::try_from(*wire) - .expect("the test metadata names a known signing scheme") - }) - .collect::>(); - let set = - SigningSchemeSet::new(schemes).expect("the test metadata names at least one scheme"); - - store_versioned_test!(&set, dir, &SIGNING_SCHEME_SET_TEST.test_filename); - TestMetadataKMS::SigningSchemeSet(SIGNING_SCHEME_SET_TEST) - } - fn gen_unified_cipher(dir: &PathBuf) -> TestMetadataKMS { let mut rng = AesRng::seed_from_u64(UNIFIED_CIPHER_TEST.state); let mut kem_ct = [0_u8; 32]; @@ -2205,7 +2178,6 @@ impl KMSCoreVersion for V0_15_0 { KmsV0_15_0::gen_mlkem1024_p384_public_key(&dir), KmsV0_15_0::gen_mlkem1024_p384_private_key(&dir), KmsV0_15_0::gen_unified_signcryption(&dir), - KmsV0_15_0::gen_signing_scheme_set(&dir), KmsV0_15_0::gen_backup_ciphertext(&dir), KmsV0_15_0::gen_unified_cipher(&dir), KmsV0_15_0::gen_hybrid_kem_ct(&dir), diff --git a/backward-compatibility/src/lib.rs b/backward-compatibility/src/lib.rs index 20ef0a39ac..c23c529068 100644 --- a/backward-compatibility/src/lib.rs +++ b/backward-compatibility/src/lib.rs @@ -786,37 +786,6 @@ impl TestType for MlKem1024P384PrivateKeyTest { } } -/// Test metadata for SigningSchemeSet backward compatibility. -/// -/// A `SigningSchemeSet` is a field of the persisted `UnifiedSigncryption` and is -/// what a composite signature commits to, so a set written by one release has to -/// stay readable by the next. -/// -/// The set is described by the wire discriminants of its schemes rather than by -/// a seed, so the fixture states exactly which schemes it holds and the loader -/// can rebuild it without depending on any generator-side helper. -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct SigningSchemeSetTest { - pub test_filename: Cow<'static, str>, - /// The wire discriminants of the schemes the stored set holds, in canonical - /// order. - pub schemes: Cow<'static, [i32]>, -} - -impl TestType for SigningSchemeSetTest { - fn module(&self) -> String { - KMS_MODULE_NAME.to_string() - } - - fn target_type(&self) -> String { - "SigningSchemeSet".to_string() - } - - fn test_filename(&self) -> String { - self.test_filename.to_string() - } -} - // KMS test #[derive(Serialize, Deserialize, Clone, Debug)] pub struct UnifiedSigncryptionTest { @@ -1346,7 +1315,6 @@ pub enum TestMetadataKMS { MlKem1024P384PublicKey(MlKem1024P384PublicKeyTest), MlKem1024P384PrivateKey(MlKem1024P384PrivateKeyTest), UnifiedSigncryption(UnifiedSigncryptionTest), - SigningSchemeSet(SigningSchemeSetTest), BackupCiphertext(BackupCiphertextTest), UnifiedCipher(UnifiedCipherTest), HybridKemCt(HybridKemCtTest), diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index dc907b664e..3b3fd55ec0 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -1,4 +1,4 @@ -use crate::backup::BACKUP_PKE_SCHEME; +use crate::backup::{BACKUP_PKE_SCHEME, backup_format_from_wire, backup_format_to_wire}; use crate::backup::operator::DSEP_BACKUP_MATERIAL; use crate::cryptography::{ encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}, @@ -11,7 +11,7 @@ use crate::cryptography::{ use crate::engine::validation::{RequestIdParsingErr, parse_optional_grpc_request_id}; use crate::{ consts::SAFE_SER_SIZE_LIMIT, - cryptography::signatures::{PublicSigKey, SigningSchemeSet}, + cryptography::signatures::PublicSigKey, }; use hashing::DomainSep; use kms_grpc::RequestId; @@ -79,7 +79,7 @@ impl TryFrom for InternalCustodianRecoveryOutput { signcryption: UnifiedSigncryption::new( backup_output.signcryption.clone(), backup_output.pke_type.try_into()?, - SigningSchemeSet::single(backup_output.signing_type.try_into()?), + backup_format_from_wire(backup_output.signing_type)?, ), custodian_role: Role::indexed_from_one(value.custodian_role as usize), }) @@ -90,21 +90,7 @@ impl TryFrom for CustodianRecoveryOutput { type Error = anyhow::Error; fn try_from(value: InternalCustodianRecoveryOutput) -> Result { - // TODO stop gap - // `OperatorBackupOutput` has room for a single signing scheme, so a - // composite signcryption cannot be described by it. Fail rather than - // name one of its schemes and silently drop the rest. - let signing_type = value - .signcryption - .sole_signing_scheme() - .ok_or_else(|| { - anyhow::anyhow!( - "cannot represent a signcryption signed under {} in an OperatorBackupOutput, \ - which carries a single signing scheme", - value.signcryption.signing_schemes - ) - })? - .as_wire(); + let signing_type = backup_format_to_wire(value.signcryption.format)?; Ok(CustodianRecoveryOutput { backup_output: Some(OperatorBackupOutput { signcryption: value.signcryption.payload, diff --git a/core/service/src/backup/mod.rs b/core/service/src/backup/mod.rs index 84594995bb..56231947e5 100644 --- a/core/service/src/backup/mod.rs +++ b/core/service/src/backup/mod.rs @@ -8,8 +8,8 @@ pub mod error; pub mod operator; pub mod secretsharing; pub mod seed_phrase; -use crate::cryptography::signatures::SigningSchemeSet; -use crate::cryptography::signcryption::UnifiedSigncryption; +use crate::cryptography::signatures::SigningSchemeType; +use crate::cryptography::signcryption::{SigncryptionFormat, UnifiedSigncryption}; use kms_grpc::RequestId; use kms_grpc::kms::v1::OperatorBackupOutput; use tfhe::named::Named; @@ -56,19 +56,39 @@ impl Named for BackupCiphertext { const NAME: &'static str = "cryptography::BackupCiphertext"; } +/// The envelope format an [`OperatorBackupOutput`] describes. +pub(crate) fn backup_format_from_wire(signing_type: i32) -> anyhow::Result { + let scheme: SigningSchemeType = signing_type.try_into()?; + Ok(SigncryptionFormat::for_schemes(&[scheme])) +} + +/// The `signing_type` an [`OperatorBackupOutput`] must carry for `format`. +/// +/// TODO stop gap: the proto field names a single scheme, so only the frozen +/// ECDSA layout can be described by it. Fail rather than mislabel a +/// multi-signature envelope as a single-scheme one. Removing this needs the +/// proto field to name a layout (or a scheme list) instead. +pub(crate) fn backup_format_to_wire(format: SigncryptionFormat) -> anyhow::Result { + match format { + SigncryptionFormat::EcdsaV0 => Ok(SigningSchemeType::Ecdsa256k1.as_wire()), + SigncryptionFormat::CompositeV1 => Err(anyhow::anyhow!( + "cannot represent a {format} signcryption in an OperatorBackupOutput, \ + whose signing_type names a single signing scheme" + )), + } +} + impl TryFrom for UnifiedSigncryption { type Error = anyhow::Error; fn try_from(value: OperatorBackupOutput) -> Result { - // Use the fallible conversion rather than prost's `pke_type()` / `signing_type()` - // accessors: those map an unrecognised discriminant to the default variant, which would - // silently relabel material as ML-KEM-512 instead of reporting the unknown scheme. + // As above for `pke_type`: the fallible conversion reports an unknown + // discriminant instead of silently relabelling it as ML-KEM-512. let pke_type = value.pke_type.try_into()?; - let signing_type = value.signing_type.try_into()?; Ok(UnifiedSigncryption::new( value.signcryption, pke_type, - SigningSchemeSet::single(signing_type), + backup_format_from_wire(value.signing_type)?, )) } } @@ -78,11 +98,10 @@ impl TryFrom<&OperatorBackupOutput> for UnifiedSigncryption { fn try_from(value: &OperatorBackupOutput) -> Result { let pke_type = value.pke_type.try_into()?; - let signing_type = value.signing_type.try_into()?; Ok(UnifiedSigncryption::new( value.signcryption.clone(), pke_type, - SigningSchemeSet::single(signing_type), + backup_format_from_wire(value.signing_type)?, )) } } diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index de5e3c0669..c281bff8e1 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -7,7 +7,7 @@ use crate::{ anyhow_error_and_log, consts::SAFE_SER_SIZE_LIMIT, cryptography::encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}, - cryptography::signatures::{PrivateSigKey, PublicSigKey, Signature, SigningSchemeSet}, + cryptography::signatures::{PrivateSigKey, PublicSigKey, Signature}, cryptography::signcryption::{ Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt, @@ -15,6 +15,7 @@ use crate::{ }; use crate::{ backup::custodian::DSEP_BACKUP_CUSTODIAN, + backup::{backup_format_from_wire, backup_format_to_wire}, cryptography::signatures::{internal_sign, internal_verify_sig}, }; use crate::{ @@ -181,7 +182,7 @@ impl TryFrom for InnerOperatorBackupOutput { signcryption: UnifiedSigncryption::new( value.signcryption, value.pke_type.try_into()?, - SigningSchemeSet::single(value.signing_type.try_into()?), + backup_format_from_wire(value.signing_type)?, ), }) } @@ -190,21 +191,7 @@ impl TryFrom for OperatorBackupOutput { type Error = anyhow::Error; fn try_from(value: InnerOperatorBackupOutput) -> Result { - // TODO stop gap - // `OperatorBackupOutput` has room for a single signing scheme, so a - // composite signcryption cannot be described by it. Fail rather than - // name one of its schemes and silently drop the rest. - let signing_type = value - .signcryption - .sole_signing_scheme() - .ok_or_else(|| { - anyhow::anyhow!( - "cannot represent a signcryption signed under {} in an OperatorBackupOutput, \ - which carries a single signing scheme", - value.signcryption.signing_schemes - ) - })? - .as_wire(); + let signing_type = backup_format_to_wire(value.signcryption.format)?; Ok(Self { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, @@ -994,7 +981,7 @@ mod tests { signcryption: UnifiedSigncryption::new( vec![1, 2, 3], BACKUP_PKE_SCHEME, - SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + SigncryptionFormat::EcdsaV0, ), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index a0d5e90135..c1c5758047 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -18,9 +18,9 @@ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ CompositeSignature, CompositeSignatureVersions, HasSigningScheme, SchemeVerfKeys, Signature, - SigningError, SigningSchemeSet, SigningSchemeType, SigningSchemeTypeVersions, - StoredTypedSignature, StoredTypedSignatureVersions, UnifiedPublicSigKey, - UnifiedPublicSigKeyVersions, VerfKeySet, + SigningError, SigningSchemeType, SigningSchemeTypeVersions, StoredTypedSignature, + StoredTypedSignatureVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, + canonical_schemes, }; pub(crate) use crate::cryptography::signing::ecdsa::{ diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index 48421cfaa7..f2f2fd05d8 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -9,41 +9,15 @@ use crate::cryptography::encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey} use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_composite_ml_kem; use crate::cryptography::hybrid_ml_kem::{self, HybridKemCt}; -use crate::cryptography::signatures::{SigningSchemeSet, SigningSchemeType}; use hashing::{DomainSep, serialize_hash_element}; use rand::{CryptoRng, RngCore}; use zeroize::Zeroizing; pub(super) const DSEP_SIGNCRYPTION: DomainSep = *b"SIGNCRYP"; -/// The layout of a signcryption's encrypted plaintext. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum SigncryptionFormat { - /// `msg ‖ sig ‖ H(sender verification key)`, ECDSA only. Frozen; see the - /// module documentation. - EcdsaV0, - /// A self-describing, versioned, multi-signature envelope. - CompositeV1, -} - -/// The format a signcryption under `schemes` uses. -/// -/// A total function of the scheme set, deliberately. The alternative — sniffing -/// the decrypted bytes for a magic prefix — cannot work: an -/// [`SigncryptionFormat::EcdsaV0`] plaintext begins with attacker-influenced -/// message content and has no tag to find, so detection would be a heuristic, in -/// a parser sitting directly under a decryption key. -pub(super) fn format_for(schemes: &SigningSchemeSet) -> SigncryptionFormat { - if schemes.as_slice() == [SigningSchemeType::Ecdsa256k1].as_slice() { - SigncryptionFormat::EcdsaV0 - } else { - SigncryptionFormat::CompositeV1 - } -} - -/// The error for a scheme set no implemented envelope format covers. -pub(super) fn unsupported_format(schemes: &SigningSchemeSet) -> CryptographyError { - CryptographyError::UnsupportedSigncryptionFormat(schemes.to_string()) +/// The error for something no implemented envelope format covers. +pub(super) fn unsupported_format(what: impl std::fmt::Display) -> CryptographyError { + CryptographyError::UnsupportedSigncryptionFormat(what.to_string()) } /// The digest of the receiver's public encryption key, as it appears in the @@ -182,33 +156,4 @@ mod tests { ); } - /// The singleton ECDSA set — and only it — selects the frozen layout. This - /// is what keeps user decryption on `EcdsaV0` without a dedicated branch. - #[test] - fn only_the_ecdsa_singleton_is_the_legacy_format() { - use strum::IntoEnumIterator; - - assert_eq!( - format_for(&SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1)), - SigncryptionFormat::EcdsaV0 - ); - - for scheme in SigningSchemeType::iter().filter(|s| *s != SigningSchemeType::Ecdsa256k1) { - assert_eq!( - format_for(&SigningSchemeSet::single(scheme)), - SigncryptionFormat::CompositeV1, - "{scheme} alone must not select the frozen ECDSA layout" - ); - } - - // Adding any scheme to ECDSA leaves the legacy format behind, which is - // what stops a composite signcryption being parsed as a legacy one. - assert_eq!( - format_for( - &SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]) - .unwrap() - ), - SigncryptionFormat::CompositeV1 - ); - } } diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 1fa9eb6d27..748260350f 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -1,23 +1,16 @@ //! The composite signcryption envelope: one signature per scheme. -use super::common::{ - SigncryptionFormat, format_for, hybrid_decrypt, hybrid_encrypt, receiver_binding, - unsupported_format, -}; -use super::{Signcrypt, UnifiedSigncryption, Unsigncrypt}; +use super::common::{hybrid_decrypt, hybrid_encrypt, receiver_binding, unsupported_format}; +use super::{SigncryptionFormat, UnifiedSigncryption}; use crate::consts::SAFE_SER_SIZE_LIMIT; -use crate::cryptography::encryption::{ - HasPkeScheme, PkeSchemeType, UnifiedPrivateEncKey, UnifiedPublicEncKey, -}; +use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; use crate::cryptography::signatures::{ - CompositeSignature, NodeSigningIdentity, SigningSchemeSet, VerfKeySet, + CompositeSignature, NodeSigningIdentity, SigningSchemeType, VerfKeySet, }; -use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; use rand::{CryptoRng, RngCore}; -use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tfhe::named::Named; use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; @@ -31,7 +24,7 @@ pub enum CompositeEnvelopeVersions { /// The plaintext of a composite signcryption, before encryption. /// -/// `signature` carries its own scheme set, so this struct does not repeat it. +/// `signature` carries its own schemes, so this struct does not repeat them. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Versionize)] #[versionize(CompositeEnvelopeVersions)] pub struct CompositeEnvelope { @@ -43,88 +36,20 @@ impl Named for CompositeEnvelope { const NAME: &'static str = "signcryption::CompositeEnvelope"; } -/// The signing half of a composite signcryption. -/// -/// Holds a [`NodeSigningIdentity`] rather than a single key, and is not -/// persisted — deliberately, since the underlying identity is a in-memory structure. -#[derive(Clone, Debug)] -pub struct CompositeSigncryptionKey<'a> { - pub identity: &'a NodeSigningIdentity, - pub schemes: &'a SigningSchemeSet, - pub receiver_enc_key: &'a UnifiedPublicEncKey, - pub receiver_id: &'a [u8], -} - -impl<'a> CompositeSigncryptionKey<'a> { - pub fn new( - identity: &'a NodeSigningIdentity, - schemes: &'a SigningSchemeSet, - receiver_enc_key: &'a UnifiedPublicEncKey, - receiver_id: &'a [u8], - ) -> Self { - Self { - identity, - schemes, - receiver_enc_key, - receiver_id, - } - } -} - -impl HasPkeScheme for CompositeSigncryptionKey<'_> { - fn encryption_scheme_type(&self) -> PkeSchemeType { - self.receiver_enc_key.encryption_scheme_type() - } -} - -/// The verifying half of a composite signcryption. -/// -/// `expected_schemes` is not optional, and it is the verifier's policy rather -/// than anything read off the message. -/// -/// The set inside the envelope is authenticated, so it says truthfully which -/// schemes signed. -#[derive(Clone, Debug)] -pub struct CompositeUnsigncryptionKey<'a> { - pub decryption_key: &'a UnifiedPrivateEncKey, - pub encryption_key: &'a UnifiedPublicEncKey, - pub sender_keys: &'a VerfKeySet, - pub expected_schemes: &'a SigningSchemeSet, - pub receiver_id: &'a [u8], -} - -impl<'a> CompositeUnsigncryptionKey<'a> { - pub fn new( - decryption_key: &'a UnifiedPrivateEncKey, - encryption_key: &'a UnifiedPublicEncKey, - sender_keys: &'a VerfKeySet, - expected_schemes: &'a SigningSchemeSet, - receiver_id: &'a [u8], - ) -> Self { - Self { - decryption_key, - encryption_key, - sender_keys, - expected_schemes, - receiver_id, - } - } -} - -impl HasPkeScheme for CompositeUnsigncryptionKey<'_> { - fn encryption_scheme_type(&self) -> PkeSchemeType { - self.encryption_key.encryption_scheme_type() - } -} - /// Signcrypt `msg` in the composite layout. /// /// Sign-then-encrypt, as in the frozen layout: each signature covers /// `dsep ‖ schemes ‖ msg ‖ receiver_id ‖ H(receiver enc key)`, and the whole /// envelope is then encrypted to the receiver. #[cfg(feature = "non-wasm")] -pub(super) fn seal( - signcrypt_key: &CompositeSigncryptionKey, +#[allow(unknown_lints)] +// We allow modifying the rng before return +#[allow(non_local_effect_before_error_return)] +pub fn seal( + identity: &NodeSigningIdentity, + schemes: &[SigningSchemeType], + receiver_enc_key: &UnifiedPublicEncKey, + receiver_id: &[u8], rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, msg: &[u8], @@ -132,18 +57,14 @@ pub(super) fn seal( // Refuse to produce a composite envelope under a scheme set that selects the // frozen layout: it would be tagged `EcdsaV0` and handed to a parser that // cannot read it. - if format_for(signcrypt_key.schemes) != SigncryptionFormat::CompositeV1 { - return Err(unsupported_format(signcrypt_key.schemes)); + let format = SigncryptionFormat::for_schemes(schemes); + if format != SigncryptionFormat::CompositeV1 { + return Err(unsupported_format(format)); } - let binding = receiver_binding(signcrypt_key.receiver_id, signcrypt_key.receiver_enc_key)?; + let binding = receiver_binding(receiver_id, receiver_enc_key)?; let signed = Zeroizing::new([msg, binding.as_slice()].concat()); - let signature = CompositeSignature::sign_uniform( - signcrypt_key.identity, - signcrypt_key.schemes, - dsep, - &signed, - )?; + let signature = CompositeSignature::sign_uniform(identity, schemes, dsep, &signed)?; let envelope = CompositeEnvelope { msg: msg.to_vec(), @@ -154,110 +75,59 @@ pub(super) fn seal( .map_err(|e| CryptographyError::SerializationError(e.to_string()))?; let plaintext = Zeroizing::new(plaintext); - let ciphertext = hybrid_encrypt(rng, &plaintext, signcrypt_key.receiver_enc_key)?; + let ciphertext = hybrid_encrypt(rng, &plaintext, receiver_enc_key)?; Ok(UnifiedSigncryption::new( bc2wrap::serialize(&ciphertext) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, - signcrypt_key.encryption_scheme_type(), - signcrypt_key.schemes.clone(), + receiver_enc_key.encryption_scheme_type(), + format, )) } /// Open a composite signcryption, returning the message only once every /// constituent signature has verified. -pub(super) fn open( - unsign_key: &CompositeUnsigncryptionKey, +/// +/// `expected_schemes` is the verifier's policy, not anything read off the +/// message. +pub fn open( + decryption_key: &UnifiedPrivateEncKey, + encryption_key: &UnifiedPublicEncKey, + sender_keys: &VerfKeySet, + expected_schemes: &[SigningSchemeType], + receiver_id: &[u8], dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { - if cipher.pke_type != unsign_key.encryption_key.encryption_scheme_type() { + if cipher.pke_type != encryption_key.encryption_scheme_type() { return Err(CryptographyError::VerificationError( "encryption type of cipher does not match the decryption key type".to_string(), )); } let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - let plaintext = hybrid_decrypt(kem_ct, unsign_key.decryption_key)?; + let plaintext = hybrid_decrypt(kem_ct, decryption_key)?; let envelope: CompositeEnvelope = safe_deserialize(std::io::Cursor::new(&*plaintext), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError)?; - let binding = receiver_binding(unsign_key.receiver_id, unsign_key.encryption_key)?; + let binding = receiver_binding(receiver_id, encryption_key)?; let signed = Zeroizing::new([envelope.msg.as_slice(), binding.as_slice()].concat()); envelope .signature - .verify_uniform( - unsign_key.sender_keys, - unsign_key.expected_schemes, - dsep, - &signed, - ) + .verify_uniform(sender_keys, expected_schemes, dsep, &signed) .map_err(|e| CryptographyError::VerificationError(e.to_string()))?; Ok(Zeroizing::new(envelope.msg)) } -/// Serialize `msg` and signcrypt it in the composite layout. -/// -/// The same `Signcrypt` contract the frozen path implements, so a caller is -/// polymorphic over the two and the format follows from the key type it holds. -#[cfg(feature = "non-wasm")] -impl<'a> Signcrypt for CompositeSigncryptionKey<'a> { - #[allow(unknown_lints)] - // We allow modifying the rng before return - #[allow(non_local_effect_before_error_return)] - fn signcrypt( - &self, - rng: &mut (impl CryptoRng + RngCore), - dsep: &DomainSep, - msg: &T, - ) -> Result - where - T: Serialize + tfhe::Versionize + tfhe::named::Named, - { - let mut serialized_msg = ZeroizingWriter::new(); - safe_serialize(msg, &mut serialized_msg, SAFE_SER_SIZE_LIMIT).map_err(|e| { - CryptographyError::SerializationError(format!( - "Could not serialize message for signcryption: {e}", - )) - })?; - seal(self, rng, dsep, serialized_msg.as_slice()) - } -} - -impl<'a> Unsigncrypt for CompositeUnsigncryptionKey<'a> { - fn unsigncrypt( - &self, - dsep: &DomainSep, - cipher: &UnifiedSigncryption, - ) -> Result { - let msg = open(self, dsep, cipher)?; - safe_deserialize(std::io::Cursor::new(&*msg), SAFE_SER_SIZE_LIMIT) - .map_err(CryptographyError::SerializationError) - } - - fn validate_signcryption( - &self, - dsep: &DomainSep, - signcryption: &UnifiedSigncryption, - ) -> Result<(), CryptographyError> { - // Sign-then-encrypt, so authenticity is only established by decrypting. - let _ = open(self, dsep, signcryption).map_err(|e| { - CryptographyError::VerificationError(format!( - "failed to decrypt signcryption for validation: {e}" - )) - })?; - Ok(()) - } -} - #[cfg(test)] mod tests { use super::super::common::lock_fixture; - use super::super::{UnifiedSigncryptionKey, UnifiedUnsigncryptionKey}; + use super::super::{ + Signcrypt, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt, + }; use super::*; use crate::cryptography::encryption::PkeSchemeType; - use crate::cryptography::signatures::SigningSchemeType; use crate::cryptography::signing::test_support::seeded_identity; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; @@ -265,15 +135,15 @@ mod tests { const DSEP: &DomainSep = b"COMPV1TT"; - fn pair() -> SigningSchemeSet { - SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]).unwrap() + fn pair() -> Vec { + vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87] } struct Fixture { rng: AesRng, identity: NodeSigningIdentity, keys: VerfKeySet, - schemes: SigningSchemeSet, + schemes: Vec, dec_key: UnifiedPrivateEncKey, enc_key: UnifiedPublicEncKey, receiver_id: Vec, @@ -297,16 +167,32 @@ mod tests { } fn seal_msg(f: &mut Fixture, msg: &[u8]) -> UnifiedSigncryption { - let key = - CompositeSigncryptionKey::new(&f.identity, &f.schemes, &f.enc_key, &f.receiver_id); - seal(&key, &mut f.rng, DSEP, msg).unwrap() + seal( + &f.identity, + &f.schemes, + &f.enc_key, + &f.receiver_id, + &mut f.rng, + DSEP, + msg, + ) + .unwrap() } - fn unsign_key<'a>( - f: &'a Fixture, - expected: &'a SigningSchemeSet, - ) -> CompositeUnsigncryptionKey<'a> { - CompositeUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &f.keys, expected, &f.receiver_id) + fn open_with( + f: &Fixture, + expected: &[SigningSchemeType], + cipher: &UnifiedSigncryption, + ) -> Result>, CryptographyError> { + open( + &f.dec_key, + &f.enc_key, + &f.keys, + expected, + &f.receiver_id, + DSEP, + cipher, + ) } /// Round-trips for both PKE schemes the backup and user-decryption paths use. @@ -316,29 +202,33 @@ mod tests { let mut f = fixture(scheme, 100); let cipher = seal_msg(&mut f, b"a composite message"); assert_eq!(cipher.pke_type, scheme); - assert_eq!(cipher.signing_schemes, pair()); + assert_eq!(cipher.format, SigncryptionFormat::CompositeV1); - let schemes = pair(); - let opened = open(&unsign_key(&f, &schemes), DSEP, &cipher).unwrap(); + let opened = open_with(&f, &pair(), &cipher).unwrap(); assert_eq!(&*opened, b"a composite message", "{scheme}"); } } /// The headline property at the envelope level: a verifier that demands the - /// pair rejects a signcryption claiming only ECDSA, before decrypting. + /// pair rejects a signcryption made under a weaker one. #[test] fn a_signature_under_another_scheme_set_is_rejected() { let mut f = fixture(PkeSchemeType::MlKem512, 200); // The sender signs under a weaker pair; using MlDsa44 instead of MlDsa87. - let weaker = - SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa44]) - .unwrap(); - let key = CompositeSigncryptionKey::new(&f.identity, &weaker, &f.enc_key, &f.receiver_id); - let cipher = seal(&key, &mut f.rng, DSEP, b"downgrade me").unwrap(); - - let required = pair(); - let err = open(&unsign_key(&f, &required), DSEP, &cipher).unwrap_err(); + let weaker = vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa44]; + let cipher = seal( + &f.identity, + &weaker, + &f.enc_key, + &f.receiver_id, + &mut f.rng, + DSEP, + b"downgrade me", + ) + .unwrap(); + + let err = open_with(&f, &pair(), &cipher).unwrap_err(); assert!( matches!(err, CryptographyError::VerificationError(_)), "{err}" @@ -348,31 +238,42 @@ mod tests { // exactly what was signed, confirming the rejection above is the policy // check and not an unrelated failure. let weaker_keys = VerfKeySet::from_identity(&f.identity, &weaker).unwrap(); - let permissive = CompositeUnsigncryptionKey::new( + let opened = open( &f.dec_key, &f.enc_key, &weaker_keys, &weaker, &f.receiver_id, - ); - assert_eq!(&*open(&permissive, DSEP, &cipher).unwrap(), b"downgrade me"); + DSEP, + &cipher, + ) + .unwrap(); + assert_eq!(&*opened, b"downgrade me"); } - /// The outer scheme tag is unauthenticated so altering it changes nothing: - /// the authoritative set is the one inside the signed envelope. + /// The outer format tag is unauthenticated, and it is only a parser + /// selector: the composite opener ignores it, and the schemes that matter + /// are the ones inside the signed envelope. #[test] - fn the_outer_scheme_tag_is_not_load_bearing() { + fn the_outer_format_tag_is_not_load_bearing() { let mut f = fixture(PkeSchemeType::MlKem512, 250); let mut cipher = seal_msg(&mut f, b"tag is only a hint"); - cipher.signing_schemes = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); + cipher.format = SigncryptionFormat::EcdsaV0; - let schemes = pair(); assert_eq!( - &*open(&unsign_key(&f, &schemes), DSEP, &cipher).unwrap(), + &*open_with(&f, &pair(), &cipher).unwrap(), b"tag is only a hint" ); } + /// An empty policy must not open anything. + #[test] + fn an_empty_expected_set_is_rejected() { + let mut f = fixture(PkeSchemeType::MlKem512, 260); + let cipher = seal_msg(&mut f, b"needs a policy"); + assert!(open_with(&f, &[], &cipher).is_err()); + } + /// A composite envelope must not be openable by the frozen ECDSA reader, and /// the frozen layout must not be openable as composite. #[test] @@ -409,9 +310,8 @@ mod tests { let frozen = ecdsa_key .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) .unwrap(); - let schemes = pair(); assert!( - open(&unsign_key(&f, &schemes), DSEP, &frozen).is_err(), + open_with(&f, &pair(), &frozen).is_err(), "the composite reader accepted a frozen envelope" ); } @@ -421,10 +321,16 @@ mod tests { #[test] fn sealing_under_the_ecdsa_singleton_is_refused() { let mut f = fixture(PkeSchemeType::MlKem512, 400); - let single = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); - let key = CompositeSigncryptionKey::new(&f.identity, &single, &f.enc_key, &f.receiver_id); assert!(matches!( - seal(&key, &mut f.rng, DSEP, b"nope"), + seal( + &f.identity, + &[SigningSchemeType::Ecdsa256k1], + &f.enc_key, + &f.receiver_id, + &mut f.rng, + DSEP, + b"nope", + ), Err(CryptographyError::UnsupportedSigncryptionFormat(_)) )); } @@ -440,15 +346,18 @@ mod tests { let mut rng = AesRng::seed_from_u64(999); let other = seeded_identity(&mut rng); let other_keys = VerfKeySet::from_identity(&other, &pair()).unwrap(); - let schemes = pair(); - let key = CompositeUnsigncryptionKey::new( - &f.dec_key, - &f.enc_key, - &other_keys, - &schemes, - &f.receiver_id, + assert!( + open( + &f.dec_key, + &f.enc_key, + &other_keys, + &pair(), + &f.receiver_id, + DSEP, + &cipher, + ) + .is_err() ); - assert!(open(&key, DSEP, &cipher).is_err()); } /// The receiver binding is covered by the signature, so opening with a @@ -458,23 +367,41 @@ mod tests { let mut f = fixture(PkeSchemeType::MlKem512, 600); let cipher = seal_msg(&mut f, b"bound to a receiver"); - let schemes = pair(); let other_id = b"a different receiver".to_vec(); - let key = - CompositeUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &f.keys, &schemes, &other_id); - assert!(open(&key, DSEP, &cipher).is_err()); + assert!( + open( + &f.dec_key, + &f.enc_key, + &f.keys, + &pair(), + &other_id, + DSEP, + &cipher, + ) + .is_err() + ); } #[test] fn a_tampered_ciphertext_or_dsep_fails() { let mut f = fixture(PkeSchemeType::MlKem512, 700); let cipher = seal_msg(&mut f, b"tamper with me"); - let schemes = pair(); let mut flipped = cipher.clone(); flipped.payload[0] ^= 0x01; - assert!(open(&unsign_key(&f, &schemes), DSEP, &flipped).is_err()); + assert!(open_with(&f, &pair(), &flipped).is_err()); - assert!(open(&unsign_key(&f, &schemes), b"OTHERDSP", &cipher).is_err()); + assert!( + open( + &f.dec_key, + &f.enc_key, + &f.keys, + &pair(), + &f.receiver_id, + b"OTHERDSP", + &cipher, + ) + .is_err() + ); } } diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 76c09a5d9f..f82075c58b 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -9,7 +9,8 @@ use super::common::{DSEP_SIGNCRYPTION, hybrid_decrypt, hybrid_encrypt, receiver_binding}; use super::{ - SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, + SigncryptionFormat, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, + UnifiedUnsigncryptionKey, }; use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey}; use crate::cryptography::error::CryptographyError; @@ -69,7 +70,7 @@ pub(super) fn inner_signcryption( bc2wrap::serialize(&ciphertext) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, signcrypt_key.encryption_scheme_type(), - signcrypt_key.signing_schemes(), + SigncryptionFormat::EcdsaV0, )) } @@ -191,7 +192,7 @@ mod tests { use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::PkeSchemeType; - use crate::cryptography::signatures::{SigningSchemeSet, SigningSchemeType, gen_sig_keys}; + use crate::cryptography::signatures::gen_sig_keys; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; use rand::SeedableRng; @@ -277,8 +278,8 @@ mod tests { let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); assert_eq!(cipher.pke_type, scheme); assert_eq!( - cipher.signing_schemes, - SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1) + cipher.format, + SigncryptionFormat::EcdsaV0 ); let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); @@ -391,7 +392,7 @@ mod tests { let frozen_cipher = UnifiedSigncryption::new( frozen_payload.clone(), scheme, - SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + SigncryptionFormat::EcdsaV0, ); let opened: TestType = unsign_key .unsigncrypt(DSEP, &frozen_cipher) diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 2ec24c5765..9a58c53c93 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -13,7 +13,8 @@ //! # Envelope formats //! //! A signcryption's encrypted plaintext has a layout, and there is more than -//! one. Which one applies is a total function of the signcryption's scheme set. +//! one. Which one applies is recorded in [`UnifiedSigncryption::format`]; on the +//! write path it follows from the scheme set, via [`SigncryptionFormat::for_schemes`]. //! //! [`SigncryptionFormat::EcdsaV0`] is the original layout and is **frozen**: //! every user-decryption ciphertext produced since 0.11 uses it, the deployed @@ -35,15 +36,13 @@ //! fixture frozen at 0.13.0. Moving an RNG draw in [`inner_signcryption`] //! breaks it. //! -//! [`SigncryptionFormat::CompositeV1`] will carry one signature per scheme, for -//! the custodian-backup chain. Not implemented yet: asking for it is a -//! [`CryptographyError::UnsupportedSigncryptionFormat`]. +//! [`SigncryptionFormat::CompositeV1`] carries one signature per scheme, for the +//! custodian-backup chain. mod common; pub mod composite_v1; mod ecdsa_v0; -pub use composite_v1::{CompositeSigncryptionKey, CompositeUnsigncryptionKey}; pub(crate) use ecdsa_v0::insecure_decrypt_ignoring_signature; use crate::consts::SAFE_SER_SIZE_LIMIT; @@ -52,7 +51,7 @@ use crate::cryptography::encryption::{ }; use crate::cryptography::error::CryptographyError; use crate::cryptography::signatures::{ - HasSigningScheme, PrivateSigKey, PublicSigKey, SigningSchemeSet, SigningSchemeType, + HasSigningScheme, PrivateSigKey, PublicSigKey, SigningSchemeType, }; use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; @@ -196,12 +195,9 @@ impl<'a> UnifiedSigncryptionKey<'a> { } } - /// The schemes this key signs under. - /// - /// A single scheme today, because the key it holds is the ECDSA - /// [`PrivateSigKey`]. - pub fn signing_schemes(&self) -> SigningSchemeSet { - SigningSchemeSet::single(self.signing_key.signing_scheme_type()) + /// The envelope format this key produces. + pub fn format(&self) -> SigncryptionFormat { + SigncryptionFormat::for_schemes(&[self.signing_key.signing_scheme_type()]) } } @@ -241,15 +237,10 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { } } - /// The schemes this key accepts a signature under. - /// - /// The counterpart of [`UnifiedSigncryptionKey::signing_schemes`], and a - /// single scheme for the same reason. - pub fn signing_schemes(&self) -> SigningSchemeSet { - SigningSchemeSet::single(self.sender_verf_key.signing_scheme_type()) + /// The envelope format this key can open. + pub fn format(&self) -> SigncryptionFormat { + SigncryptionFormat::for_schemes(&[self.sender_verf_key.signing_scheme_type()]) } - - // TODO this file should be split up and this moved to signcryption } impl HasPkeScheme for UnifiedUnsigncryptionKey<'_> { @@ -325,14 +316,50 @@ impl HasSigningScheme for UnifiedUnsigncryptionKeyOwned { } } +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, VersionsDispatch)] +pub enum SigncryptionFormatVersions { + V0(SigncryptionFormat), +} + +/// The layout of a signcryption's encrypted plaintext. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Serialize, Deserialize, Versionize)] +#[versionize(SigncryptionFormatVersions)] +pub enum SigncryptionFormat { + /// `msg ‖ sig ‖ H(sender verification key)`, ECDSA only. Frozen; see the + /// module documentation. + EcdsaV0, + /// A self-describing, versioned, multi-signature envelope. + CompositeV1, +} + +impl SigncryptionFormat { + /// The format a signcryption produced under `schemes` must use. + pub fn for_schemes(schemes: &[SigningSchemeType]) -> Self { + if schemes == [SigningSchemeType::Ecdsa256k1] { + Self::EcdsaV0 + } else { + Self::CompositeV1 + } + } +} + +impl std::fmt::Display for SigncryptionFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EcdsaV0 => write!(f, "EcdsaV0"), + Self::CompositeV1 => write!(f, "CompositeV1"), + } + } +} + #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, VersionsDispatch)] pub enum UnifiedSigncryptionVersions { V0(UnifiedSigncryptionV0), V1(UnifiedSigncryption), } -/// A signcryption as it was stored and sent before composite signing, carrying -/// exactly one signature scheme. +/// A signcryption as it was stored and sent before multi-signature envelopes, +/// naming a signing scheme where the current form names a layout. /// /// Kept so that legacy material can still be read. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Version)] @@ -349,39 +376,31 @@ impl Upgrade for UnifiedSigncryptionV0 { Ok(UnifiedSigncryption { payload: self.payload, pke_type: self.pke_type, - signing_schemes: SigningSchemeSet::single(self.signing_type), + format: SigncryptionFormat::for_schemes(&[self.signing_type]), }) } } -/// A signcrypted message, tagged with the schemes used to protect it. +/// A signcrypted message, tagged with the layout of its encrypted plaintext. /// -/// `signing_schemes` names every scheme whose signature is inside `payload`. +/// `format` says how to parse `payload` once decrypted. It does *not* say which +/// schemes signed it: that is inside the envelope, where it is authenticated. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Versionize)] #[versionize(UnifiedSigncryptionVersions)] pub struct UnifiedSigncryption { pub payload: Vec, pub pke_type: PkeSchemeType, - pub signing_schemes: SigningSchemeSet, + pub format: SigncryptionFormat, } impl UnifiedSigncryption { - /// A signcryption protected by every scheme in `signing_schemes`. - pub fn new( - payload: Vec, - pke_type: PkeSchemeType, - signing_schemes: SigningSchemeSet, - ) -> Self { + /// A signcryption whose encrypted plaintext uses `format`. + pub fn new(payload: Vec, pke_type: PkeSchemeType, format: SigncryptionFormat) -> Self { Self { payload, pke_type, - signing_schemes, + format, } } - - /// The one scheme protecting this signcryption, or `None` if several do. - pub fn sole_signing_scheme(&self) -> Option { - self.signing_schemes.sole() - } } #[derive(Clone, Serialize, Deserialize, Hash, PartialEq, Eq, Debug, VersionsDispatch)] @@ -481,12 +500,11 @@ impl<'a> Signcrypt for UnifiedSigncryptionKey<'a> { "Could not serialize message for signcryption: {e}", )) })?; - let schemes = self.signing_schemes(); - match common::format_for(&schemes) { - common::SigncryptionFormat::EcdsaV0 => { + match self.format() { + SigncryptionFormat::EcdsaV0 => { ecdsa_v0::inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) } - common::SigncryptionFormat::CompositeV1 => Err(common::unsupported_format(&schemes)), + format @ SigncryptionFormat::CompositeV1 => Err(common::unsupported_format(format)), } } } @@ -528,7 +546,7 @@ impl<'a> SigncryptFHEPlaintext for UnifiedSigncryptionKey<'a> { let mut serialized_msg = ZeroizingWriter::new(); bc2wrap::serialize_into(&*signcryption_msg, &mut serialized_msg) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - // Deliberately not routed through `format_for`: the wire type this + // Deliberately not routed through `SigncryptionFormat::for_schemes`: the wire type this // produces, `TypedSigncryptedCiphertext.signcrypted_ciphertext`, is a // bare `bytes` field with nowhere to record a format, so the only // layout its readers can parse is the frozen one. @@ -550,19 +568,15 @@ impl SigncryptFHEPlaintext for UnifiedSigncryptionKeyOwned { } } -/// Open `cipher` in whichever format its scheme set names. +/// Open `cipher` in whichever format it names. fn open_dispatch( unsign_key: &UnifiedUnsigncryptionKey, dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { - match common::format_for(&cipher.signing_schemes) { - common::SigncryptionFormat::EcdsaV0 => { - ecdsa_v0::inner_unsigncrypt(unsign_key, dsep, cipher) - } - common::SigncryptionFormat::CompositeV1 => { - Err(common::unsupported_format(&cipher.signing_schemes)) - } + match cipher.format { + SigncryptionFormat::EcdsaV0 => ecdsa_v0::inner_unsigncrypt(unsign_key, dsep, cipher), + format @ SigncryptionFormat::CompositeV1 => Err(common::unsupported_format(format)), } } @@ -626,7 +640,7 @@ impl<'a> UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKey<'a> { let parsed_signcryption = UnifiedSigncryption::new( signcryption.to_owned(), self.encryption_key.encryption_scheme_type(), - self.signing_schemes(), + SigncryptionFormat::EcdsaV0, ); let decrypted_signcryption = ecdsa_v0::inner_unsigncrypt(self, dsep, &parsed_signcryption)?; // LEGACY should be using safe_deserialization from tfhe-rs @@ -930,50 +944,71 @@ mod tests { .unwrap_err(); } - /// Everything produced by the pre-composite path must be tagged with the - /// singleton ECDSA set, because that is what keeps it on the frozen layout. + /// The singleton ECDSA set — and only it — selects the frozen layout. This + /// is what keeps user decryption on `EcdsaV0` without a dedicated branch. #[test] - fn the_legacy_path_produces_the_ecdsa_singleton() { + fn only_the_ecdsa_singleton_is_the_legacy_format() { + use strum::IntoEnumIterator; + + assert_eq!( + SigncryptionFormat::for_schemes(&[SigningSchemeType::Ecdsa256k1]), + SigncryptionFormat::EcdsaV0 + ); + + for scheme in SigningSchemeType::iter().filter(|s| *s != SigningSchemeType::Ecdsa256k1) { + assert_eq!( + SigncryptionFormat::for_schemes(&[scheme]), + SigncryptionFormat::CompositeV1, + "{scheme} alone must not select the frozen ECDSA layout" + ); + } + + // Adding any scheme to ECDSA leaves the legacy format behind, which is + // what stops a composite signcryption being parsed as a legacy one. + assert_eq!( + SigncryptionFormat::for_schemes(&[ + SigningSchemeType::Ecdsa256k1, + SigningSchemeType::MlDsa87 + ]), + SigncryptionFormat::CompositeV1 + ); + } + + /// Everything produced by the single-ECDSA path must be tagged with the + /// frozen layout, because that is the only layout its bytes can be read as. + #[test] + fn the_legacy_path_produces_the_frozen_format() { let (mut rng, keys) = test_setup(); - let expected = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); + let expected = SigncryptionFormat::EcdsaV0; let cipher = keys .signcrypt_key .signcrypt(&mut rng, b"TESTTEST", &TestType { i: 7 }) .unwrap(); - assert_eq!(cipher.signing_schemes, expected); - assert_eq!( - cipher.sole_signing_scheme(), - Some(SigningSchemeType::Ecdsa256k1) - ); + assert_eq!(cipher.format, expected); let plaintext_cipher = keys .signcrypt_key .signcrypt_plaintext(&mut rng, b"TESTTEST", &[1], FheTypes::Bool, &[9u8; 4]) .unwrap(); - assert_eq!(plaintext_cipher.signing_schemes, expected); + assert_eq!(plaintext_cipher.format, expected); - assert_eq!(keys.signcrypt_key.reference().signing_schemes(), expected); - assert_eq!( - keys.unsigncryption_key.reference().signing_schemes(), - expected - ); + assert_eq!(keys.signcrypt_key.reference().format(), expected); + assert_eq!(keys.unsigncryption_key.reference().format(), expected); } - /// A signcryption claiming a composite scheme set must be refused rather - /// than parsed as a legacy one. Until the composite envelope lands that is - /// an "unsupported format" error; once it lands, the composite opener takes - /// over and this must still never reach [`inner_unsigncrypt`]. + /// A signcryption claiming the multi-signature layout must be refused rather + /// than parsed as a legacy one. Until that envelope is wired in that is an + /// "unsupported format" error; once it is, the composite opener takes over + /// and this must still never reach [`ecdsa_v0::inner_unsigncrypt`]. #[test] - fn a_composite_scheme_set_does_not_reach_the_legacy_opener() { + fn a_composite_format_does_not_reach_the_legacy_opener() { let (mut rng, keys) = test_setup(); let mut cipher = keys .signcrypt_key .signcrypt(&mut rng, b"TESTTEST", &TestType { i: 11 }) .unwrap(); - cipher.signing_schemes = - SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]) - .unwrap(); + cipher.format = SigncryptionFormat::CompositeV1; let err = keys .unsigncryption_key @@ -985,10 +1020,14 @@ mod tests { ); } - /// Material written before composite signing must still read, and must mean - /// the same thing: one scheme, spelled as a set of one. + /// Material written before multi-signature envelopes must still read, and + /// must still name the layout its bytes are actually in. + /// + /// Only the ECDSA variant was ever written, and it is the one that has to + /// land on the frozen layout. The other schemes are exercised to pin what + /// the mapping would do rather than because such data exists. #[test] - fn v0_upgrades_to_the_singleton_set() { + fn v0_upgrades_to_the_frozen_format() { use strum::IntoEnumIterator; for scheme in SigningSchemeType::iter() { @@ -997,17 +1036,22 @@ mod tests { pke_type: PkeSchemeType::MlKem512, signing_type: scheme, }; + let expected = if scheme == SigningSchemeType::Ecdsa256k1 { + SigncryptionFormat::EcdsaV0 + } else { + SigncryptionFormat::CompositeV1 + }; + let upgraded = v0.clone().upgrade().unwrap(); assert_eq!(upgraded.payload, v0.payload); assert_eq!(upgraded.pke_type, v0.pke_type); - assert_eq!(upgraded.signing_schemes, SigningSchemeSet::single(scheme)); - assert_eq!(upgraded.sole_signing_scheme(), Some(scheme)); + assert_eq!(upgraded.format, expected); - // The upgrade is exactly what the singleton constructor builds, so a + // The upgrade is exactly what the constructor builds, so a // regenerated artifact still compares equal to a frozen one. assert_eq!( upgraded, - UnifiedSigncryption::new(v0.payload, v0.pke_type, SigningSchemeSet::single(scheme)) + UnifiedSigncryption::new(v0.payload, v0.pke_type, expected) ); } } diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index ebda73c898..6dd4b36db6 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -7,10 +7,9 @@ //! //! What makes that hold is that the scheme set is inside the bytes each //! signature covers. A signature produced under `{A, B}` attests to that set, so -//! it cannot be re-presented as a complete signature under `{A}`.≠ +//! it cannot be re-presented as a complete signature under `{A}`. use super::identity::NodeSigningIdentity; -use super::scheme_set::SigningSchemeSet; use super::typed_signature::StoredTypedSignature; use super::verf_key_set::VerfKeySet; use super::{Signature, SigningError, SigningSchemeType, unified_verify}; @@ -18,6 +17,59 @@ use hashing::DomainSep; use serde::{Deserialize, Serialize}; use tfhe_versionable::{Versionize, VersionsDispatch}; +/// Sort `schemes` into canonical order and drop duplicates. +/// +/// Errors when `schemes` is empty. +pub fn canonical_schemes( + schemes: &[SigningSchemeType], +) -> Result, SigningError> { + let mut canonical = schemes.to_vec(); + canonical.sort_unstable(); + canonical.dedup(); + if canonical.is_empty() { + return Err(SigningError::EmptySchemeSet); + } + Ok(canonical) +} + +/// The unambiguous byte encoding of `schemes`, for use inside a signed preimage. +/// +/// Length-prefixed, so no set's encoding is a prefix of a longer set's. +fn canonical_scheme_bytes(schemes: &[SigningSchemeType]) -> Vec { + let mut out = Vec::with_capacity(4 + 4 * schemes.len()); + // Bounded by the number of known schemes, so the cast cannot truncate. + out.extend_from_slice(&(schemes.len() as u32).to_le_bytes()); + for scheme in schemes { + out.extend_from_slice(&scheme.tag()); + } + out +} + +/// Render `schemes` for an error message. +fn render_schemes(schemes: &[SigningSchemeType]) -> String { + format!( + "{{{}}}", + schemes + .iter() + .map(|scheme| scheme.to_string()) + .collect::>() + .join(", ") + ) +} + +/// Accept `schemes` only if it is already canonical. +fn ensure_canonical(schemes: &[SigningSchemeType]) -> Result<(), SigningError> { + let canonical = canonical_schemes(schemes)?; + if canonical != schemes { + return Err(SigningError::NonCanonicalSchemeSet(format!( + "expected {}, got {}", + render_schemes(&canonical), + render_schemes(schemes) + ))); + } + Ok(()) +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] pub enum CompositeSignatureVersions { V0(CompositeSignature), @@ -49,9 +101,11 @@ impl CompositeSignature { /// Accept `entries` only if they are already ordered by scheme, carry no /// duplicate scheme, and are non-empty. pub fn from_canonical(entries: Vec) -> Result { - let schemes = - SigningSchemeSet::from_canonical(entries.iter().map(|entry| entry.scheme).collect())?; - debug_assert_eq!(schemes.len(), entries.len()); + let schemes = entries + .iter() + .map(|entry| entry.scheme) + .collect::>(); + ensure_canonical(&schemes)?; Ok(Self(entries)) } @@ -59,25 +113,27 @@ impl CompositeSignature { pub fn entries(&self) -> &[StoredTypedSignature] { &self.0 } + /// The bytes every constituent signature is made over. - pub fn preimage(schemes: &SigningSchemeSet, msg: &[u8]) -> Vec { - // Note that the scheme should comes first and is length-prefixed - [schemes.canonical_bytes().as_slice(), msg].concat() + pub fn preimage(schemes: &[SigningSchemeType], msg: &[u8]) -> Result, SigningError> { + let schemes = canonical_schemes(schemes)?; + Ok([canonical_scheme_bytes(&schemes).as_slice(), msg].concat()) } /// Sign `msg` under every scheme in `schemes`, each over the same bytes. #[cfg(feature = "non-wasm")] pub fn sign_uniform( identity: &NodeSigningIdentity, - schemes: &SigningSchemeSet, + schemes: &[SigningSchemeType], dsep: &DomainSep, msg: &[u8], ) -> Result { - identity.ensure_supported(schemes.as_slice())?; - let preimage = Self::preimage(schemes, msg); + let schemes = canonical_schemes(schemes)?; + identity.ensure_supported(&schemes)?; + let preimage = Self::preimage(&schemes, msg)?; let entries = schemes .iter() - .map(|scheme| { + .map(|&scheme| { identity .unified_sign_with(scheme, dsep, &preimage) .map(|signature| StoredTypedSignature { @@ -97,21 +153,22 @@ impl CompositeSignature { pub fn verify_uniform( &self, keys: &VerfKeySet, - expected: &SigningSchemeSet, + expected: &[SigningSchemeType], dsep: &DomainSep, msg: &[u8], ) -> Result<(), SigningError> { + let expected = canonical_schemes(expected)?; // The scheme-set comparison happens before any cryptography, // so a composite signature presented with one of its parts removed is // rejected for being the wrong shape - let schemes = self.schemes()?; - if schemes != *expected { + let schemes = self.schemes(); + if schemes != expected { return Err(SigningError::UnexpectedSchemeSet { - expected: expected.to_string(), - actual: schemes.to_string(), + expected: render_schemes(&expected), + actual: render_schemes(&schemes), }); } - let preimage = Self::preimage(&schemes, msg); + let preimage = Self::preimage(&schemes, msg)?; for entry in &self.0 { let signature = Signature::new(entry.scheme, entry.signature.clone()); unified_verify(dsep, &preimage, &signature, keys.require(entry.scheme)?)?; @@ -119,9 +176,9 @@ impl CompositeSignature { Ok(()) } - /// The schemes this signature was made under, derived from its entries. - pub fn schemes(&self) -> Result { - SigningSchemeSet::new(self.0.iter().map(|entry| entry.scheme)) + /// The schemes this signature was made under, derived from its entries.. + pub fn schemes(&self) -> Vec { + self.0.iter().map(|entry| entry.scheme).collect() } } @@ -134,8 +191,7 @@ pub fn result_signed_bytes( schemes: &[SigningSchemeType], payload_bytes: &[u8], ) -> Result, SigningError> { - let schemes = SigningSchemeSet::new(schemes.iter().copied())?; - Ok(CompositeSignature::preimage(&schemes, payload_bytes)) + CompositeSignature::preimage(schemes, payload_bytes) } /// The per-scheme signatures of a *result*: a keygen, CRS, preprocessing or @@ -198,11 +254,11 @@ mod tests { const DSEP: &DomainSep = b"COMPSIGT"; const MSG: &[u8] = b"a message signed under several schemes at once"; - fn pair() -> SigningSchemeSet { - SigningSchemeSet::new([SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]).unwrap() + fn pair() -> Vec { + vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87] } - fn setup(seed: u64) -> (NodeSigningIdentity, VerfKeySet, SigningSchemeSet) { + fn setup(seed: u64) -> (NodeSigningIdentity, VerfKeySet, Vec) { let mut rng = AesRng::seed_from_u64(seed); let identity = seeded_identity(&mut rng); let schemes = pair(); @@ -214,7 +270,7 @@ mod tests { fn round_trip_sunshine() { let (identity, keys, schemes) = setup(1); let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - assert_eq!(sig.schemes().unwrap(), schemes); + assert_eq!(sig.schemes(), schemes); sig.verify_uniform(&keys, &schemes, DSEP, MSG).unwrap(); } @@ -236,7 +292,7 @@ mod tests { // ...and even if a verifier were talked into asking only for ECDSA, the // surviving signature covers a preimage naming the *pair*, so it does // not verify against the single-scheme preimage either. - let ecdsa_only = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); + let ecdsa_only = vec![SigningSchemeType::Ecdsa256k1]; assert!( stripped .verify_uniform(&keys, &ecdsa_only, DSEP, MSG) @@ -315,11 +371,8 @@ mod tests { let (identity, _keys, schemes) = setup(8); let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - let ecdsa_only = VerfKeySet::from_identity( - &identity, - &SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), - ) - .unwrap(); + let ecdsa_only = + VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); assert!(matches!( sig.verify_uniform(&ecdsa_only, &schemes, DSEP, MSG), Err(SigningError::NoVerificationKey(_)) @@ -339,7 +392,7 @@ mod tests { } /// The result shape splits by scheme: ECDSA signs the EIP-712 hash, every - /// other scheme signs the payload. + /// other scheme signs the scheme-set-bound payload. #[test] fn result_entries_split_ecdsa_from_the_rest() { let mut rng = AesRng::seed_from_u64(20); @@ -351,6 +404,7 @@ mod tests { ]; let eip712_hash = [0x11u8; 32]; let payload = b"the serialized result payload"; + let bound = result_signed_bytes(&schemes, payload).unwrap(); let entries = sign_result_entries(&identity, &schemes, DSEP, &eip712_hash, payload).unwrap(); @@ -365,9 +419,11 @@ mod tests { } let vk = identity.unified_verifying_key(entry.scheme).unwrap(); let sig = Signature::new(entry.scheme, entry.signature.clone()); - unified_verify(DSEP, payload, &sig, &vk) - .unwrap_or_else(|e| panic!("{:?} should sign the payload: {e}", entry.scheme)); - // ...and not the EIP-712 hash. + unified_verify(DSEP, &bound, &sig, &vk).unwrap_or_else(|e| { + panic!("{:?} should sign the bound payload: {e}", entry.scheme) + }); + // ...and neither the bare payload nor the EIP-712 hash. + assert!(unified_verify(DSEP, payload, &sig, &vk).is_err()); assert!(unified_verify(DSEP, &eip712_hash, &sig, &vk).is_err()); } } @@ -389,10 +445,53 @@ mod tests { /// signature crossing between them. #[test] fn preimages_separate_scheme_sets() { - let single = SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1); + let single = vec![SigningSchemeType::Ecdsa256k1]; assert_ne!( - CompositeSignature::preimage(&single, MSG), - CompositeSignature::preimage(&pair(), MSG) + CompositeSignature::preimage(&single, MSG).unwrap(), + CompositeSignature::preimage(&pair(), MSG).unwrap() + ); + + // The length prefix is what separates a set from a longer one starting + // with it. + assert!( + !CompositeSignature::preimage(&pair(), MSG) + .unwrap() + .starts_with(&canonical_scheme_bytes(&single)) + ); + } + + /// Canonicalisation normalises order and duplicates, and refuses the empty + /// set — the case that would make "every scheme verified" vacuous. + #[test] + fn canonicalisation_normalises_and_refuses_empty() { + let reordered = [ + SigningSchemeType::MlDsa87, + SigningSchemeType::Ecdsa256k1, + SigningSchemeType::MlDsa87, + ]; + assert_eq!(canonical_schemes(&reordered).unwrap(), pair()); + + // So callers may pass any order and still agree on the signed bytes. + assert_eq!( + CompositeSignature::preimage(&reordered, MSG).unwrap(), + CompositeSignature::preimage(&pair(), MSG).unwrap() ); + + assert!(matches!( + canonical_schemes(&[]), + Err(SigningError::EmptySchemeSet) + )); + } + + /// An empty policy must not verify anything, including a signature that + /// carries entries. + #[test] + fn an_empty_expected_set_is_rejected() { + let (identity, keys, schemes) = setup(10); + let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + assert!(matches!( + sig.verify_uniform(&keys, &[], DSEP, MSG), + Err(SigningError::EmptySchemeSet) + )); } } diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 5780b6f007..5e01fc4319 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -8,13 +8,11 @@ pub mod ecdsa; mod eddsa; pub mod identity; mod mldsa; -pub mod scheme_set; pub mod seed; pub mod typed_signature; pub mod verf_key_set; -pub use composite::{CompositeSignature, CompositeSignatureVersions}; -pub use scheme_set::SigningSchemeSet; +pub use composite::{CompositeSignature, CompositeSignatureVersions, canonical_schemes}; pub use typed_signature::{StoredTypedSignature, StoredTypedSignatureVersions}; pub use verf_key_set::VerfKeySet; @@ -779,12 +777,12 @@ mod tests { /// Declaration order is ascending wire order. /// /// Two things depend on this and neither would fail loudly if it broke. - /// [`SigningSchemeSet`] treats "canonical" as ascending wire order, but - /// sorts with the derived [`Ord`], which follows declaration order; - /// so its digest walks the members in derived-`Ord` order - /// while the set it reports is ordered by wire value. If the two orders ever - /// disagreed, a key set's identifier would be computed over a different - /// ordering than the scheme set naming it, silently. + /// [`composite::canonical_schemes`] calls "canonical" ascending wire order + /// but sorts with the derived [`Ord`], which follows declaration order; and + /// [`verf_key_set::VerfKeySet`] reports its schemes in `BTreeMap` order, the + /// same derived `Ord`. If the two orders ever disagreed, a signature's + /// preimage would name its schemes in a different order than the key set + /// verifying it reports, silently. #[test] fn declaration_order_is_wire_order() { // `EnumIter` yields variants in declaration order, so the index is the diff --git a/core/service/src/cryptography/signing/scheme_set.rs b/core/service/src/cryptography/signing/scheme_set.rs deleted file mode 100644 index f320a28423..0000000000 --- a/core/service/src/cryptography/signing/scheme_set.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! The set of signature schemes a composite signature is made under. -//! -//! A composite signature is only as strong as the *guarantee that every one of -//! its constituent signatures is present* hence this type needs to be persisted -//! as part of the signature or request, to avoid malleability attacks cutting -//! out one or more of the composite signature elements. - -use super::{SigningError, SigningSchemeType}; -use crate::impl_generic_versionize; -use serde::{Deserialize, Deserializer, Serialize}; -use std::fmt; - -/// A non-empty, ascending, duplicate-free set of signature schemes. -/// -/// # Canonical form -/// -/// The schemes are ordered by their wire discriminant and carry no duplicates. -/// The set is never empty. -/// -/// Ordering by wire discriminant is the same as ordering by the derived [`Ord`] -/// on [`SigningSchemeType`], which follows declaration order. -/// -/// # Versioning -/// -/// Versioned through [`impl_generic_versionize`] rather than a dispatch enum of -/// its own, because `Vec` is not `Versionize`. -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] -#[serde(transparent)] -pub struct SigningSchemeSet(Vec); - -impl_generic_versionize!(SigningSchemeSet); - -impl SigningSchemeSet { - /// Creates a canonical set of `schemes`, sorting and de-duplicating the input. - /// - /// Errors when `schemes` is empty. - pub fn new(schemes: I) -> Result - where - I: IntoIterator, - { - let mut schemes: Vec = schemes.into_iter().collect(); - schemes.sort_unstable(); - schemes.dedup(); - if schemes.is_empty() { - return Err(SigningError::EmptySchemeSet); - } - Ok(Self(schemes)) - } - - /// The set containing `scheme` alone. - pub fn single(scheme: SigningSchemeType) -> Self { - Self(vec![scheme]) - } - - /// Accept `schemes` only if it is already in canonical form. - pub fn from_canonical(schemes: Vec) -> Result { - if schemes.is_empty() { - return Err(SigningError::EmptySchemeSet); - } - let canonical = Self::new(schemes.iter().copied())?; - if canonical.0 != schemes { - return Err(SigningError::NonCanonicalSchemeSet(format!( - "expected {canonical}, got {}", - Self::render(&schemes) - ))); - } - Ok(canonical) - } - - /// The schemes, in canonical order. - pub fn as_slice(&self) -> &[SigningSchemeType] { - &self.0 - } - - /// The schemes, in canonical order. - pub fn iter(&self) -> impl Iterator + '_ { - self.0.iter().copied() - } - - /// How many schemes the set holds; never zero. - #[allow(clippy::len_without_is_empty)] - pub fn len(&self) -> usize { - self.0.len() - } - - /// Whether `scheme` is in the set. - pub fn contains(&self, scheme: SigningSchemeType) -> bool { - self.0.contains(&scheme) - } - - /// The single scheme this set holds, or `None` if it holds several. - pub fn sole(&self) -> Option { - match self.0.as_slice() { - [scheme] => Some(*scheme), - _ => None, - } - } - - /// The unambiguous byte encoding of this set, for use inside a signature's - /// or a digest's preimage. - pub fn canonical_bytes(&self) -> Vec { - let mut out = Vec::with_capacity(4 + 4 * self.0.len()); - // The set is non-empty and bounded by the number of known schemes, so - // the cast cannot truncate. - out.extend_from_slice(&(self.0.len() as u32).to_le_bytes()); - for scheme in &self.0 { - out.extend_from_slice(&scheme.tag()); - } - out - } - - fn render(schemes: &[SigningSchemeType]) -> String { - schemes - .iter() - .map(|scheme| scheme.to_string()) - .collect::>() - .join(", ") - } -} - -impl fmt::Display for SigningSchemeSet { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{{{}}}", Self::render(&self.0)) - } -} - -impl<'de> Deserialize<'de> for SigningSchemeSet { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let schemes = Vec::::deserialize(deserializer)?; - Self::from_canonical(schemes).map_err(serde::de::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use strum::IntoEnumIterator; - - /// The set every pre-composite signature carries. - const ECDSA: SigningSchemeType = SigningSchemeType::Ecdsa256k1; - - #[test] - fn new_sorts_and_dedups() { - let set = SigningSchemeSet::new([ - SigningSchemeType::MlDsa87, - ECDSA, - SigningSchemeType::MlDsa87, - SigningSchemeType::Ed25519, - ]) - .unwrap(); - assert_eq!( - set.as_slice(), - [ - ECDSA, - SigningSchemeType::Ed25519, - SigningSchemeType::MlDsa87, - ] - .as_slice() - ); - assert_eq!(set.len(), 3); - } - - #[test] - fn empty_is_rejected() { - assert!(matches!( - SigningSchemeSet::new(std::iter::empty()), - Err(SigningError::EmptySchemeSet) - )); - assert!(matches!( - SigningSchemeSet::from_canonical(vec![]), - Err(SigningError::EmptySchemeSet) - )); - } - - /// Canonical order is the only accepted encoding. - #[test] - fn from_canonical_rejects_non_canonical() { - let reordered = vec![SigningSchemeType::MlDsa87, ECDSA]; - assert!(matches!( - SigningSchemeSet::from_canonical(reordered), - Err(SigningError::NonCanonicalSchemeSet(_)) - )); - - let duplicated = vec![ECDSA, ECDSA]; - assert!(matches!( - SigningSchemeSet::from_canonical(duplicated), - Err(SigningError::NonCanonicalSchemeSet(_)) - )); - - // ...and the canonica order is accepted. - SigningSchemeSet::from_canonical(vec![ECDSA, SigningSchemeType::MlDsa87]).unwrap(); - } - - /// A non-canonical set must not survive a serialization round-trip, because - /// that is the path an attacker-supplied encoding takes. - #[test] - fn deserialization_rejects_non_canonical() { - let canonical = SigningSchemeSet::new([ECDSA, SigningSchemeType::MlDsa87]).unwrap(); - let bytes = bc2wrap::serialize(&canonical).unwrap(); - let back: SigningSchemeSet = bc2wrap::deserialize_slice(&bytes).unwrap(); - assert_eq!(canonical, back); - - // Serialize the reversed list directly; it must not deserialize back. - let reversed = vec![SigningSchemeType::MlDsa87, ECDSA]; - let bytes = bc2wrap::serialize(&reversed).unwrap(); - assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); - } - - #[test] - fn sole_is_none_for_a_composite_set() { - assert_eq!(SigningSchemeSet::single(ECDSA).sole(), Some(ECDSA)); - assert_eq!( - SigningSchemeSet::new([ECDSA, SigningSchemeType::MlDsa87]) - .unwrap() - .sole(), - None - ); - } - - /// Distinct sets must not share a preimage, including the case where one is - /// a prefix of another — that is the collision a bare concatenation would - /// admit. - #[test] - fn canonical_bytes_separate_distinct_sets() { - let mut seen = std::collections::HashSet::new(); - for scheme in SigningSchemeType::iter() { - assert!( - seen.insert(SigningSchemeSet::single(scheme).canonical_bytes()), - "{scheme} shares a preimage with another set" - ); - } - let pair = SigningSchemeSet::new([ECDSA, SigningSchemeType::MlDsa87]).unwrap(); - assert!(seen.insert(pair.canonical_bytes())); - - // The length prefix is what separates a set from a longer one that - // starts with it. - let single = SigningSchemeSet::single(ECDSA); - assert!( - !pair - .canonical_bytes() - .starts_with(&single.canonical_bytes()) - ); - } - - /// Locks the serialized form of a [`SigningSchemeSet`]. - /// - /// The freeze-and-replay harness covers this type too - /// (`SigningSchemeSetTest`), but that catches a break only when the stored - /// fixtures are pulled and replayed. This runs in the unit suite and names - /// the exact bytes, so an encoding change is reported where it is made. The - /// same pairing exists for `SigncryptionPayload`, which has both a fixture - /// and `test_signcryption_payload_v0_serialization_locked`. - /// - /// The expected bytes follow `bc2wrap`'s bincode-v1 rules: a sequence length - /// as a little-endian `u64`, then each enum variant as a little-endian - /// `u32` discriminant. - /// - /// A change here breaks every signcryption already written under a - /// composite scheme set, so it must come with a new version of the - /// containing type rather than with an update to these bytes. - #[test] - fn versioned_encoding_is_locked() { - // One scheme: length 1, then discriminant 0 (Ecdsa256k1). - assert_eq!( - bc2wrap::serialize(&SigningSchemeSet::single(ECDSA)).unwrap(), - vec![ - 1, 0, 0, 0, 0, 0, 0, 0, // sequence length - 0, 0, 0, 0, // Ecdsa256k1 - ], - "LOCKED: the singleton encoding changed" - ); - - // Two schemes, in canonical order: discriminants 0 and 4. This is the - // pair the backward-compatibility fixture freezes. - assert_eq!( - bc2wrap::serialize( - &SigningSchemeSet::new([ECDSA, SigningSchemeType::MlDsa87]).unwrap() - ) - .unwrap(), - vec![ - 2, 0, 0, 0, 0, 0, 0, 0, // sequence length - 0, 0, 0, 0, // Ecdsa256k1 - 4, 0, 0, 0, // MlDsa87 - ], - "LOCKED: the composite encoding changed" - ); - - // The discriminants are the wire values, so the stored encoding cannot - // drift from what a peer of another release expects. - for scheme in SigningSchemeType::iter() { - let bytes = bc2wrap::serialize(&SigningSchemeSet::single(scheme)).unwrap(); - assert_eq!( - bytes[8..], - (scheme.as_wire() as u32).to_le_bytes(), - "{scheme} is not encoded as its wire discriminant" - ); - } - } - - #[test] - fn every_scheme_round_trips_as_a_singleton() { - for scheme in SigningSchemeType::iter() { - let set = SigningSchemeSet::single(scheme); - assert_eq!(set.sole(), Some(scheme)); - assert!(set.contains(scheme)); - let bytes = bc2wrap::serialize(&set).unwrap(); - let back: SigningSchemeSet = bc2wrap::deserialize_slice(&bytes).unwrap(); - assert_eq!(set, back); - } - } -} diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 03938b9600..0fa7575f5d 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -1,7 +1,6 @@ //! The verification keys one party publishes, one per signature scheme. use super::identity::NodeSigningIdentity; -use super::scheme_set::SigningSchemeSet; use super::{HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey}; use hashing::{DomainSep, hash_element}; use serde::{Deserialize, Serialize}; @@ -46,18 +45,19 @@ impl VerfKeySet { /// The key set `identity` publishes for `schemes`. pub fn from_identity( identity: &NodeSigningIdentity, - schemes: &SigningSchemeSet, + schemes: &[SigningSchemeType], ) -> Result { let mut keys = BTreeMap::new(); - for scheme in schemes.iter() { + for &scheme in schemes { keys.insert(scheme, identity.unified_verifying_key(scheme)?); } Self::new(keys) } - /// The schemes this set holds keys for. - pub fn schemes(&self) -> Result { - SigningSchemeSet::new(self.keys.keys().copied()) + /// The schemes this set holds keys for, in canonical order. + pub fn schemes(&self) -> Vec { + // Canonical order is based on the underlying BTreeMap order + self.keys.keys().copied().collect() } /// The key for `scheme`, if the set holds one. @@ -107,11 +107,11 @@ mod tests { fn from_identity_covers_every_requested_scheme() { let mut rng = AesRng::seed_from_u64(1); let identity = seeded_identity(&mut rng); - let schemes = SigningSchemeSet::new(SigningSchemeType::iter()).unwrap(); + let schemes: Vec<_> = SigningSchemeType::iter().collect(); let set = VerfKeySet::from_identity(&identity, &schemes).unwrap(); - assert_eq!(set.schemes().unwrap(), schemes); - for scheme in schemes.iter() { + assert_eq!(set.schemes(), schemes); + for &scheme in &schemes { let key = set.require(scheme).unwrap(); assert_eq!(key.signing_scheme_type(), scheme); assert_eq!(key, &identity.unified_verifying_key(scheme).unwrap()); @@ -126,17 +126,11 @@ mod tests { let (_pk, sk) = crate::cryptography::signatures::gen_sig_keys(&mut rng); let identity = NodeSigningIdentity::ecdsa_only(sk); - VerfKeySet::from_identity( - &identity, - &SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), - ) - .unwrap(); + VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); + let all: Vec<_> = SigningSchemeType::iter().collect(); assert!(matches!( - VerfKeySet::from_identity( - &identity, - &SigningSchemeSet::new(SigningSchemeType::iter()).unwrap() - ), + VerfKeySet::from_identity(&identity, &all), Err(SigningError::MissingRootSeed(_)) )); } @@ -168,13 +162,13 @@ mod tests { let mut rng = AesRng::seed_from_u64(4); let identity = seeded_identity(&mut rng); let other_identity = seeded_identity(&mut rng); - let schemes = SigningSchemeSet::new(SigningSchemeType::iter()).unwrap(); + let schemes: Vec<_> = SigningSchemeType::iter().collect(); let base = VerfKeySet::from_identity(&identity, &schemes).unwrap(); let base_id = base.id().unwrap(); assert_eq!(base_id, base.id().unwrap(), "the id is not deterministic"); - for scheme in schemes.iter() { + for &scheme in &schemes { let mut swapped = base.keys.clone(); swapped.insert( scheme, diff --git a/core/service/src/engine/backup_operator.rs b/core/service/src/engine/backup_operator.rs index 87b7390394..d270d89359 100644 --- a/core/service/src/engine/backup_operator.rs +++ b/core/service/src/engine/backup_operator.rs @@ -1455,8 +1455,8 @@ mod tests { use crate::{ backup::custodian::{CustodianSetupMessagePayload, HEADER, InternalCustodianContext}, cryptography::{ - signatures::{SigningSchemeSet, SigningSchemeType, gen_sig_keys}, - signcryption::UnifiedSigncryption, + signatures::{SigningSchemeType, gen_sig_keys}, + signcryption::{SigncryptionFormat, UnifiedSigncryption}, }, engine::base::derive_request_id, }; @@ -1823,7 +1823,7 @@ mod tests { signcryption: UnifiedSigncryption::new( vec![1, 2, 3], BACKUP_PKE_SCHEME, - SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + SigncryptionFormat::EcdsaV0, ), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); diff --git a/core/service/src/vault/storage/mod.rs b/core/service/src/vault/storage/mod.rs index f52879481d..2cb1cc4777 100644 --- a/core/service/src/vault/storage/mod.rs +++ b/core/service/src/vault/storage/mod.rs @@ -858,8 +858,8 @@ pub mod tests { consts::{DEFAULT_MPC_CONTEXT, SAFE_SER_SIZE_LIMIT}, cryptography::{ encryption::{Encryption, PkeScheme, UnifiedPublicEncKey}, - signatures::{PrivateSigKey, SigningSchemeSet, SigningSchemeType, gen_sig_keys}, - signcryption::UnifiedSigncryption, + signatures::{PrivateSigKey, SigningSchemeType, gen_sig_keys}, + signcryption::{SigncryptionFormat, UnifiedSigncryption}, }, }; use aes_prng::AesRng; @@ -936,7 +936,7 @@ pub mod tests { signcryption: UnifiedSigncryption::new( vec![1, 2, 3], BACKUP_PKE_SCHEME, - SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + SigncryptionFormat::EcdsaV0, ), }; let mut cts = BTreeMap::new(); diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index d8f4ca18dd..637b9d6850 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -17,7 +17,7 @@ use backward_compatibility::{ MlKem1024P384PublicKeyTest, NodeInfoTest, OperatorBackupOutputTest, PrepKeygenSignedPayloadTest, PrivateSigKeyTest, PrssSetupCombinedTest, PublicDecSignedPayloadTest, PublicSigKeyTest, RecoveryValidationMaterialTest, - RootSigningSeedTest, SchemeDigestsTest, SigncryptionPayloadTest, SigningSchemeSetTest, + RootSigningSeedTest, SchemeDigestsTest, SigncryptionPayloadTest, SoftwareVersionTest, StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataKMS, TestType, Testcase, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedCipherTest, UnifiedPublicSigKeyTest, UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, @@ -55,13 +55,13 @@ use kms_lib::{ }, hybrid_ml_kem::HybridKemCt, signatures::{ - NodeSigningIdentity, PrivateSigKey, PublicSigKey, RootSigningSeed, SigningSchemeSet, + NodeSigningIdentity, PrivateSigKey, PublicSigKey, RootSigningSeed, SigningSchemeType, StoredTypedSignature, UnifiedPublicSigKey, compute_eip712_signature, gen_sig_keys, }, signcryption::{ - Signcrypt, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, - UnifiedUnsigncryptionKeyOwned, + Signcrypt, SigncryptionFormat, SigncryptionPayload, UnifiedSigncryption, + UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned, }, }, engine::{ @@ -807,35 +807,6 @@ fn test_mlkem1024_p384_private_key( Ok(test.success(format)) } -/// A `SigningSchemeSet` written by an earlier release must still load, and must -/// still hold the schemes it was written with. -/// -/// The expected value is rebuilt from the wire discriminants in the test -/// metadata rather than from any constructor shortcut, so a reordering of -/// `SigningSchemeType` would be caught here rather than quietly reinterpreting -/// the stored bytes as a different set. -fn test_signing_scheme_set( - dir: &Path, - test: &SigningSchemeSetTest, - format: DataFormat, -) -> Result { - let original: SigningSchemeSet = load_and_unversionize(dir, test, format)?; - - let expected = SigningSchemeSet::new(test.schemes.iter().map(|wire| { - SigningSchemeType::try_from(*wire).expect("the test metadata names a known signing scheme") - })) - .expect("the test metadata names at least one scheme"); - - if original != expected { - Err(test.failure( - format!("Invalid SigningSchemeSet:\n Expected :\n{expected:?}\nGot:\n{original:?}"), - format, - )) - } else { - Ok(test.success(format)) - } -} - fn test_unified_signcryption( dir: &Path, test: &UnifiedSigncryptionTest, @@ -1179,7 +1150,7 @@ fn test_recovery_material( signcryption: UnifiedSigncryption::new( payload.to_vec(), BACKUP_PKE_SCHEME, - SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + SigncryptionFormat::EcdsaV0, ), }; cts.insert(cus_role, cts_out.clone()); @@ -1224,7 +1195,7 @@ fn test_internal_recovery_request( let signcryption = UnifiedSigncryption::new( payload.to_vec(), BACKUP_PKE_SCHEME, - SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + SigncryptionFormat::EcdsaV0, ); cts.insert(cur_role, InnerOperatorBackupOutput { signcryption }); } @@ -1311,7 +1282,7 @@ fn test_internal_custodian_recovery_output( let signcryption = UnifiedSigncryption::new( buf.to_vec(), BACKUP_PKE_SCHEME, - SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + SigncryptionFormat::EcdsaV0, ); let new_versionized = InternalCustodianRecoveryOutput { @@ -1847,9 +1818,6 @@ impl TestedModule for KMS { Self::Metadata::SigncryptionPayload(test) => { test_signcryption_payload(test_dir.as_ref(), test, format).into() } - Self::Metadata::SigningSchemeSet(test) => { - test_signing_scheme_set(test_dir.as_ref(), test, format).into() - } Self::Metadata::UnifiedSigncryptionKeyOwned(test) => { test_signcryption_keys(test_dir.as_ref(), test, format).into() } diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index 2b9309c3c6..6fbfd22fa0 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -53,6 +53,9 @@ const ALLOW_UNCOVERED: &[&str] = &[ // Field of UnifiedCipher. // Covered via UnifiedCipherTest. "PkeSchemeType", + // Field of UnifiedSigncryption. + // Covered via UnifiedSigncryptionTest. + "SigncryptionFormat", // Field of StoredTypedSignature, and of UnifiedSigncryptionV0. (PrivateSigKey / // PublicSigKey expose it only via the HasSigningScheme trait method, not as // a struct field.) From a3a21dc2209345de1786d7a075a377d340c14236 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 14:50:21 +0200 Subject: [PATCH 016/114] refactor: incremental refactor commit --- core/service/src/backup/custodian.rs | 6 +- core/service/src/backup/mod.rs | 43 +-- core/service/src/backup/operator.rs | 7 +- core/service/src/cryptography/signatures.rs | 2 +- .../cryptography/signcryption/composite_v1.rs | 101 ++++-- .../src/cryptography/signcryption/ecdsa_v0.rs | 29 +- .../src/cryptography/signcryption/mod.rs | 340 +++++++----------- .../service/src/cryptography/signing/ecdsa.rs | 10 +- .../src/cryptography/signing/identity.rs | 2 +- core/service/src/cryptography/signing/mod.rs | 23 +- core/service/src/cryptography/signing/seed.rs | 3 +- .../src/cryptography/signing/verf_key_set.rs | 2 +- core/service/src/engine/backup_operator.rs | 3 +- core/service/src/util/key_setup/mod.rs | 2 +- core/service/src/vault/storage/mod.rs | 3 +- .../tests/backward_compatibility_kms.rs | 7 +- core/service/tests/versioned_enum_coverage.rs | 5 +- 17 files changed, 242 insertions(+), 346 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 3b3fd55ec0..2e737c019b 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -1,4 +1,4 @@ -use crate::backup::{BACKUP_PKE_SCHEME, backup_format_from_wire, backup_format_to_wire}; +use crate::backup::{BACKUP_PKE_SCHEME, VESTIGIAL_BACKUP_SIGNING_TYPE}; use crate::backup::operator::DSEP_BACKUP_MATERIAL; use crate::cryptography::{ encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}, @@ -79,7 +79,6 @@ impl TryFrom for InternalCustodianRecoveryOutput { signcryption: UnifiedSigncryption::new( backup_output.signcryption.clone(), backup_output.pke_type.try_into()?, - backup_format_from_wire(backup_output.signing_type)?, ), custodian_role: Role::indexed_from_one(value.custodian_role as usize), }) @@ -90,12 +89,11 @@ impl TryFrom for CustodianRecoveryOutput { type Error = anyhow::Error; fn try_from(value: InternalCustodianRecoveryOutput) -> Result { - let signing_type = backup_format_to_wire(value.signcryption.format)?; Ok(CustodianRecoveryOutput { backup_output: Some(OperatorBackupOutput { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, - signing_type, + signing_type: VESTIGIAL_BACKUP_SIGNING_TYPE.as_wire(), }), custodian_role: value.custodian_role.one_based() as u64, }) diff --git a/core/service/src/backup/mod.rs b/core/service/src/backup/mod.rs index 56231947e5..b66cba0e28 100644 --- a/core/service/src/backup/mod.rs +++ b/core/service/src/backup/mod.rs @@ -9,7 +9,7 @@ pub mod operator; pub mod secretsharing; pub mod seed_phrase; use crate::cryptography::signatures::SigningSchemeType; -use crate::cryptography::signcryption::{SigncryptionFormat, UnifiedSigncryption}; +use crate::cryptography::signcryption::UnifiedSigncryption; use kms_grpc::RequestId; use kms_grpc::kms::v1::OperatorBackupOutput; use tfhe::named::Named; @@ -56,40 +56,26 @@ impl Named for BackupCiphertext { const NAME: &'static str = "cryptography::BackupCiphertext"; } -/// The envelope format an [`OperatorBackupOutput`] describes. -pub(crate) fn backup_format_from_wire(signing_type: i32) -> anyhow::Result { - let scheme: SigningSchemeType = signing_type.try_into()?; - Ok(SigncryptionFormat::for_schemes(&[scheme])) -} - -/// The `signing_type` an [`OperatorBackupOutput`] must carry for `format`. +/// The value written to the vestigial `OperatorBackupOutput.signing_type`. /// -/// TODO stop gap: the proto field names a single scheme, so only the frozen -/// ECDSA layout can be described by it. Fail rather than mislabel a -/// multi-signature envelope as a single-scheme one. Removing this needs the -/// proto field to name a layout (or a scheme list) instead. -pub(crate) fn backup_format_to_wire(format: SigncryptionFormat) -> anyhow::Result { - match format { - SigncryptionFormat::EcdsaV0 => Ok(SigningSchemeType::Ecdsa256k1.as_wire()), - SigncryptionFormat::CompositeV1 => Err(anyhow::anyhow!( - "cannot represent a {format} signcryption in an OperatorBackupOutput, \ - whose signing_type names a single signing scheme" - )), - } -} +/// A [`UnifiedSigncryption`] names neither a signing scheme nor a layout, so +/// there is nothing to map onto this field and a reader ignores it. +/// +/// TODO: drop `signing_type` from `OperatorBackupOutput` when the backup format +/// is reshaped. Nothing deployed reads it. +pub(crate) const VESTIGIAL_BACKUP_SIGNING_TYPE: SigningSchemeType = SigningSchemeType::Ecdsa256k1; impl TryFrom for UnifiedSigncryption { type Error = anyhow::Error; fn try_from(value: OperatorBackupOutput) -> Result { - // As above for `pke_type`: the fallible conversion reports an unknown - // discriminant instead of silently relabelling it as ML-KEM-512. + // Use the fallible conversion rather than prost's `pke_type()` accessor: + // that maps an unrecognised discriminant to the default variant, which + // would silently relabel material as ML-KEM-512 instead of reporting the + // unknown scheme. `signing_type` is deliberately not read; see + // `VESTIGIAL_BACKUP_SIGNING_TYPE`. let pke_type = value.pke_type.try_into()?; - Ok(UnifiedSigncryption::new( - value.signcryption, - pke_type, - backup_format_from_wire(value.signing_type)?, - )) + Ok(UnifiedSigncryption::new(value.signcryption, pke_type)) } } @@ -101,7 +87,6 @@ impl TryFrom<&OperatorBackupOutput> for UnifiedSigncryption { Ok(UnifiedSigncryption::new( value.signcryption.clone(), pke_type, - backup_format_from_wire(value.signing_type)?, )) } } diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index c281bff8e1..2820cd251d 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -15,7 +15,7 @@ use crate::{ }; use crate::{ backup::custodian::DSEP_BACKUP_CUSTODIAN, - backup::{backup_format_from_wire, backup_format_to_wire}, + backup::VESTIGIAL_BACKUP_SIGNING_TYPE, cryptography::signatures::{internal_sign, internal_verify_sig}, }; use crate::{ @@ -182,7 +182,6 @@ impl TryFrom for InnerOperatorBackupOutput { signcryption: UnifiedSigncryption::new( value.signcryption, value.pke_type.try_into()?, - backup_format_from_wire(value.signing_type)?, ), }) } @@ -191,11 +190,10 @@ impl TryFrom for OperatorBackupOutput { type Error = anyhow::Error; fn try_from(value: InnerOperatorBackupOutput) -> Result { - let signing_type = backup_format_to_wire(value.signcryption.format)?; Ok(Self { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, - signing_type, + signing_type: VESTIGIAL_BACKUP_SIGNING_TYPE.as_wire(), }) } } @@ -981,7 +979,6 @@ mod tests { signcryption: UnifiedSigncryption::new( vec![1, 2, 3], BACKUP_PKE_SCHEME, - SigncryptionFormat::EcdsaV0, ), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index c1c5758047..f8e43fd771 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -17,7 +17,7 @@ pub use crate::cryptography::signing::ecdsa::{ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ - CompositeSignature, CompositeSignatureVersions, HasSigningScheme, SchemeVerfKeys, Signature, + CompositeSignature, CompositeSignatureVersions, SchemeVerfKeys, Signature, SigningError, SigningSchemeType, SigningSchemeTypeVersions, StoredTypedSignature, StoredTypedSignatureVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, canonical_schemes, diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 748260350f..52b284a7cc 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -1,7 +1,7 @@ //! The composite signcryption envelope: one signature per scheme. +use super::UnifiedSigncryption; use super::common::{hybrid_decrypt, hybrid_encrypt, receiver_binding, unsupported_format}; -use super::{SigncryptionFormat, UnifiedSigncryption}; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; @@ -54,12 +54,14 @@ pub fn seal( dsep: &DomainSep, msg: &[u8], ) -> Result { - // Refuse to produce a composite envelope under a scheme set that selects the - // frozen layout: it would be tagged `EcdsaV0` and handed to a parser that - // cannot read it. - let format = SigncryptionFormat::for_schemes(schemes); - if format != SigncryptionFormat::CompositeV1 { - return Err(unsupported_format(format)); + // Refuse the ECDSA singleton. Such a signcryption would be indistinguishable + // by scheme set from one a caller expects in the frozen layout, and the two + // are not interchangeable — so produce nothing rather than something whose + // reader is ambiguous. + if schemes == [SigningSchemeType::Ecdsa256k1] { + return Err(unsupported_format( + "a multi-signature envelope under ECDSA alone; use the frozen layout instead", + )); } let binding = receiver_binding(receiver_id, receiver_enc_key)?; @@ -80,7 +82,6 @@ pub fn seal( bc2wrap::serialize(&ciphertext) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, receiver_enc_key.encryption_scheme_type(), - format, )) } @@ -89,7 +90,7 @@ pub fn seal( /// /// `expected_schemes` is the verifier's policy, not anything read off the /// message. -pub fn open( +pub(super) fn open( decryption_key: &UnifiedPrivateEncKey, encryption_key: &UnifiedPublicEncKey, sender_keys: &VerfKeySet, @@ -123,9 +124,7 @@ pub fn open( #[cfg(test)] mod tests { use super::super::common::lock_fixture; - use super::super::{ - Signcrypt, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt, - }; + use super::super::{Signcrypt, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt}; use super::*; use crate::cryptography::encryption::PkeSchemeType; use crate::cryptography::signing::test_support::seeded_identity; @@ -184,15 +183,14 @@ mod tests { expected: &[SigningSchemeType], cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { - open( + UnifiedUnsigncryptionKey::new_multi( &f.dec_key, &f.enc_key, &f.keys, expected, &f.receiver_id, - DSEP, - cipher, ) + .open(DSEP, cipher) } /// Round-trips for both PKE schemes the backup and user-decryption paths use. @@ -202,7 +200,6 @@ mod tests { let mut f = fixture(scheme, 100); let cipher = seal_msg(&mut f, b"a composite message"); assert_eq!(cipher.pke_type, scheme); - assert_eq!(cipher.format, SigncryptionFormat::CompositeV1); let opened = open_with(&f, &pair(), &cipher).unwrap(); assert_eq!(&*opened, b"a composite message", "{scheme}"); @@ -251,19 +248,34 @@ mod tests { assert_eq!(&*opened, b"downgrade me"); } - /// The outer format tag is unauthenticated, and it is only a parser - /// selector: the composite opener ignores it, and the schemes that matter - /// are the ones inside the signed envelope. + /// The unified reader picks its layout from `sender`, so the same key type + /// opens a multi-signature envelope and refuses it when built for ECDSA — + /// with no tag on the message involved either way. #[test] - fn the_outer_format_tag_is_not_load_bearing() { - let mut f = fixture(PkeSchemeType::MlKem512, 250); - let mut cipher = seal_msg(&mut f, b"tag is only a hint"); - cipher.format = SigncryptionFormat::EcdsaV0; + fn the_unified_key_dispatches_on_sender_auth() { + let mut f = fixture(PkeSchemeType::MlKem512, 270); + let cipher = seal_msg(&mut f, b"dispatched by key material"); + let multi = UnifiedUnsigncryptionKey::new_multi( + &f.dec_key, + &f.enc_key, + &f.keys, + &pair(), + &f.receiver_id, + ); assert_eq!( - &*open_with(&f, &pair(), &cipher).unwrap(), - b"tag is only a hint" + &*multi.open(DSEP, &cipher).unwrap(), + b"dispatched by key material" ); + + // The same envelope, read by a key built for the frozen layout. + let ecdsa = match f.keys.require(SigningSchemeType::Ecdsa256k1).unwrap() { + crate::cryptography::signatures::UnifiedPublicSigKey::Ecdsa256k1(k) => k.clone(), + _ => unreachable!("the ECDSA member of the set is an ECDSA key"), + }; + let frozen_reader = + UnifiedUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &ecdsa, &f.receiver_id); + assert!(frozen_reader.open(DSEP, &cipher).is_err()); } /// An empty policy must not open anything. @@ -274,14 +286,21 @@ mod tests { assert!(open_with(&f, &[], &cipher).is_err()); } - /// A composite envelope must not be openable by the frozen ECDSA reader, and - /// the frozen layout must not be openable as composite. + /// Neither reader accepts the other's envelope. + /// + /// This is what lets a [`UnifiedSigncryption`] carry no layout tag: the + /// rejection comes from each parser on its own, not from a dispatch step + /// reading an unauthenticated field. Both directions are asserted on the + /// *specific* failure, so a future change that makes one of them succeed — + /// or fail for an unrelated reason — is caught here. #[test] fn the_two_formats_do_not_cross() { let mut f = fixture(PkeSchemeType::MlKem512, 300); let composite = seal_msg(&mut f, b"composite payload"); - // Legacy reader, handed a composite signcryption: refused by dispatch. + // Frozen reader, handed a composite envelope. It decrypts, then reads + // the trailing 32 bytes as `H(sender verification key)` — which they are + // not, being the tail of an ML-DSA signature. let legacy_verf = f .keys .require(SigningSchemeType::Ecdsa256k1) @@ -293,16 +312,18 @@ mod tests { }; let legacy_key = UnifiedUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &legacy_ecdsa, &f.receiver_id); + let err = legacy_key + .unsigncrypt::(DSEP, &composite) + .unwrap_err(); assert!( - legacy_key - .unsigncrypt::(DSEP, &composite) - .is_err(), - "the frozen reader accepted a composite envelope" + err.to_string() + .contains("unexpected verification key digest"), + "the frozen reader must reject a composite envelope on the key digest, got: {err}" ); - // Composite reader, handed a frozen-layout signcryption: it decrypts, - // but the plaintext is `msg ‖ sig ‖ digest` rather than a serialized - // `CompositeEnvelope`, so deserialization refuses it. + // Composite reader, handed a frozen envelope. It decrypts, but the + // plaintext is `msg ‖ sig ‖ digest` rather than a serialized + // `CompositeEnvelope`, so `safe_deserialize` refuses it on the header. let base = lock_fixture(PkeSchemeType::MlKem512, 300); let mut rng = base.rng; let ecdsa_key = @@ -310,14 +331,16 @@ mod tests { let frozen = ecdsa_key .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) .unwrap(); + let err = open_with(&f, &pair(), &frozen).unwrap_err(); assert!( - open_with(&f, &pair(), &frozen).is_err(), - "the composite reader accepted a frozen envelope" + matches!(err, CryptographyError::SerializationError(_)), + "the composite reader must reject a frozen envelope on deserialization, got: {err}" ); } - /// Sealing under a set that selects the frozen layout is refused, rather - /// than producing an envelope no reader can open. + /// Sealing under ECDSA alone is refused. Such an envelope would be + /// indistinguishable by scheme set from one a caller expects in the frozen + /// layout, and the two are not interchangeable. #[test] fn sealing_under_the_ecdsa_singleton_is_refused() { let mut f = fixture(PkeSchemeType::MlKem512, 400); diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index f82075c58b..cd84d85a68 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -9,8 +9,7 @@ use super::common::{DSEP_SIGNCRYPTION, hybrid_decrypt, hybrid_encrypt, receiver_binding}; use super::{ - SigncryptionFormat, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, - UnifiedUnsigncryptionKey, + SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, }; use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey}; use crate::cryptography::error::CryptographyError; @@ -37,7 +36,7 @@ fn sender_verf_key_digest(verf_key: &PublicSigKey) -> Result, Cryptograp // Implements the actual signcryption but without serialization // -// This is the FROZEN `SigncryptionFormat::EcdsaV0` layout; see the module +// This is the FROZEN layout; see the module // documentation for what depends on its bytes and on its RNG usage. pub(super) fn inner_signcryption( signcrypt_key: &UnifiedSigncryptionKey, @@ -70,16 +69,16 @@ pub(super) fn inner_signcryption( bc2wrap::serialize(&ciphertext) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, signcrypt_key.encryption_scheme_type(), - SigncryptionFormat::EcdsaV0, )) } /// Implements the actual unsigncryption process, but without any deserialization /// -/// This is the FROZEN `SigncryptionFormat::EcdsaV0` layout; see the module +/// This is the FROZEN layout; see the module /// documentation. pub(super) fn inner_unsigncrypt( unsign_key: &UnifiedUnsigncryptionKey, + sender_verf_key: &PublicSigKey, dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { @@ -92,8 +91,8 @@ pub(super) fn inner_unsigncrypt( let deserialized_payload: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; let decrypted_plaintext = hybrid_decrypt(deserialized_payload, unsign_key.decryption_key)?; - let (msg, sig) = parse_msg(decrypted_plaintext, unsign_key.sender_verf_key)?; - check_format_and_signature(dsep, &msg, &sig, unsign_key)?; + let (msg, sig) = parse_msg(decrypted_plaintext, sender_verf_key)?; + check_format_and_signature(dsep, &msg, &sig, unsign_key, sender_verf_key)?; Ok(msg) } @@ -139,6 +138,7 @@ fn check_format_and_signature( msg: &[u8], sig: &Signature, unsigncryption_key: &UnifiedUnsigncryptionKey, + sender_verf_key: &PublicSigKey, ) -> Result<(), CryptographyError> { // What should be signed is dsep || msg || H(client_verification_key) || H(client_enc_key) let binding = receiver_binding( @@ -150,8 +150,7 @@ fn check_format_and_signature( check_normalized(sig)?; - unsigncryption_key - .sender_verf_key + sender_verf_key .raw_verifying_key() .verify( &msg_signed, @@ -253,7 +252,7 @@ mod tests { &f.sender_verf_key, &f.receiver_id, ); - check_format_and_signature(DSEP, msg, &sig, &unsign_key).unwrap(); + check_format_and_signature(DSEP, msg, &sig, &unsign_key, &f.sender_verf_key).unwrap(); } } @@ -277,10 +276,6 @@ mod tests { let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); assert_eq!(cipher.pke_type, scheme); - assert_eq!( - cipher.format, - SigncryptionFormat::EcdsaV0 - ); let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); let plaintext = hybrid_decrypt(kem_ct, &f.dec_key).unwrap(); @@ -389,11 +384,7 @@ mod tests { &f.sender_verf_key, &f.receiver_id, ); - let frozen_cipher = UnifiedSigncryption::new( - frozen_payload.clone(), - scheme, - SigncryptionFormat::EcdsaV0, - ); + let frozen_cipher = UnifiedSigncryption::new(frozen_payload.clone(), scheme); let opened: TestType = unsign_key .unsigncrypt(DSEP, &frozen_cipher) .expect("current code must still open the frozen ciphertext"); diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 9a58c53c93..08b3a9f9aa 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -13,10 +13,32 @@ //! # Envelope formats //! //! A signcryption's encrypted plaintext has a layout, and there is more than -//! one. Which one applies is recorded in [`UnifiedSigncryption::format`]; on the -//! write path it follows from the scheme set, via [`SigncryptionFormat::for_schemes`]. +//! one. **A [`UnifiedSigncryption`] does not say which.** The layout follows from +//! the key material a reader holds, named by [`SenderAuth`]: a single +//! [`PublicSigKey`] can only open the frozen layout, and a [`VerfKeySet`] plus a +//! scheme policy can only open the multi-signature one. Those are the same +//! choice, so they are the same value, and there is nothing for a tag to +//! disagree with. //! -//! [`SigncryptionFormat::EcdsaV0`] is the original layout and is **frozen**: +//! A layout tag on the message was tried and removed: it travels outside the +//! ciphertext, so it could never be trusted over the caller's own knowledge of +//! which opener it invoked. The two parsers reject each other's output on their +//! own — a multi-signature envelope fails the frozen reader's +//! `H(sender verification key)` check, and a frozen envelope fails +//! `safe_deserialize` in the multi-signature reader. Both directions are checked +//! by `composite_v1::tests::the_two_formats_do_not_cross`. +//! +//! If a reader ever has to walk stored material of mixed vintage, prefer the +//! type-name header `safe_serialize` already writes *inside* the ciphertext over +//! reinstating an outer tag: an attacker cannot forge it without the decryption +//! key. +//! +//! One exception to "a signature names the schemes it was made under": the +//! frozen layout is **not** scheme-set-bound, because its preimage cannot take a +//! prefix. A reader of that layout learns the scheme only from the verification +//! key it chose. Every other layout binds the set into the signed bytes. +//! +//! The frozen layout is the original one: //! every user-decryption ciphertext produced since 0.11 uses it, the deployed //! browser-side verifier in `crate::client::user_decryption_wasm` parses it, and //! because it carries no version tag of its own and is recovered by subtracting @@ -33,11 +55,17 @@ //! by the backward-compatibility harness: `test_unified_signcryption` in //! `core/service/tests/backward_compatibility_kms.rs` regenerates a //! signcryption from a seeded RNG and compares it byte-for-byte against a -//! fixture frozen at 0.13.0. Moving an RNG draw in [`inner_signcryption`] +//! fixture frozen at 0.13.0. Moving an RNG draw in `ecdsa_v0::inner_signcryption` //! breaks it. //! -//! [`SigncryptionFormat::CompositeV1`] carries one signature per scheme, for the -//! custodian-backup chain. +//! The multi-signature layout carries one signature per scheme, for the +//! custodian-backup chain. Reading it goes through [`Unsigncrypt`] like the +//! frozen layout, on a key built with +//! [`UnifiedUnsigncryptionKey::new_multi`]. Writing it does not yet: it needs a +//! [`NodeSigningIdentity`](crate::cryptography::signatures::NodeSigningIdentity) +//! rather than the single [`PrivateSigKey`] the signing key types hold, so it +//! stays behind [`composite_v1::seal`] until user decryption grows multi-scheme +//! signing support. mod common; pub mod composite_v1; @@ -51,7 +79,7 @@ use crate::cryptography::encryption::{ }; use crate::cryptography::error::CryptographyError; use crate::cryptography::signatures::{ - HasSigningScheme, PrivateSigKey, PublicSigKey, SigningSchemeType, + PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet, }; use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; @@ -167,11 +195,6 @@ impl HasPkeScheme for UnifiedSigncryptionKeyOwned { self.receiver_enc_key.encryption_scheme_type() } } -impl HasSigningScheme for UnifiedSigncryptionKeyOwned { - fn signing_scheme_type(&self) -> SigningSchemeType { - self.signing_key.signing_scheme_type() - } -} /// Internal type for signcryption keys, storing only references to the real internal keys. /// Thus this type should not be serialized instead `UnifiedSigncryptionKeyOwned` should be used. @@ -194,11 +217,6 @@ impl<'a> UnifiedSigncryptionKey<'a> { receiver_id, } } - - /// The envelope format this key produces. - pub fn format(&self) -> SigncryptionFormat { - SigncryptionFormat::for_schemes(&[self.signing_key.signing_scheme_type()]) - } } impl HasPkeScheme for UnifiedSigncryptionKey<'_> { @@ -206,10 +224,26 @@ impl HasPkeScheme for UnifiedSigncryptionKey<'_> { self.receiver_enc_key.encryption_scheme_type() } } -impl HasSigningScheme for UnifiedSigncryptionKey<'_> { - fn signing_scheme_type(&self) -> SigningSchemeType { - self.signing_key.signing_scheme_type() - } + +/// What a reader authenticates a signcryption with — and therefore which +/// envelope layout it can open. +/// +/// The two are the same choice, so they are the same value. There is no way to +/// build a key that is ambiguous about the layout it reads, and no layout tag on +/// the message for the two to disagree with. +#[derive(Clone, Debug)] +pub enum SenderAuth<'a> { + /// A single ECDSA verification key: the frozen layout. + Ecdsa(&'a PublicSigKey), + /// One verification key per scheme, plus the set every signature must have + /// been made under: the multi-signature layout. + /// + /// `expected_schemes` is the reader's policy, not anything read off the + /// message, and an empty one is refused rather than treated as "any". + Multi { + keys: &'a VerfKeySet, + expected_schemes: &'a [SigningSchemeType], + }, } /// Internal reference type for unsigncryption keys, storing only references to the real internal keys. @@ -217,12 +251,13 @@ impl HasSigningScheme for UnifiedSigncryptionKey<'_> { pub struct UnifiedUnsigncryptionKey<'a> { pub decryption_key: &'a UnifiedPrivateEncKey, pub encryption_key: &'a UnifiedPublicEncKey, // Needed for validation of the signcrypted payload - pub sender_verf_key: &'a PublicSigKey, + pub sender: SenderAuth<'a>, /// The ID of the receiver of the signcryption, e.g. blockchain address pub receiver_id: &'a [u8], } impl<'a> UnifiedUnsigncryptionKey<'a> { + /// A reader of the frozen, single-ECDSA layout. pub fn new( decryption_key: &'a UnifiedPrivateEncKey, encryption_key: &'a UnifiedPublicEncKey, @@ -230,16 +265,58 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { receiver_id: &'a [u8], ) -> Self { Self { - sender_verf_key, + sender: SenderAuth::Ecdsa(sender_verf_key), + decryption_key, + encryption_key, + receiver_id, + } + } + + /// A reader of the multi-signature layout, requiring a signature under every + /// scheme in `expected_schemes`. + pub fn new_multi( + decryption_key: &'a UnifiedPrivateEncKey, + encryption_key: &'a UnifiedPublicEncKey, + keys: &'a VerfKeySet, + expected_schemes: &'a [SigningSchemeType], + receiver_id: &'a [u8], + ) -> Self { + Self { + sender: SenderAuth::Multi { + keys, + expected_schemes, + }, decryption_key, encryption_key, receiver_id, } } - /// The envelope format this key can open. - pub fn format(&self) -> SigncryptionFormat { - SigncryptionFormat::for_schemes(&[self.sender_verf_key.signing_scheme_type()]) + /// Decrypt and authenticate `cipher`, returning the message bytes. + /// + /// The layout follows from `sender`; see the module documentation. + fn open( + &self, + dsep: &DomainSep, + cipher: &UnifiedSigncryption, + ) -> Result>, CryptographyError> { + match &self.sender { + SenderAuth::Ecdsa(sender_verf_key) => { + ecdsa_v0::inner_unsigncrypt(self, sender_verf_key, dsep, cipher) + } + SenderAuth::Multi { + keys, + expected_schemes, + } => composite_v1::open( + self.decryption_key, + self.encryption_key, + keys, + expected_schemes, + self.receiver_id, + dsep, + cipher, + ), + } } } @@ -249,12 +326,6 @@ impl HasPkeScheme for UnifiedUnsigncryptionKey<'_> { } } -impl HasSigningScheme for UnifiedUnsigncryptionKey<'_> { - fn signing_scheme_type(&self) -> SigningSchemeType { - self.sender_verf_key.signing_scheme_type() - } -} - #[derive( Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop, VersionsDispatch, )] @@ -298,7 +369,7 @@ impl UnifiedUnsigncryptionKeyOwned { UnifiedUnsigncryptionKey { decryption_key: &self.decryption_key, encryption_key: &self.encryption_key, - sender_verf_key: &self.sender_verf_key, + sender: SenderAuth::Ecdsa(&self.sender_verf_key), receiver_id: &self.receiver_id, } } @@ -310,48 +381,6 @@ impl HasPkeScheme for UnifiedUnsigncryptionKeyOwned { } } -impl HasSigningScheme for UnifiedUnsigncryptionKeyOwned { - fn signing_scheme_type(&self) -> SigningSchemeType { - self.sender_verf_key.signing_scheme_type() - } -} - -#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, VersionsDispatch)] -pub enum SigncryptionFormatVersions { - V0(SigncryptionFormat), -} - -/// The layout of a signcryption's encrypted plaintext. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Serialize, Deserialize, Versionize)] -#[versionize(SigncryptionFormatVersions)] -pub enum SigncryptionFormat { - /// `msg ‖ sig ‖ H(sender verification key)`, ECDSA only. Frozen; see the - /// module documentation. - EcdsaV0, - /// A self-describing, versioned, multi-signature envelope. - CompositeV1, -} - -impl SigncryptionFormat { - /// The format a signcryption produced under `schemes` must use. - pub fn for_schemes(schemes: &[SigningSchemeType]) -> Self { - if schemes == [SigningSchemeType::Ecdsa256k1] { - Self::EcdsaV0 - } else { - Self::CompositeV1 - } - } -} - -impl std::fmt::Display for SigncryptionFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::EcdsaV0 => write!(f, "EcdsaV0"), - Self::CompositeV1 => write!(f, "CompositeV1"), - } - } -} - #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, VersionsDispatch)] pub enum UnifiedSigncryptionVersions { V0(UnifiedSigncryptionV0), @@ -359,9 +388,11 @@ pub enum UnifiedSigncryptionVersions { } /// A signcryption as it was stored and sent before multi-signature envelopes, -/// naming a signing scheme where the current form names a layout. +/// naming the signing scheme that produced it. /// -/// Kept so that legacy material can still be read. +/// Kept so that legacy material can still be read. The scheme it names is +/// dropped on upgrade: a reader gets the layout from the opener it calls, not +/// from the message. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Version)] pub struct UnifiedSigncryptionV0 { pub payload: Vec, @@ -376,30 +407,24 @@ impl Upgrade for UnifiedSigncryptionV0 { Ok(UnifiedSigncryption { payload: self.payload, pke_type: self.pke_type, - format: SigncryptionFormat::for_schemes(&[self.signing_type]), }) } } -/// A signcrypted message, tagged with the layout of its encrypted plaintext. +/// A signcrypted message. /// -/// `format` says how to parse `payload` once decrypted. It does *not* say which -/// schemes signed it: that is inside the envelope, where it is authenticated. +/// Carries no indication of which envelope layout `payload` is in, and no +/// indication of which schemes signed it. Both follow from the opener a reader +/// calls — see the module documentation. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Versionize)] #[versionize(UnifiedSigncryptionVersions)] pub struct UnifiedSigncryption { pub payload: Vec, pub pke_type: PkeSchemeType, - pub format: SigncryptionFormat, } impl UnifiedSigncryption { - /// A signcryption whose encrypted plaintext uses `format`. - pub fn new(payload: Vec, pke_type: PkeSchemeType, format: SigncryptionFormat) -> Self { - Self { - payload, - pke_type, - format, - } + pub fn new(payload: Vec, pke_type: PkeSchemeType) -> Self { + Self { payload, pke_type } } } @@ -500,12 +525,10 @@ impl<'a> Signcrypt for UnifiedSigncryptionKey<'a> { "Could not serialize message for signcryption: {e}", )) })?; - match self.format() { - SigncryptionFormat::EcdsaV0 => { - ecdsa_v0::inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) - } - format @ SigncryptionFormat::CompositeV1 => Err(common::unsupported_format(format)), - } + // This key holds one ECDSA signing key, so the frozen layout is the only + // one it can produce. A multi-signature envelope needs a + // `NodeSigningIdentity` and goes through `composite_v1::seal`. + ecdsa_v0::inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) } } @@ -546,10 +569,10 @@ impl<'a> SigncryptFHEPlaintext for UnifiedSigncryptionKey<'a> { let mut serialized_msg = ZeroizingWriter::new(); bc2wrap::serialize_into(&*signcryption_msg, &mut serialized_msg) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - // Deliberately not routed through `SigncryptionFormat::for_schemes`: the wire type this - // produces, `TypedSigncryptedCiphertext.signcrypted_ciphertext`, is a - // bare `bytes` field with nowhere to record a format, so the only - // layout its readers can parse is the frozen one. + // The wire type this produces, + // `TypedSigncryptedCiphertext.signcrypted_ciphertext`, is a bare `bytes` + // field, and the deployed browser-side verifier parses exactly one + // layout. The frozen one is not a default here, it is the only option. ecdsa_v0::inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) } } @@ -568,25 +591,13 @@ impl SigncryptFHEPlaintext for UnifiedSigncryptionKeyOwned { } } -/// Open `cipher` in whichever format it names. -fn open_dispatch( - unsign_key: &UnifiedUnsigncryptionKey, - dsep: &DomainSep, - cipher: &UnifiedSigncryption, -) -> Result>, CryptographyError> { - match cipher.format { - SigncryptionFormat::EcdsaV0 => ecdsa_v0::inner_unsigncrypt(unsign_key, dsep, cipher), - format @ SigncryptionFormat::CompositeV1 => Err(common::unsupported_format(format)), - } -} - impl<'a> Unsigncrypt for UnifiedUnsigncryptionKey<'a> { fn unsigncrypt( &self, dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result { - let msg_vec = open_dispatch(self, dsep, cipher)?; + let msg_vec = self.open(dsep, cipher)?; safe_deserialize(std::io::Cursor::new(&*msg_vec), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError) } @@ -597,7 +608,7 @@ impl<'a> Unsigncrypt for UnifiedUnsigncryptionKey<'a> { signcryption: &UnifiedSigncryption, ) -> Result<(), CryptographyError> { // Since we use sign-then-encrypt, we need to decrypt first to get the message for signature verification - let _ = open_dispatch(self, dsep, signcryption).map_err(|e| { + let _ = self.open(dsep, signcryption).map_err(|e| { CryptographyError::VerificationError(format!( "failed to decrypt signcryption for validation: {}", e @@ -634,15 +645,12 @@ impl<'a> UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKey<'a> { signcryption: &[u8], link: &[u8], ) -> Result { - // The legacy user-decryption path. See the note in `signcrypt_plaintext` - // for why the format is fixed here rather than chosen: the raw bytes - // this receives have nowhere to record one. + // The legacy user-decryption path; see the note in `signcrypt_plaintext`. let parsed_signcryption = UnifiedSigncryption::new( signcryption.to_owned(), self.encryption_key.encryption_scheme_type(), - SigncryptionFormat::EcdsaV0, ); - let decrypted_signcryption = ecdsa_v0::inner_unsigncrypt(self, dsep, &parsed_signcryption)?; + let decrypted_signcryption = self.open(dsep, &parsed_signcryption)?; // LEGACY should be using safe_deserialization from tfhe-rs let mut signcrypted_msg: SigncryptionPayload = bc2wrap::deserialize_slice(&decrypted_signcryption) @@ -944,90 +952,12 @@ mod tests { .unwrap_err(); } - /// The singleton ECDSA set — and only it — selects the frozen layout. This - /// is what keeps user decryption on `EcdsaV0` without a dedicated branch. - #[test] - fn only_the_ecdsa_singleton_is_the_legacy_format() { - use strum::IntoEnumIterator; - - assert_eq!( - SigncryptionFormat::for_schemes(&[SigningSchemeType::Ecdsa256k1]), - SigncryptionFormat::EcdsaV0 - ); - - for scheme in SigningSchemeType::iter().filter(|s| *s != SigningSchemeType::Ecdsa256k1) { - assert_eq!( - SigncryptionFormat::for_schemes(&[scheme]), - SigncryptionFormat::CompositeV1, - "{scheme} alone must not select the frozen ECDSA layout" - ); - } - - // Adding any scheme to ECDSA leaves the legacy format behind, which is - // what stops a composite signcryption being parsed as a legacy one. - assert_eq!( - SigncryptionFormat::for_schemes(&[ - SigningSchemeType::Ecdsa256k1, - SigningSchemeType::MlDsa87 - ]), - SigncryptionFormat::CompositeV1 - ); - } - - /// Everything produced by the single-ECDSA path must be tagged with the - /// frozen layout, because that is the only layout its bytes can be read as. - #[test] - fn the_legacy_path_produces_the_frozen_format() { - let (mut rng, keys) = test_setup(); - let expected = SigncryptionFormat::EcdsaV0; - - let cipher = keys - .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &TestType { i: 7 }) - .unwrap(); - assert_eq!(cipher.format, expected); - - let plaintext_cipher = keys - .signcrypt_key - .signcrypt_plaintext(&mut rng, b"TESTTEST", &[1], FheTypes::Bool, &[9u8; 4]) - .unwrap(); - assert_eq!(plaintext_cipher.format, expected); - - assert_eq!(keys.signcrypt_key.reference().format(), expected); - assert_eq!(keys.unsigncryption_key.reference().format(), expected); - } - - /// A signcryption claiming the multi-signature layout must be refused rather - /// than parsed as a legacy one. Until that envelope is wired in that is an - /// "unsupported format" error; once it is, the composite opener takes over - /// and this must still never reach [`ecdsa_v0::inner_unsigncrypt`]. - #[test] - fn a_composite_format_does_not_reach_the_legacy_opener() { - let (mut rng, keys) = test_setup(); - let mut cipher = keys - .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &TestType { i: 11 }) - .unwrap(); - cipher.format = SigncryptionFormat::CompositeV1; - - let err = keys - .unsigncryption_key - .unsigncrypt::(b"TESTTEST", &cipher) - .unwrap_err(); - assert!( - matches!(err, CryptographyError::UnsupportedSigncryptionFormat(_)), - "a composite tag must not be handled by the legacy opener: {err}" - ); - } - - /// Material written before multi-signature envelopes must still read, and - /// must still name the layout its bytes are actually in. - /// - /// Only the ECDSA variant was ever written, and it is the one that has to - /// land on the frozen layout. The other schemes are exercised to pin what - /// the mapping would do rather than because such data exists. + /// Material written before multi-signature envelopes must still read. The + /// scheme it named is dropped: a reader gets the layout from the opener it + /// calls, and every V0 artifact was written in the frozen layout regardless + /// of which scheme the field claimed. #[test] - fn v0_upgrades_to_the_frozen_format() { + fn v0_upgrades_by_dropping_the_scheme() { use strum::IntoEnumIterator; for scheme in SigningSchemeType::iter() { @@ -1036,22 +966,16 @@ mod tests { pke_type: PkeSchemeType::MlKem512, signing_type: scheme, }; - let expected = if scheme == SigningSchemeType::Ecdsa256k1 { - SigncryptionFormat::EcdsaV0 - } else { - SigncryptionFormat::CompositeV1 - }; let upgraded = v0.clone().upgrade().unwrap(); assert_eq!(upgraded.payload, v0.payload); assert_eq!(upgraded.pke_type, v0.pke_type); - assert_eq!(upgraded.format, expected); // The upgrade is exactly what the constructor builds, so a // regenerated artifact still compares equal to a frozen one. assert_eq!( upgraded, - UnifiedSigncryption::new(v0.payload, v0.pke_type, expected) + UnifiedSigncryption::new(v0.payload, v0.pke_type) ); } } diff --git a/core/service/src/cryptography/signing/ecdsa.rs b/core/service/src/cryptography/signing/ecdsa.rs index cbb0cf4cb9..7554b5950f 100644 --- a/core/service/src/cryptography/signing/ecdsa.rs +++ b/core/service/src/cryptography/signing/ecdsa.rs @@ -1,6 +1,6 @@ //! ECDSA over secp256k1 signing backend. -use super::{HasSigningScheme, Signature, SigningError, SigningScheme, SigningSchemeType}; +use super::{Signature, SigningError, SigningScheme, SigningSchemeType}; use crate::anyhow_tracked; use crate::cryptography::error::CryptographyError; use crate::cryptography::internal_crypto_types::LegacySerialization; @@ -87,8 +87,8 @@ impl PublicSigKey { } } -impl HasSigningScheme for PublicSigKey { - fn signing_scheme_type(&self) -> SigningSchemeType { +impl PublicSigKey { + pub fn signing_scheme_type(&self) -> SigningSchemeType { SigningSchemeType::Ecdsa256k1 } } @@ -246,8 +246,8 @@ impl PrivateSigKey { } } -impl HasSigningScheme for PrivateSigKey { - fn signing_scheme_type(&self) -> SigningSchemeType { +impl PrivateSigKey { + pub fn signing_scheme_type(&self) -> SigningSchemeType { SigningSchemeType::Ecdsa256k1 } } diff --git a/core/service/src/cryptography/signing/identity.rs b/core/service/src/cryptography/signing/identity.rs index 25adb9514a..930f88841e 100644 --- a/core/service/src/cryptography/signing/identity.rs +++ b/core/service/src/cryptography/signing/identity.rs @@ -149,7 +149,7 @@ mod tests { use super::*; use crate::cryptography::signatures::gen_sig_keys; use crate::cryptography::signing::test_support::seeded_identity; - use crate::cryptography::signing::{HasSigningScheme, unified_verify}; + use crate::cryptography::signing::unified_verify; use aes_prng::AesRng; use rand::SeedableRng; use strum::IntoEnumIterator; diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 5e01fc4319..59d366f8e1 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -114,11 +114,6 @@ pub enum SigningError { }, } -/// Trait for any value that is tied to a concrete signature scheme. -pub trait HasSigningScheme { - fn signing_scheme_type(&self) -> SigningSchemeType; -} - #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, VersionsDispatch)] pub enum SigningSchemeTypeVersions { V0(SigningSchemeType), @@ -327,12 +322,6 @@ impl Signature { } } -impl HasSigningScheme for Signature { - fn signing_scheme_type(&self) -> SigningSchemeType { - self.scheme - } -} - /// A signing key tagged with the scheme it belongs to. /// Large types are boxed so the enum remains small to move. pub enum UnifiedPrivateSigKey { @@ -343,8 +332,9 @@ pub enum UnifiedPrivateSigKey { MlDsa87(Box>), } -impl HasSigningScheme for UnifiedPrivateSigKey { - fn signing_scheme_type(&self) -> SigningSchemeType { +impl UnifiedPrivateSigKey { + /// The scheme this key belongs to. + pub fn signing_scheme_type(&self) -> SigningSchemeType { match self { UnifiedPrivateSigKey::Ecdsa256k1(_) => SigningSchemeType::Ecdsa256k1, UnifiedPrivateSigKey::Ed25519(_) => SigningSchemeType::Ed25519, @@ -353,9 +343,7 @@ impl HasSigningScheme for UnifiedPrivateSigKey { UnifiedPrivateSigKey::MlDsa87(_) => SigningSchemeType::MlDsa87, } } -} -impl UnifiedPrivateSigKey { /// Derive the matching public verification key, when the scheme supports it. /// /// Currently always `Ok` (every supported scheme can derive it); fallible so @@ -490,10 +478,9 @@ impl UnifiedPublicSigKey { other => format!("0x{}", hex::encode(other.digest())), } } -} -impl HasSigningScheme for UnifiedPublicSigKey { - fn signing_scheme_type(&self) -> SigningSchemeType { + /// The scheme this key belongs to. + pub fn signing_scheme_type(&self) -> SigningSchemeType { match self { UnifiedPublicSigKey::Ecdsa256k1(_) => SigningSchemeType::Ecdsa256k1, UnifiedPublicSigKey::Ed25519(_) => SigningSchemeType::Ed25519, diff --git a/core/service/src/cryptography/signing/seed.rs b/core/service/src/cryptography/signing/seed.rs index 3ed58997b7..35bbf1124c 100644 --- a/core/service/src/cryptography/signing/seed.rs +++ b/core/service/src/cryptography/signing/seed.rs @@ -127,7 +127,6 @@ impl RootSigningSeed { /// use the persisted [`PrivateSigKey`]. #[cfg(feature = "non-wasm")] pub(crate) fn derive_ecdsa_signing_key(&self) -> Result { - use crate::cryptography::signing::HasSigningScheme; match self.derive_signing_key(SigningSchemeType::Ecdsa256k1)? { UnifiedPrivateSigKey::Ecdsa256k1(sk) => Ok(sk.clone()), @@ -315,7 +314,7 @@ impl Zeroize for DerivedKeyCache { mod tests { use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; - use crate::cryptography::signing::{HasSigningScheme, unified_sign, unified_verify}; + use crate::cryptography::signing::{unified_sign, unified_verify}; use aes_prng::AesRng; use hashing::DomainSep; use rand::SeedableRng; diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 0fa7575f5d..335c868d17 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -1,7 +1,7 @@ //! The verification keys one party publishes, one per signature scheme. use super::identity::NodeSigningIdentity; -use super::{HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey}; +use super::{SigningError, SigningSchemeType, UnifiedPublicSigKey}; use hashing::{DomainSep, hash_element}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; diff --git a/core/service/src/engine/backup_operator.rs b/core/service/src/engine/backup_operator.rs index d270d89359..a5e3bd9d30 100644 --- a/core/service/src/engine/backup_operator.rs +++ b/core/service/src/engine/backup_operator.rs @@ -1456,7 +1456,7 @@ mod tests { backup::custodian::{CustodianSetupMessagePayload, HEADER, InternalCustodianContext}, cryptography::{ signatures::{SigningSchemeType, gen_sig_keys}, - signcryption::{SigncryptionFormat, UnifiedSigncryption}, + signcryption::UnifiedSigncryption, }, engine::base::derive_request_id, }; @@ -1823,7 +1823,6 @@ mod tests { signcryption: UnifiedSigncryption::new( vec![1, 2, 3], BACKUP_PKE_SCHEME, - SigncryptionFormat::EcdsaV0, ), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); diff --git a/core/service/src/util/key_setup/mod.rs b/core/service/src/util/key_setup/mod.rs index abecc06a4f..f4beabe0ff 100644 --- a/core/service/src/util/key_setup/mod.rs +++ b/core/service/src/util/key_setup/mod.rs @@ -1716,7 +1716,7 @@ mod tests { use crate::cryptography::signatures::{PrivateSigKey, PublicSigKey, gen_sig_keys}; use crate::cryptography::signing::identity::NodeSigningIdentity; use crate::cryptography::signing::seed::RootSigningSeed; - use crate::cryptography::signing::{HasSigningScheme, SigningSchemeType, unified_verify}; + use crate::cryptography::signing::{SigningSchemeType, unified_verify}; use crate::engine::threshold::service::epoch_manager::EpochData; use crate::util::key_setup::{ all_verf_material_slots, delete_all_verf_material, non_legacy_verf_material_slots, diff --git a/core/service/src/vault/storage/mod.rs b/core/service/src/vault/storage/mod.rs index 2cb1cc4777..22723b5f8e 100644 --- a/core/service/src/vault/storage/mod.rs +++ b/core/service/src/vault/storage/mod.rs @@ -859,7 +859,7 @@ pub mod tests { cryptography::{ encryption::{Encryption, PkeScheme, UnifiedPublicEncKey}, signatures::{PrivateSigKey, SigningSchemeType, gen_sig_keys}, - signcryption::{SigncryptionFormat, UnifiedSigncryption}, + signcryption::UnifiedSigncryption, }, }; use aes_prng::AesRng; @@ -936,7 +936,6 @@ pub mod tests { signcryption: UnifiedSigncryption::new( vec![1, 2, 3], BACKUP_PKE_SCHEME, - SigncryptionFormat::EcdsaV0, ), }; let mut cts = BTreeMap::new(); diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index 637b9d6850..80a223ea23 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -60,8 +60,8 @@ use kms_lib::{ gen_sig_keys, }, signcryption::{ - Signcrypt, SigncryptionFormat, SigncryptionPayload, UnifiedSigncryption, - UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned, + Signcrypt, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, + UnifiedUnsigncryptionKeyOwned, }, }, engine::{ @@ -1150,7 +1150,6 @@ fn test_recovery_material( signcryption: UnifiedSigncryption::new( payload.to_vec(), BACKUP_PKE_SCHEME, - SigncryptionFormat::EcdsaV0, ), }; cts.insert(cus_role, cts_out.clone()); @@ -1195,7 +1194,6 @@ fn test_internal_recovery_request( let signcryption = UnifiedSigncryption::new( payload.to_vec(), BACKUP_PKE_SCHEME, - SigncryptionFormat::EcdsaV0, ); cts.insert(cur_role, InnerOperatorBackupOutput { signcryption }); } @@ -1282,7 +1280,6 @@ fn test_internal_custodian_recovery_output( let signcryption = UnifiedSigncryption::new( buf.to_vec(), BACKUP_PKE_SCHEME, - SigncryptionFormat::EcdsaV0, ); let new_versionized = InternalCustodianRecoveryOutput { diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index 6fbfd22fa0..13919ba05e 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -53,11 +53,8 @@ const ALLOW_UNCOVERED: &[&str] = &[ // Field of UnifiedCipher. // Covered via UnifiedCipherTest. "PkeSchemeType", - // Field of UnifiedSigncryption. - // Covered via UnifiedSigncryptionTest. - "SigncryptionFormat", // Field of StoredTypedSignature, and of UnifiedSigncryptionV0. (PrivateSigKey / - // PublicSigKey expose it only via the HasSigningScheme trait method, not as + // PublicSigKey expose it only via the `signing_scheme_type` method, not as // a struct field.) // Covered via StoredTypedSignatureTest, and via UnifiedSigncryptionTest. "SigningSchemeType", From 23c06ba358666065e527d9357217ce5e36897334 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 15:06:07 +0200 Subject: [PATCH 017/114] refactor: more pruning --- .../cryptography/signcryption/composite_v1.rs | 46 ++++------------ .../src/cryptography/signcryption/mod.rs | 25 ++------- .../src/cryptography/signing/composite.rs | 54 +++++++++---------- .../service/src/cryptography/signing/ecdsa.rs | 5 +- 4 files changed, 42 insertions(+), 88 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 52b284a7cc..fe1d04dad1 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -88,13 +88,11 @@ pub fn seal( /// Open a composite signcryption, returning the message only once every /// constituent signature has verified. /// -/// `expected_schemes` is the verifier's policy, not anything read off the -/// message. +/// `sender_keys` is the verifier's policy, not anything read off the message pub(super) fn open( decryption_key: &UnifiedPrivateEncKey, encryption_key: &UnifiedPublicEncKey, sender_keys: &VerfKeySet, - expected_schemes: &[SigningSchemeType], receiver_id: &[u8], dsep: &DomainSep, cipher: &UnifiedSigncryption, @@ -115,7 +113,7 @@ pub(super) fn open( let signed = Zeroizing::new([envelope.msg.as_slice(), binding.as_slice()].concat()); envelope .signature - .verify_uniform(sender_keys, expected_schemes, dsep, &signed) + .verify_uniform(sender_keys, dsep, &signed) .map_err(|e| CryptographyError::VerificationError(e.to_string()))?; Ok(Zeroizing::new(envelope.msg)) @@ -180,17 +178,10 @@ mod tests { fn open_with( f: &Fixture, - expected: &[SigningSchemeType], cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { - UnifiedUnsigncryptionKey::new_multi( - &f.dec_key, - &f.enc_key, - &f.keys, - expected, - &f.receiver_id, - ) - .open(DSEP, cipher) + UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id) + .open(DSEP, cipher) } /// Round-trips for both PKE schemes the backup and user-decryption paths use. @@ -201,7 +192,7 @@ mod tests { let cipher = seal_msg(&mut f, b"a composite message"); assert_eq!(cipher.pke_type, scheme); - let opened = open_with(&f, &pair(), &cipher).unwrap(); + let opened = open_with(&f, &cipher).unwrap(); assert_eq!(&*opened, b"a composite message", "{scheme}"); } } @@ -225,7 +216,7 @@ mod tests { ) .unwrap(); - let err = open_with(&f, &pair(), &cipher).unwrap_err(); + let err = open_with(&f, &cipher).unwrap_err(); assert!( matches!(err, CryptographyError::VerificationError(_)), "{err}" @@ -239,7 +230,6 @@ mod tests { &f.dec_key, &f.enc_key, &weaker_keys, - &weaker, &f.receiver_id, DSEP, &cipher, @@ -256,13 +246,8 @@ mod tests { let mut f = fixture(PkeSchemeType::MlKem512, 270); let cipher = seal_msg(&mut f, b"dispatched by key material"); - let multi = UnifiedUnsigncryptionKey::new_multi( - &f.dec_key, - &f.enc_key, - &f.keys, - &pair(), - &f.receiver_id, - ); + let multi = + UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id); assert_eq!( &*multi.open(DSEP, &cipher).unwrap(), b"dispatched by key material" @@ -278,14 +263,6 @@ mod tests { assert!(frozen_reader.open(DSEP, &cipher).is_err()); } - /// An empty policy must not open anything. - #[test] - fn an_empty_expected_set_is_rejected() { - let mut f = fixture(PkeSchemeType::MlKem512, 260); - let cipher = seal_msg(&mut f, b"needs a policy"); - assert!(open_with(&f, &[], &cipher).is_err()); - } - /// Neither reader accepts the other's envelope. /// /// This is what lets a [`UnifiedSigncryption`] carry no layout tag: the @@ -331,7 +308,7 @@ mod tests { let frozen = ecdsa_key .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) .unwrap(); - let err = open_with(&f, &pair(), &frozen).unwrap_err(); + let err = open_with(&f, &frozen).unwrap_err(); assert!( matches!(err, CryptographyError::SerializationError(_)), "the composite reader must reject a frozen envelope on deserialization, got: {err}" @@ -374,7 +351,6 @@ mod tests { &f.dec_key, &f.enc_key, &other_keys, - &pair(), &f.receiver_id, DSEP, &cipher, @@ -396,7 +372,6 @@ mod tests { &f.dec_key, &f.enc_key, &f.keys, - &pair(), &other_id, DSEP, &cipher, @@ -412,14 +387,13 @@ mod tests { let mut flipped = cipher.clone(); flipped.payload[0] ^= 0x01; - assert!(open_with(&f, &pair(), &flipped).is_err()); + assert!(open_with(&f, &flipped).is_err()); assert!( open( &f.dec_key, &f.enc_key, &f.keys, - &pair(), &f.receiver_id, b"OTHERDSP", &cipher, diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 08b3a9f9aa..ea4a37f04d 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -235,15 +235,8 @@ impl HasPkeScheme for UnifiedSigncryptionKey<'_> { pub enum SenderAuth<'a> { /// A single ECDSA verification key: the frozen layout. Ecdsa(&'a PublicSigKey), - /// One verification key per scheme, plus the set every signature must have - /// been made under: the multi-signature layout. - /// - /// `expected_schemes` is the reader's policy, not anything read off the - /// message, and an empty one is refused rather than treated as "any". - Multi { - keys: &'a VerfKeySet, - expected_schemes: &'a [SigningSchemeType], - }, + /// One verification key per scheme: the multi-signature layout. + Multi(&'a VerfKeySet), } /// Internal reference type for unsigncryption keys, storing only references to the real internal keys. @@ -273,19 +266,15 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { } /// A reader of the multi-signature layout, requiring a signature under every - /// scheme in `expected_schemes`. + /// scheme `keys` holds a key for. pub fn new_multi( decryption_key: &'a UnifiedPrivateEncKey, encryption_key: &'a UnifiedPublicEncKey, keys: &'a VerfKeySet, - expected_schemes: &'a [SigningSchemeType], receiver_id: &'a [u8], ) -> Self { Self { - sender: SenderAuth::Multi { - keys, - expected_schemes, - }, + sender: SenderAuth::Multi(keys), decryption_key, encryption_key, receiver_id, @@ -304,14 +293,10 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { SenderAuth::Ecdsa(sender_verf_key) => { ecdsa_v0::inner_unsigncrypt(self, sender_verf_key, dsep, cipher) } - SenderAuth::Multi { - keys, - expected_schemes, - } => composite_v1::open( + SenderAuth::Multi(keys) => composite_v1::open( self.decryption_key, self.encryption_key, keys, - expected_schemes, self.receiver_id, dsep, cipher, diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 6dd4b36db6..dd807922a7 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -147,17 +147,16 @@ impl CompositeSignature { } /// Check every signature against `keys`, having first checked that this - /// signature was made under exactly `expected`. + /// signature was made under exactly the schemes `keys` holds keys for. /// /// Every signature must verify. pub fn verify_uniform( &self, keys: &VerfKeySet, - expected: &[SigningSchemeType], dsep: &DomainSep, msg: &[u8], ) -> Result<(), SigningError> { - let expected = canonical_schemes(expected)?; + let expected = keys.schemes(); // The scheme-set comparison happens before any cryptography, // so a composite signature presented with one of its parts removed is // rejected for being the wrong shape @@ -171,12 +170,15 @@ impl CompositeSignature { let preimage = Self::preimage(&schemes, msg)?; for entry in &self.0 { let signature = Signature::new(entry.scheme, entry.signature.clone()); + // Cannot fail: `schemes` equals `keys.schemes()` on this path. unified_verify(dsep, &preimage, &signature, keys.require(entry.scheme)?)?; } Ok(()) } - /// The schemes this signature was made under, derived from its entries.. + /// The schemes this signature was made under, derived from its entries. + /// + /// Canonical by construction: every constructor validates the entry order. pub fn schemes(&self) -> Vec { self.0.iter().map(|entry| entry.scheme).collect() } @@ -271,7 +273,7 @@ mod tests { let (identity, keys, schemes) = setup(1); let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); assert_eq!(sig.schemes(), schemes); - sig.verify_uniform(&keys, &schemes, DSEP, MSG).unwrap(); + sig.verify_uniform(&keys, DSEP, MSG).unwrap(); } /// Removing a signature must not leave something that verifies under the remaining scheme. @@ -285,19 +287,17 @@ mod tests { // Against the original policy it is the wrong scheme set... assert!(matches!( - stripped.verify_uniform(&keys, &schemes, DSEP, MSG), + stripped.verify_uniform(&keys, DSEP, MSG), Err(SigningError::UnexpectedSchemeSet { .. }) )); - // ...and even if a verifier were talked into asking only for ECDSA, the - // surviving signature covers a preimage naming the *pair*, so it does - // not verify against the single-scheme preimage either. - let ecdsa_only = vec![SigningSchemeType::Ecdsa256k1]; - assert!( - stripped - .verify_uniform(&keys, &ecdsa_only, DSEP, MSG) - .is_err() - ); + // ...and a verifier downgraded all the way to an ECDSA-only key set — + // the only way to ask for less, now that the key set *is* the policy — + // still rejects it: the surviving signature covers a preimage naming the + // *pair*, which does not match the single-scheme preimage. + let ecdsa_only = + VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); + assert!(stripped.verify_uniform(&ecdsa_only, DSEP, MSG).is_err()); } /// A list that is not ordered by scheme, or repeats one, is refused on @@ -329,7 +329,7 @@ mod tests { entries[index].signature[0] ^= 0x01; let tampered = CompositeSignature::from_canonical(entries).unwrap(); assert!( - tampered.verify_uniform(&keys, &schemes, DSEP, MSG).is_err(), + tampered.verify_uniform(&keys, DSEP, MSG).is_err(), "tampering with signature {index} was not detected" ); } @@ -340,11 +340,11 @@ mod tests { let (identity, keys, schemes) = setup(5); let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); assert!( - sig.verify_uniform(&keys, &schemes, DSEP, b"a different message") + sig.verify_uniform(&keys, DSEP, b"a different message") .is_err() ); assert!( - sig.verify_uniform(&keys, &schemes, b"OTHERDSP", MSG) + sig.verify_uniform(&keys, b"OTHERDSP", MSG) .is_err() ); } @@ -357,9 +357,9 @@ mod tests { let (_, other_keys, _) = setup(7); let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - sig.verify_uniform(&keys, &schemes, DSEP, MSG).unwrap(); + sig.verify_uniform(&keys, DSEP, MSG).unwrap(); assert!( - sig.verify_uniform(&other_keys, &schemes, DSEP, MSG) + sig.verify_uniform(&other_keys, DSEP, MSG) .is_err() ); } @@ -374,8 +374,8 @@ mod tests { let ecdsa_only = VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); assert!(matches!( - sig.verify_uniform(&ecdsa_only, &schemes, DSEP, MSG), - Err(SigningError::NoVerificationKey(_)) + sig.verify_uniform(&ecdsa_only, DSEP, MSG), + Err(SigningError::UnexpectedSchemeSet { .. }) )); } @@ -483,14 +483,12 @@ mod tests { )); } - /// An empty policy must not verify anything, including a signature that - /// carries entries. + /// An empty policy is unrepresentable rather than rejected: the policy *is* + /// the key set, and a [`VerfKeySet`] cannot be empty. #[test] - fn an_empty_expected_set_is_rejected() { - let (identity, keys, schemes) = setup(10); - let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + fn an_empty_policy_cannot_be_constructed() { assert!(matches!( - sig.verify_uniform(&keys, &[], DSEP, MSG), + VerfKeySet::new(std::collections::BTreeMap::new()), Err(SigningError::EmptySchemeSet) )); } diff --git a/core/service/src/cryptography/signing/ecdsa.rs b/core/service/src/cryptography/signing/ecdsa.rs index 7554b5950f..4c5b88580c 100644 --- a/core/service/src/cryptography/signing/ecdsa.rs +++ b/core/service/src/cryptography/signing/ecdsa.rs @@ -85,9 +85,8 @@ impl PublicSigKey { pub fn to_uncompressed_bytes(&self) -> Vec { self.pk.0.to_encoded_point(false).as_bytes().to_vec() } -} -impl PublicSigKey { + /// The scheme this key belongs to: always ECDSA/secp256k1. pub fn signing_scheme_type(&self) -> SigningSchemeType { SigningSchemeType::Ecdsa256k1 } @@ -244,9 +243,7 @@ impl PrivateSigKey { })?; Ok(PrivateSigKey::new(key)) } -} -impl PrivateSigKey { pub fn signing_scheme_type(&self) -> SigningSchemeType { SigningSchemeType::Ecdsa256k1 } From 6477d22039940128333864ed35b008a9c2e37b40 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 15:23:37 +0200 Subject: [PATCH 018/114] chore: more simplification --- core/service/src/client/client_non_wasm.rs | 30 ++++++------ core/service/src/client/client_wasm.rs | 2 +- .../src/cryptography/signcryption/common.rs | 18 ++----- .../src/cryptography/signcryption/ecdsa_v0.rs | 4 +- .../src/cryptography/signing/composite.rs | 49 +++++++++---------- core/service/src/cryptography/signing/mod.rs | 6 +-- core/service/src/engine/validation_wasm.rs | 2 +- 7 files changed, 48 insertions(+), 63 deletions(-) diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index 1aa56d682b..a029957319 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -1,7 +1,7 @@ use crate::anyhow_error_and_log; use crate::client::client_wasm::Client; use crate::consts::{SIGNING_KEY_ID, signing_material_id}; -use crate::cryptography::signing::{SigningSchemeType, UnifiedPublicSigKey}; +use crate::cryptography::signing::{SigningSchemeType, VerfKeySet}; use crate::engine::validation::{ ExpectedSigner, ResponseSignatures, SignedPayloads, verify_response_signatures, }; @@ -84,7 +84,9 @@ impl Client { let mut scheme_verf_keys = HashMap::new(); for (party_id, cur_storage) in pub_storages.iter() { - scheme_verf_keys.insert(*party_id, read_all_verf_keys(cur_storage).await?); + if let Some(keys) = read_all_verf_keys(cur_storage).await? { + scheme_verf_keys.insert(*party_id, keys); + } } Ok(Client::new( @@ -161,11 +163,10 @@ impl Client { } } -async fn read_all_verf_keys( - storage: &S, -) -> anyhow::Result> { +/// Every typed verification key `storage` holds, or `None` if it holds none. +async fn read_all_verf_keys(storage: &S) -> anyhow::Result> { let data_type = PubDataType::TypedVerfKey.to_string(); - let mut keys = HashMap::new(); + let mut keys = std::collections::BTreeMap::new(); for scheme in SigningSchemeType::iter() { let req_id = signing_material_id(scheme); if !storage.data_exists(&req_id, &data_type).await? { @@ -181,7 +182,10 @@ async fn read_all_verf_keys( })?; keys.insert(scheme, verf_key); } - Ok(keys) + if keys.is_empty() { + return Ok(None); + } + Ok(Some(VerfKeySet::new(keys)?)) } #[cfg(test)] @@ -215,14 +219,12 @@ mod tests { /// A client that knows `identity` as party [`PARTY`], with or without that /// party's selected per-scheme verification keys. fn client_for(identity: &NodeSigningIdentity, key_schemes: &[SigningSchemeType]) -> Client { - let scheme_verf_keys = if !key_schemes.is_empty() { - let keys = key_schemes - .iter() - .map(|&scheme| (scheme, identity.unified_verifying_key(scheme).unwrap())) - .collect(); - HashMap::from([(PARTY, keys)]) - } else { + let scheme_verf_keys = if key_schemes.is_empty() { + // A party with no published keys is absent, not present-and-empty. HashMap::new() + } else { + let keys = VerfKeySet::from_identity(identity, key_schemes).unwrap(); + HashMap::from([(PARTY, keys)]) }; Client::new( HashMap::from([(PARTY, identity.verf_key())]), diff --git a/core/service/src/client/client_wasm.rs b/core/service/src/client/client_wasm.rs index ec6424f7bd..a967feee24 100644 --- a/core/service/src/client/client_wasm.rs +++ b/core/service/src/client/client_wasm.rs @@ -128,7 +128,7 @@ impl Client { let holders = self .scheme_verf_keys .values() - .filter(|keys| keys.contains_key(scheme)) + .filter(|keys| keys.get(*scheme).is_some()) .count(); if holders == 0 { return Err(SigningError::NoVerificationKey(*scheme)); diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index f2f2fd05d8..23f0a462ef 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -22,7 +22,9 @@ pub(super) fn unsupported_format(what: impl std::fmt::Display) -> CryptographyEr /// The digest of the receiver's public encryption key, as it appears in the /// signed preimage. -fn receiver_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Result, CryptographyError> { +pub(super) fn receiver_enc_key_digest( + enc_key: &UnifiedPublicEncKey, +) -> Result, CryptographyError> { match enc_key { UnifiedPublicEncKey::MlKem512(public_enc_key) => { serialize_hash_element(&DSEP_SIGNCRYPTION, public_enc_key) @@ -78,19 +80,6 @@ pub(super) fn hybrid_decrypt( } } -#[allow(dead_code)] -pub(super) fn expected_enc_key_digest(enc_key: &UnifiedPublicEncKey) -> Vec { - match enc_key { - UnifiedPublicEncKey::MlKem512(inner) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() - } - UnifiedPublicEncKey::MlKem1024P384(inner) => { - serialize_hash_element(&DSEP_SIGNCRYPTION, inner).unwrap() - } - _ => unreachable!("only the two locked schemes are exercised"), - } -} - // Test scaffolding shared with `super::ecdsa_v0`, which locks the frozen layout // against the same keys. At module level rather than inside `tests` so that // `ecdsa_v0`'s tests can reach it without a copy. @@ -155,5 +144,4 @@ mod tests { receiver_binding(&f.receiver_id, &other.enc_key).unwrap() ); } - } diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index cd84d85a68..00dc5502a5 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -186,7 +186,7 @@ pub(crate) fn insecure_decrypt_ignoring_signature( #[cfg(test)] mod tests { - use super::super::common::{expected_enc_key_digest, lock_fixture}; + use super::super::common::{lock_fixture, receiver_enc_key_digest}; use super::super::{Signcrypt, Unsigncrypt}; use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; @@ -229,7 +229,7 @@ mod tests { &DSEP[..], msg.as_slice(), f.receiver_id.as_slice(), - expected_enc_key_digest(&f.enc_key).as_slice(), + receiver_enc_key_digest(&f.enc_key).unwrap().as_slice(), ] .concat(); diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index dd807922a7..a31f583f53 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -57,6 +57,19 @@ fn render_schemes(schemes: &[SigningSchemeType]) -> String { ) } +/// The bytes a scheme-set-bound signature covers: the canonical scheme set, +/// length-prefixed, followed by the message. +/// +/// `schemes` is canonicalised here, so callers may pass it in any order and +/// still agree on the bytes. +pub fn scheme_bound_preimage( + schemes: &[SigningSchemeType], + msg: &[u8], +) -> Result, SigningError> { + let schemes = canonical_schemes(schemes)?; + Ok([canonical_scheme_bytes(&schemes).as_slice(), msg].concat()) +} + /// Accept `schemes` only if it is already canonical. fn ensure_canonical(schemes: &[SigningSchemeType]) -> Result<(), SigningError> { let canonical = canonical_schemes(schemes)?; @@ -114,12 +127,6 @@ impl CompositeSignature { &self.0 } - /// The bytes every constituent signature is made over. - pub fn preimage(schemes: &[SigningSchemeType], msg: &[u8]) -> Result, SigningError> { - let schemes = canonical_schemes(schemes)?; - Ok([canonical_scheme_bytes(&schemes).as_slice(), msg].concat()) - } - /// Sign `msg` under every scheme in `schemes`, each over the same bytes. #[cfg(feature = "non-wasm")] pub fn sign_uniform( @@ -130,7 +137,7 @@ impl CompositeSignature { ) -> Result { let schemes = canonical_schemes(schemes)?; identity.ensure_supported(&schemes)?; - let preimage = Self::preimage(&schemes, msg)?; + let preimage = scheme_bound_preimage(&schemes, msg)?; let entries = schemes .iter() .map(|&scheme| { @@ -167,7 +174,7 @@ impl CompositeSignature { actual: render_schemes(&schemes), }); } - let preimage = Self::preimage(&schemes, msg)?; + let preimage = scheme_bound_preimage(&schemes, msg)?; for entry in &self.0 { let signature = Signature::new(entry.scheme, entry.signature.clone()); // Cannot fail: `schemes` equals `keys.schemes()` on this path. @@ -184,18 +191,6 @@ impl CompositeSignature { } } -/// The bytes a non-ECDSA result signature covers: the payload, prefixed by the -/// canonical scheme set. -/// -/// `schemes` is canonicalised here, so callers may pass it in any order and -/// still agree on the bytes. -pub fn result_signed_bytes( - schemes: &[SigningSchemeType], - payload_bytes: &[u8], -) -> Result, SigningError> { - CompositeSignature::preimage(schemes, payload_bytes) -} - /// The per-scheme signatures of a *result*: a keygen, CRS, preprocessing or /// decryption response. /// @@ -221,7 +216,7 @@ pub fn sign_result_entries( } // Every non-ECDSA entry commits to the scheme set, so one cannot be lifted // out of a larger response and presented as a complete smaller one. - let signed = result_signed_bytes(schemes, payload_bytes)?; + let signed = scheme_bound_preimage(schemes, payload_bytes)?; schemes .iter() .map(|&scheme| { @@ -404,7 +399,7 @@ mod tests { ]; let eip712_hash = [0x11u8; 32]; let payload = b"the serialized result payload"; - let bound = result_signed_bytes(&schemes, payload).unwrap(); + let bound = scheme_bound_preimage(&schemes, payload).unwrap(); let entries = sign_result_entries(&identity, &schemes, DSEP, &eip712_hash, payload).unwrap(); @@ -447,14 +442,14 @@ mod tests { fn preimages_separate_scheme_sets() { let single = vec![SigningSchemeType::Ecdsa256k1]; assert_ne!( - CompositeSignature::preimage(&single, MSG).unwrap(), - CompositeSignature::preimage(&pair(), MSG).unwrap() + scheme_bound_preimage(&single, MSG).unwrap(), + scheme_bound_preimage(&pair(), MSG).unwrap() ); // The length prefix is what separates a set from a longer one starting // with it. assert!( - !CompositeSignature::preimage(&pair(), MSG) + !scheme_bound_preimage(&pair(), MSG) .unwrap() .starts_with(&canonical_scheme_bytes(&single)) ); @@ -473,8 +468,8 @@ mod tests { // So callers may pass any order and still agree on the signed bytes. assert_eq!( - CompositeSignature::preimage(&reordered, MSG).unwrap(), - CompositeSignature::preimage(&pair(), MSG).unwrap() + scheme_bound_preimage(&reordered, MSG).unwrap(), + scheme_bound_preimage(&pair(), MSG).unwrap() ); assert!(matches!( diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 59d366f8e1..68d74cd8cb 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -492,8 +492,8 @@ impl UnifiedPublicSigKey { } /// The verification keys a client or validator holds for its peers: per party -/// id, one key per scheme that party has published. -pub type SchemeVerfKeys = HashMap>; +/// id, the set of keys that party has published. +pub type SchemeVerfKeys = HashMap; /// The verification key `party_id` published for `scheme`, if it published one. pub fn verf_key_for( @@ -501,7 +501,7 @@ pub fn verf_key_for( party_id: u32, scheme: SigningSchemeType, ) -> Option<&UnifiedPublicSigKey> { - keys.get(&party_id).and_then(|keys| keys.get(&scheme)) + keys.get(&party_id).and_then(|keys| keys.get(scheme)) } /// Sign `msg` (domain-separated by `dsep`) under the scheme of `sk`. diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index f84a501d22..a8e8e30b9a 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -445,7 +445,7 @@ pub(crate) fn verify_response_signatures( } // Bound to the set this verifier requested, so an entry lifted from a // response signed under a larger set does not verify here. - let signed_bytes = crate::cryptography::signing::composite::result_signed_bytes( + let signed_bytes = crate::cryptography::signing::composite::scheme_bound_preimage( requested, payloads.payload_bytes, ) From 90ae047c9205b0bce0661579b3ed8097ba747d1a Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 15:29:19 +0200 Subject: [PATCH 019/114] fix: missing diff --- core/service/src/engine/storage_material_verification.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index 44718ee1dc..b72680c827 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -545,7 +545,7 @@ fn verify_metadata_signatures( // outside: at boot there is no request, and the question is whether the // stored list is intact. let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); - let signed_bytes = crate::cryptography::signing::composite::result_signed_bytes( + let signed_bytes = crate::cryptography::signing::composite::scheme_bound_preimage( &stored_schemes, payload_bytes, )?; From a30a666a6014e0252a22371cda14c89267ff62be Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 15:39:32 +0200 Subject: [PATCH 020/114] fix: lint --- core/service/src/backup/operator.rs | 14 ++++---------- core/service/src/cryptography/signatures.rs | 2 +- core/service/src/cryptography/signing/composite.rs | 12 ++++-------- core/service/src/cryptography/signing/mod.rs | 2 +- core/service/src/engine/backup_operator.rs | 10 ++-------- .../src/engine/storage_material_verification.rs | 6 ++++-- .../src/engine/threshold/service/kms_impl.rs | 2 +- core/service/src/engine/validation_wasm.rs | 2 +- .../src/vault/storage/crypto_material/threshold.rs | 4 ++-- core/service/src/vault/storage/mod.rs | 7 ++----- 10 files changed, 22 insertions(+), 39 deletions(-) diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index 2820cd251d..28e27ef27a 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -14,8 +14,8 @@ use crate::{ }, }; use crate::{ - backup::custodian::DSEP_BACKUP_CUSTODIAN, backup::VESTIGIAL_BACKUP_SIGNING_TYPE, + backup::custodian::DSEP_BACKUP_CUSTODIAN, cryptography::signatures::{internal_sign, internal_verify_sig}, }; use crate::{ @@ -179,10 +179,7 @@ impl TryFrom for InnerOperatorBackupOutput { fn try_from(value: OperatorBackupOutput) -> Result { Ok(Self { - signcryption: UnifiedSigncryption::new( - value.signcryption, - value.pke_type.try_into()?, - ), + signcryption: UnifiedSigncryption::new(value.signcryption, value.pke_type.try_into()?), }) } } @@ -930,7 +927,7 @@ mod tests { consts::DEFAULT_MPC_CONTEXT, cryptography::{ encryption::{Encryption, PkeScheme}, - signatures::{SigningSchemeType, gen_sig_keys}, + signatures::gen_sig_keys, }, engine::base::derive_request_id, }; @@ -976,10 +973,7 @@ mod tests { commitments.insert(Role::indexed_from_one(3), vec![3_u8; 32]); let mut cts = BTreeMap::new(); let cts_out = InnerOperatorBackupOutput { - signcryption: UnifiedSigncryption::new( - vec![1, 2, 3], - BACKUP_PKE_SCHEME, - ), + signcryption: UnifiedSigncryption::new(vec![1, 2, 3], BACKUP_PKE_SCHEME), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); cts.insert(Role::indexed_from_one(2), cts_out.clone()); diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index f8e43fd771..d14bf8cee6 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -17,7 +17,7 @@ pub use crate::cryptography::signing::ecdsa::{ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ - CompositeSignature, CompositeSignatureVersions, SchemeVerfKeys, Signature, + CompositeSignature, SchemeVerfKeys, Signature, SigningError, SigningSchemeType, SigningSchemeTypeVersions, StoredTypedSignature, StoredTypedSignatureVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, canonical_schemes, diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index a31f583f53..7d6fa6e3d5 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -13,9 +13,9 @@ use super::identity::NodeSigningIdentity; use super::typed_signature::StoredTypedSignature; use super::verf_key_set::VerfKeySet; use super::{Signature, SigningError, SigningSchemeType, unified_verify}; +use crate::impl_generic_versionize; use hashing::DomainSep; use serde::{Deserialize, Serialize}; -use tfhe_versionable::{Versionize, VersionsDispatch}; /// Sort `schemes` into canonical order and drop duplicates. /// @@ -83,11 +83,6 @@ fn ensure_canonical(schemes: &[SigningSchemeType]) -> Result<(), SigningError> { Ok(()) } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] -pub enum CompositeSignatureVersions { - V0(CompositeSignature), -} - /// One signature per scheme, over a message that commits to the scheme set. /// /// Entries are ordered by scheme and carry no duplicate scheme. @@ -95,11 +90,12 @@ pub enum CompositeSignatureVersions { /// The [`Deserialize`] impl *rejects* a non-canonical list rather than sorting it: /// a value arriving from storage or the wire is attacker-chosen, and silently /// reordering it would give one signature more than one encoding. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Versionize)] -#[versionize(CompositeSignatureVersions)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(transparent)] pub struct CompositeSignature(Vec); +impl_generic_versionize!(CompositeSignature); + impl<'de> Deserialize<'de> for CompositeSignature { fn deserialize(deserializer: D) -> Result where diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 68d74cd8cb..3c75537767 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -12,7 +12,7 @@ pub mod seed; pub mod typed_signature; pub mod verf_key_set; -pub use composite::{CompositeSignature, CompositeSignatureVersions, canonical_schemes}; +pub use composite::{CompositeSignature, canonical_schemes}; pub use typed_signature::{StoredTypedSignature, StoredTypedSignatureVersions}; pub use verf_key_set::VerfKeySet; diff --git a/core/service/src/engine/backup_operator.rs b/core/service/src/engine/backup_operator.rs index a5e3bd9d30..8a9a66c15f 100644 --- a/core/service/src/engine/backup_operator.rs +++ b/core/service/src/engine/backup_operator.rs @@ -1454,10 +1454,7 @@ mod tests { }; use crate::{ backup::custodian::{CustodianSetupMessagePayload, HEADER, InternalCustodianContext}, - cryptography::{ - signatures::{SigningSchemeType, gen_sig_keys}, - signcryption::UnifiedSigncryption, - }, + cryptography::{signatures::gen_sig_keys, signcryption::UnifiedSigncryption}, engine::base::derive_request_id, }; use aes_prng::AesRng; @@ -1820,10 +1817,7 @@ mod tests { InternalCustodianContext::new(custodian_context, enc_key.clone()).unwrap(); let mut cts = BTreeMap::new(); let cts_out = InnerOperatorBackupOutput { - signcryption: UnifiedSigncryption::new( - vec![1, 2, 3], - BACKUP_PKE_SCHEME, - ), + signcryption: UnifiedSigncryption::new(vec![1, 2, 3], BACKUP_PKE_SCHEME), }; cts.insert(Role::indexed_from_one(1), cts_out.clone()); cts.insert(Role::indexed_from_one(2), cts_out.clone()); diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index b72680c827..7e0699bce2 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -39,10 +39,12 @@ use crate::cryptography::signing::ecdsa::{ PrivateSigKey, PublicSigKey, recover_address_from_eip712_hash, }; use crate::cryptography::signing::identity::NodeSigningIdentity; -use crate::cryptography::signing::{Signature, SigningSchemeType, unified_verify}; +use crate::cryptography::signing::{ + Signature, SigningSchemeType, StoredTypedSignature, unified_verify, +}; use crate::engine::base::{ CrsGenMetadata, CrsGenMetadataInner, CurrentPublicMaterialLayout, DSEP_PUBDATA_CRS, - DSEP_PUBDATA_KEY, KeyGenMetadata, KeyGenMetadataInner, StoredTypedSignature, + DSEP_PUBDATA_KEY, KeyGenMetadata, KeyGenMetadataInner, classify_current_public_material, crs_payload_bytes, crs_sol_type, keygen_payload_bytes, keygen_sol_type, }; diff --git a/core/service/src/engine/threshold/service/kms_impl.rs b/core/service/src/engine/threshold/service/kms_impl.rs index 2e453e2bf0..05b135ec9e 100644 --- a/core/service/src/engine/threshold/service/kms_impl.rs +++ b/core/service/src/engine/threshold/service/kms_impl.rs @@ -424,7 +424,7 @@ impl std::fmt::Debug for ThresholdFheKeys { pub struct BucketMetaStore { pub(crate) preprocessing_id: RequestId, pub(crate) external_signature: Vec, - pub(crate) signatures: Vec, + pub(crate) signatures: Vec, pub(crate) preprocessing_store: PreprocMaterial, pub(crate) dkg_param: DKGParams, } diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index a8e8e30b9a..3fc9d7848b 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -387,7 +387,7 @@ fn attribute_scheme_entry( let parsed = Signature::new(scheme, signature.to_vec()); keys.iter() .find_map(|(party_id, party_keys)| { - let verf_key = party_keys.get(&scheme)?; + let verf_key = party_keys.get(scheme)?; unified_verify(payloads.dsep, signed_bytes, &parsed, verf_key).ok()?; addresses.get(party_id).map(|address| (*party_id, *address)) }) diff --git a/core/service/src/vault/storage/crypto_material/threshold.rs b/core/service/src/vault/storage/crypto_material/threshold.rs index 8e6cfe9bdc..882857ee7d 100644 --- a/core/service/src/vault/storage/crypto_material/threshold.rs +++ b/core/service/src/vault/storage/crypto_material/threshold.rs @@ -9,9 +9,9 @@ use tokio::sync::{Mutex, OwnedRwLockReadGuard, RwLock}; use super::base::CryptoMaterialStorage; use crate::{ - cryptography::signatures::{PrivateSigKey, compute_eip712_signature}, + cryptography::signatures::{PrivateSigKey, StoredTypedSignature, compute_eip712_signature}, engine::{ - base::{CrsGenMetadata, KeyGenMetadata, StoredTypedSignature}, + base::{CrsGenMetadata, KeyGenMetadata}, material_integrity::verify_public_key_digest_from_bytes, threshold::service::{ThresholdFheKeys, epoch_manager::EpochData}, }, diff --git a/core/service/src/vault/storage/mod.rs b/core/service/src/vault/storage/mod.rs index 22723b5f8e..35f3799093 100644 --- a/core/service/src/vault/storage/mod.rs +++ b/core/service/src/vault/storage/mod.rs @@ -858,7 +858,7 @@ pub mod tests { consts::{DEFAULT_MPC_CONTEXT, SAFE_SER_SIZE_LIMIT}, cryptography::{ encryption::{Encryption, PkeScheme, UnifiedPublicEncKey}, - signatures::{PrivateSigKey, SigningSchemeType, gen_sig_keys}, + signatures::{PrivateSigKey, gen_sig_keys}, signcryption::UnifiedSigncryption, }, }; @@ -933,10 +933,7 @@ pub mod tests { }; let cts_out = InnerOperatorBackupOutput { - signcryption: UnifiedSigncryption::new( - vec![1, 2, 3], - BACKUP_PKE_SCHEME, - ), + signcryption: UnifiedSigncryption::new(vec![1, 2, 3], BACKUP_PKE_SCHEME), }; let mut cts = BTreeMap::new(); let mut commitments = BTreeMap::new(); From 25fcb242566538c09205934e3c5187761d58cbc4 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 15:43:44 +0200 Subject: [PATCH 021/114] fix: lint --- core/service/src/engine/validation_non_wasm.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 2e86d41e73..174b032d5c 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -1194,6 +1194,7 @@ fn unpack_new_mpc_epoch_req(req: NewMpcEpochRequest) -> anyhow::Result| PublicDecryptionRequest { signing_schemes: schemes, From 7935c01db7825ba80f72d71e0fd833e406255c84 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 15:53:39 +0200 Subject: [PATCH 022/114] chore: incremental versionize --- .../service/src/cryptography/signing/mldsa.rs | 4 ++ core/service/src/cryptography/signing/mod.rs | 4 +- .../src/cryptography/signing/verf_key_set.rs | 61 ++++++++++++++++++- core/service/tests/versioned_enum_coverage.rs | 7 +++ 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/core/service/src/cryptography/signing/mldsa.rs b/core/service/src/cryptography/signing/mldsa.rs index 48fc38964a..0841a82e90 100644 --- a/core/service/src/cryptography/signing/mldsa.rs +++ b/core/service/src/cryptography/signing/mldsa.rs @@ -101,6 +101,10 @@ impl PartialEq for MlDsaVerfKey

{ } } +/// Equality on the inner verifying key is exact, so it is a genuine equivalence +/// relation and this marker is sound. +impl Eq for MlDsaVerfKey

{} + /// The FIPS-204 parameter set's own name. pub trait MlDsaParamSet { const PARAM_SET_NAME: &'static str; diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 3c75537767..201b376b35 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -435,13 +435,13 @@ const _: () = { assert_zeroize_on_drop::>(); }; -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, VersionsDispatch)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] pub enum UnifiedPublicSigKeyVersions { V0(UnifiedPublicSigKey), } /// A verification key tagged with the scheme it belongs to. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Versionize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Versionize)] #[versionize(UnifiedPublicSigKeyVersions)] pub enum UnifiedPublicSigKey { Ecdsa256k1(PublicSigKey), diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 335c868d17..c410b64cb4 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -3,24 +3,45 @@ use super::identity::NodeSigningIdentity; use super::{SigningError, SigningSchemeType, UnifiedPublicSigKey}; use hashing::{DomainSep, hash_element}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; +use tfhe_versionable::{Versionize, VersionsDispatch}; /// Domain separator for the digest that identifies a whole verification-key set. const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] +pub enum VerfKeySetVersions { + V0(VerfKeySet), +} + /// One party's verification keys, keyed by the scheme each belongs to. /// /// The counterpart of [`NodeSigningIdentity`] on the verifying side: an identity /// signs under several schemes, so a verifier needs several keys, and needs them /// to travel together. /// -/// Not versioned since this is an in-memory only structure. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// # Invariants +/// +/// The set is non-empty, and every key is filed under the scheme it actually +/// belongs to. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Versionize)] +#[versionize(VerfKeySetVersions)] +#[serde(transparent)] pub struct VerfKeySet { keys: BTreeMap, } +impl<'de> Deserialize<'de> for VerfKeySet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let keys = BTreeMap::::deserialize(deserializer)?; + Self::new(keys).map_err(serde::de::Error::custom) + } +} + impl VerfKeySet { /// A non-empty key set from `keys`, checking that each key belongs to the /// scheme it is filed under. @@ -156,6 +177,40 @@ mod tests { )); } + /// The invariants must hold for a set that is *read back*, not only for one + /// built in process. `#[serde(transparent)]` makes the encoding of a + /// `VerfKeySet` identical to that of its map, so a hand-crafted map is + /// exactly what an attacker would be able to present. + #[test] + fn a_deserialized_set_is_validated() { + let mut rng = AesRng::seed_from_u64(4); + let identity = seeded_identity(&mut rng); + let ecdsa = identity + .unified_verifying_key(SigningSchemeType::Ecdsa256k1) + .unwrap(); + + let good = VerfKeySet::new(BTreeMap::from([( + SigningSchemeType::Ecdsa256k1, + ecdsa.clone(), + )])) + .unwrap(); + let bytes = bc2wrap::serialize(&good).unwrap(); + assert_eq!( + bc2wrap::deserialize_slice::(&bytes).unwrap(), + good + ); + + // A key filed under a scheme it does not belong to. + let misfiled = BTreeMap::from([(SigningSchemeType::MlDsa87, ecdsa)]); + let bytes = bc2wrap::serialize(&misfiled).unwrap(); + assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); + + // An empty set, which would verify a signature against no keys at all. + let empty = BTreeMap::::new(); + let bytes = bc2wrap::serialize(&empty).unwrap(); + assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); + } + /// The id names the whole set: swapping any single member key changes it. #[test] fn id_changes_when_any_member_key_changes() { diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index 13919ba05e..cab1f8a94b 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -58,6 +58,13 @@ const ALLOW_UNCOVERED: &[&str] = &[ // a struct field.) // Covered via StoredTypedSignatureTest, and via UnifiedSigncryptionTest. "SigningSchemeType", + // Not yet a field of any persisted type. It is versioned ahead of the + // custodian-backup composite-signing work, which puts it inside + // InternalCustodianSetupMessage, CustodianSetupMessagePayload, + // BackupMaterial and InternalRecoveryRequest. + // TODO: give it a real fixture when the generator's pinned rev is next + // bumped; the pinned crate cannot versionize it today. + "VerfKeySet", // Map value in RecoveryValidationMaterialPayload.cts. // Covered via RecoveryValidationMaterialTest. "InnerOperatorBackupOutput", From 13726ff8b0ad1384f900f9116e2e6017909f3bb2 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 16:03:28 +0200 Subject: [PATCH 023/114] fix: compile issues --- .../src/cryptography/signing/verf_key_set.rs | 20 +++++++++++-------- core/service/tests/versioned_enum_coverage.rs | 7 ------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index c410b64cb4..20cd4bb626 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -2,19 +2,14 @@ use super::identity::NodeSigningIdentity; use super::{SigningError, SigningSchemeType, UnifiedPublicSigKey}; +use crate::impl_generic_versionize; use hashing::{DomainSep, hash_element}; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; -use tfhe_versionable::{Versionize, VersionsDispatch}; /// Domain separator for the digest that identifies a whole verification-key set. const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] -pub enum VerfKeySetVersions { - V0(VerfKeySet), -} - /// One party's verification keys, keyed by the scheme each belongs to. /// /// The counterpart of [`NodeSigningIdentity`] on the verifying side: an identity @@ -25,13 +20,22 @@ pub enum VerfKeySetVersions { /// /// The set is non-empty, and every key is filed under the scheme it actually /// belongs to. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Versionize)] -#[versionize(VerfKeySetVersions)] +/// +/// # Versioning +/// +/// Not versioned, for the same reason as [`CompositeSignature`]: a +/// `#[versionize(..)]` derive requires every field to implement `Version`, and +/// `BTreeMap` does not. The stored form +/// is therefore the serde form — with `#[serde(transparent)]`, the bare map — +/// and a later change of shape has to be absorbed by the containing type. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(transparent)] pub struct VerfKeySet { keys: BTreeMap, } +impl_generic_versionize!(VerfKeySet); + impl<'de> Deserialize<'de> for VerfKeySet { fn deserialize(deserializer: D) -> Result where diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index cab1f8a94b..13919ba05e 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -58,13 +58,6 @@ const ALLOW_UNCOVERED: &[&str] = &[ // a struct field.) // Covered via StoredTypedSignatureTest, and via UnifiedSigncryptionTest. "SigningSchemeType", - // Not yet a field of any persisted type. It is versioned ahead of the - // custodian-backup composite-signing work, which puts it inside - // InternalCustodianSetupMessage, CustodianSetupMessagePayload, - // BackupMaterial and InternalRecoveryRequest. - // TODO: give it a real fixture when the generator's pinned rev is next - // bumped; the pinned crate cannot versionize it today. - "VerfKeySet", // Map value in RecoveryValidationMaterialPayload.cts. // Covered via RecoveryValidationMaterialTest. "InnerOperatorBackupOutput", From 445bdc8ab7d7893260384ccfeb13d936ff521fc5 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 16:25:54 +0200 Subject: [PATCH 024/114] fix: handled preliminary claude review --- ai-docs/ARCHITECTURE.md | 8 +-- .../src/cryptography/signing/composite.rs | 50 ++++++++++++-- core/service/src/engine/base.rs | 69 +++++++++++++++---- 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index bdcac04ba4..de173a79ef 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -535,7 +535,7 @@ What it verifies, and how failures are treated: |---|---| | Published keysets and CRSes are present, and their raw stored bytes hash to the digests in `KeyGenMetadata` / `CrsGenMetadata` | repaired from peers and verified again when possible (threshold only); otherwise boot fails | | Current private keygen and CRS metadata with a stored domain reconstruct a valid EIP-712 signature from the node's signing key | boot fails | -| Every non-ECDSA entry of the per-scheme `signatures` in current private keygen and CRS metadata verifies, under the key the node derives for that scheme, over the rebuilt result payload | boot fails | +| Every non-ECDSA entry of the per-scheme `signatures` in current private keygen and CRS metadata verifies, under the key the node derives for that scheme, over the rebuilt result payload prefixed by the schemes the stored entries name | boot fails | | `VerfKey` and `VerfAddress` at `SIGNING_KEY_ID` match the key derived from the private `SigningKey` | boot fails | | Every entry in a `PubDataType` folder is accounted for by private storage or by a fixed-ID convention | error logged, boot continues | | Every top-level name in public storage is a `PubDataType` folder, and every folder can be listed | error logged, boot continues | @@ -581,9 +581,9 @@ Legacy metadata has no digest, so its public objects receive a raw presence chec with a stored domain reconstruct their signed Solidity payload and must recover the node's signing address. Older metadata versions upgrade with no domain and stay unverifiable. The entries of the other schemes sign the serialized result payload (`keygen_payload_bytes`, -`crs_payload_bytes`) instead, which needs no domain, so they are checked for every current -entry. A node that cannot derive a scheme's key, because it holds no root seed, fails boot on -such an entry rather than passing it over. +`crs_payload_bytes`) prefixed by the canonical scheme set, which needs no domain, so they are +checked for every current entry. A node that cannot derive a scheme's key, because it holds no +root seed, fails boot on such an entry rather than passing it over. `PubDataType::DecompressionKey` has no private-storage counterpart at all (`write_decompression_key` persists no private data), so a published decompression key cannot be diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 7d6fa6e3d5..7beb2f70b3 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -191,14 +191,24 @@ impl CompositeSignature { /// decryption response. /// /// ECDSA signs `eip712_hash` recoverably, producing the signature the fhevm -/// contracts verify on chain. Every other scheme signs `dsep ‖ payload_bytes`. +/// contracts verify on chain. Every other scheme signs +/// [`scheme_bound_preimage`] over `payload_bytes`, so it commits to the scheme +/// set as well as to the payload. /// -/// Returns the entries rather than a [`CompositeSignature`] because a result may -/// legitimately request no schemes at all, which that type refuses to represent. +/// `schemes` may be given in any order; the entries come back ordered by +/// scheme. /// -/// WARNING: the non-ECDSA entries here are *not* yet bound to the scheme set, so a -/// verifier must still check the schemes it receives against the ones it asked -/// for! +/// Returns a plain list rather than a [`CompositeSignature`] for two reasons, +/// and they are the only two places the shapes differ: +/// +/// - A result may legitimately request no schemes at all, which that type +/// refuses to represent. +/// - Its ECDSA entry is **not** scheme-bound. An EIP-712 hash is 32 bytes with +/// nowhere to put a prefix, and the fhevm contracts must be able to recover +/// the signer from it, so that entry signs the hash verbatim. +/// +/// WARNING: that unbound ECDSA entry is why a verifier must still check the +/// schemes it received against the ones it asked for. #[cfg(feature = "non-wasm")] pub fn sign_result_entries( identity: &NodeSigningIdentity, @@ -210,9 +220,10 @@ pub fn sign_result_entries( if schemes.is_empty() { return Ok(Vec::new()); } + let schemes = canonical_schemes(schemes)?; // Every non-ECDSA entry commits to the scheme set, so one cannot be lifted // out of a larger response and presented as a complete smaller one. - let signed = scheme_bound_preimage(schemes, payload_bytes)?; + let signed = scheme_bound_preimage(&schemes, payload_bytes)?; schemes .iter() .map(|&scheme| { @@ -419,6 +430,31 @@ mod tests { } } + /// Result entries use the same ordering convention as [`CompositeSignature`]: + /// by scheme, duplicate-free, whatever order the request arrived in. + #[test] + fn result_entries_are_ordered_by_scheme() { + let mut rng = AesRng::seed_from_u64(21); + let identity = seeded_identity(&mut rng); + let requested = [ + SigningSchemeType::MlDsa65, + SigningSchemeType::Ecdsa256k1, + SigningSchemeType::MlDsa65, + SigningSchemeType::Ed25519, + ]; + let canonical = canonical_schemes(&requested).unwrap(); + + let entries = + sign_result_entries(&identity, &requested, DSEP, &[0x11u8; 32], b"payload").unwrap(); + + assert_eq!( + entries.iter().map(|e| e.scheme).collect::>(), + canonical + ); + // ...and that is exactly the list `CompositeSignature` would accept. + CompositeSignature::from_canonical(entries).unwrap(); + } + /// No schemes requested means no per-scheme entries, which is why this /// returns a plain list rather than a `CompositeSignature`. #[test] diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index dc730438dd..af3a927c5f 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -1834,7 +1834,10 @@ pub(crate) mod tests { use crate::cryptography::signatures::internal_sign; use crate::cryptography::signing::identity::NodeSigningIdentity; use crate::cryptography::signing::seed::RootSigningSeed; - use crate::cryptography::signing::{Signature, SigningSchemeType, unified_verify}; + use crate::cryptography::signing::{ + Signature, SigningSchemeType, canonical_schemes, composite::scheme_bound_preimage, + unified_verify, + }; use crate::engine::base::DSEP_PUBLIC_DECRYPTION; use crate::{ consts::{SAFE_SER_SIZE_LIMIT, TEST_PARAM}, @@ -1979,8 +1982,10 @@ pub(crate) mod tests { .unwrap(); assert_eq!(recovered, sk.verf_key().address()); - for (scheme, scheme_sig) in schemes.iter().zip(&sigs.signatures) { - // The wire tag matches the requested scheme, in order. + // Entries come back ordered by scheme, whatever order was asked for. + let ordered = canonical_schemes(&schemes).unwrap(); + for (scheme, scheme_sig) in ordered.iter().zip(&sigs.signatures) { + // The wire tag matches the scheme, in canonical order. assert_eq!( SigningSchemeType::try_from(scheme_sig.scheme).unwrap(), *scheme @@ -1992,18 +1997,44 @@ pub(crate) mod tests { assert_eq!(scheme_sig.signature, sigs.external_signature); assert_ne!(scheme_sig.signature, sigs.signature); } else { - // Every other scheme signs the versioned payload, which carries - // the response bytes together with the extra data. - let signed = + // Every other scheme signs the versioned payload — the response + // bytes together with the extra data — prefixed by the scheme + // set, so the entry commits to the set it was produced under. + let payload_signed = super::public_dec_payload_bytes(&payload_bytes, extra_data).unwrap(); + let signed = scheme_bound_preimage(&ordered, &payload_signed).unwrap(); let vk = sk.unified_verifying_key(*scheme).unwrap(); let sig = Signature::new(*scheme, scheme_sig.signature.clone()); unified_verify(&DSEP_PUBLIC_DECRYPTION, &signed, &sig, &vk) .unwrap_or_else(|e| panic!("{scheme:?} signature should verify: {e}")); + // The unprefixed payload is specifically not what was signed. + assert!( + unified_verify(&DSEP_PUBLIC_DECRYPTION, &payload_signed, &sig, &vk) + .is_err(), + "{scheme:?} signature must be bound to the scheme set" + ); + + // Nor is the same payload under any other scheme set, which is + // what stops an entry being lifted out of a larger response. + for other_set in [ + vec![*scheme], + vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa44], + ] { + if other_set == ordered { + continue; + } + let rebound = scheme_bound_preimage(&other_set, &payload_signed).unwrap(); + assert!( + unified_verify(&DSEP_PUBLIC_DECRYPTION, &rebound, &sig, &vk).is_err(), + "{scheme:?} signature verified under {other_set:?}" + ); + } + // The extra data is part of what that entry covers. - let other = + let other_extra = super::public_dec_payload_bytes(&payload_bytes, b"other extra").unwrap(); + let other = scheme_bound_preimage(&ordered, &other_extra).unwrap(); assert!( unified_verify(&DSEP_PUBLIC_DECRYPTION, &other, &sig, &vk).is_err(), "{scheme:?} signature did not cover the extra data" @@ -2092,18 +2123,25 @@ pub(crate) mod tests { SigningSchemeType::Ecdsa256k1 => { assert_eq!(stored.signature, expected_external); } - // Every other scheme signs the serialized CRS payload. + // Every other scheme signs the serialized CRS payload, prefixed + // by the scheme set the response was produced under. scheme => { + let signed = scheme_bound_preimage(&schemes, &payload_bytes).unwrap(); let vk = sk.unified_verifying_key(scheme).unwrap(); let sig = Signature::new(scheme, stored.signature.clone()); - unified_verify(&DSEP_PUBDATA_CRS, &payload_bytes, &sig, &vk) + unified_verify(&DSEP_PUBDATA_CRS, &signed, &sig, &vk) .unwrap_or_else(|e| panic!("{scheme:?} CRS signature should verify: {e}")); - // Specifically not the EIP-712 hash any more. + // Specifically not the EIP-712 hash any more... assert!( unified_verify(&DSEP_PUBDATA_CRS, eip712_hash.as_slice(), &sig, &vk) .is_err(), "{scheme:?} must sign the payload, not the EIP-712 hash" ); + // ...and not the unprefixed payload either. + assert!( + unified_verify(&DSEP_PUBDATA_CRS, &payload_bytes, &sig, &vk).is_err(), + "{scheme:?} CRS signature must be bound to the scheme set" + ); assert!( unified_verify(&DSEP_PUBDATA_CRS, b"tampered", &sig, &vk).is_err(), "{scheme:?} verified a tampered payload" @@ -2164,10 +2202,17 @@ pub(crate) mod tests { assert_eq!(stored.signature, inner.external_signature); } scheme => { + let signed = scheme_bound_preimage(&schemes, &expected_payload).unwrap(); let vk = sk.unified_verifying_key(scheme).unwrap(); let sig = Signature::new(scheme, stored.signature.clone()); - unified_verify(&DSEP_PUBDATA_KEY, &expected_payload, &sig, &vk).unwrap_or_else( - |e| panic!("{scheme:?} keygen signature should verify: {e}"), + unified_verify(&DSEP_PUBDATA_KEY, &signed, &sig, &vk).unwrap_or_else(|e| { + panic!("{scheme:?} keygen signature should verify: {e}") + }); + // The scheme-set prefix is load-bearing: without it the entry + // could be replayed as a complete response under one scheme. + assert!( + unified_verify(&DSEP_PUBDATA_KEY, &expected_payload, &sig, &vk).is_err(), + "{scheme:?} keygen signature must be bound to the scheme set" ); } } From b28290b3ce8b90c3eebfb563ef2d7d1c2f4c13b2 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 16:33:15 +0200 Subject: [PATCH 025/114] fix: unneeded restriction --- core/service/src/cryptography/error.rs | 2 - .../src/cryptography/signcryption/common.rs | 5 -- .../cryptography/signcryption/composite_v1.rs | 89 +++++++++++-------- 3 files changed, 50 insertions(+), 46 deletions(-) diff --git a/core/service/src/cryptography/error.rs b/core/service/src/cryptography/error.rs index 1dbaa1ffe6..34d6e63b16 100644 --- a/core/service/src/cryptography/error.rs +++ b/core/service/src/cryptography/error.rs @@ -34,8 +34,6 @@ pub enum CryptographyError { VerificationError(String), #[error(transparent)] Signing(#[from] SigningError), - #[error("unsupported signcryption format for signing schemes {0}")] - UnsupportedSigncryptionFormat(String), #[error("Other: {0}")] Other(String), } diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index 23f0a462ef..8ec1d14720 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -15,11 +15,6 @@ use zeroize::Zeroizing; pub(super) const DSEP_SIGNCRYPTION: DomainSep = *b"SIGNCRYP"; -/// The error for something no implemented envelope format covers. -pub(super) fn unsupported_format(what: impl std::fmt::Display) -> CryptographyError { - CryptographyError::UnsupportedSigncryptionFormat(what.to_string()) -} - /// The digest of the receiver's public encryption key, as it appears in the /// signed preimage. pub(super) fn receiver_enc_key_digest( diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index fe1d04dad1..629828c74f 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -1,7 +1,7 @@ //! The composite signcryption envelope: one signature per scheme. use super::UnifiedSigncryption; -use super::common::{hybrid_decrypt, hybrid_encrypt, receiver_binding, unsupported_format}; +use super::common::{hybrid_decrypt, hybrid_encrypt, receiver_binding}; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; @@ -54,16 +54,6 @@ pub fn seal( dsep: &DomainSep, msg: &[u8], ) -> Result { - // Refuse the ECDSA singleton. Such a signcryption would be indistinguishable - // by scheme set from one a caller expects in the frozen layout, and the two - // are not interchangeable — so produce nothing rather than something whose - // reader is ambiguous. - if schemes == [SigningSchemeType::Ecdsa256k1] { - return Err(unsupported_format( - "a multi-signature envelope under ECDSA alone; use the frozen layout instead", - )); - } - let binding = receiver_binding(receiver_id, receiver_enc_key)?; let signed = Zeroizing::new([msg, binding.as_slice()].concat()); let signature = CompositeSignature::sign_uniform(identity, schemes, dsep, &signed)?; @@ -147,10 +137,13 @@ mod tests { } fn fixture(scheme: PkeSchemeType, seed: u64) -> Fixture { + fixture_under(scheme, seed, pair()) + } + + fn fixture_under(scheme: PkeSchemeType, seed: u64, schemes: Vec) -> Fixture { let base = lock_fixture(scheme, seed); let mut rng = AesRng::seed_from_u64(seed ^ 0xC0FFEE); let identity = seeded_identity(&mut rng); - let schemes = pair(); let keys = VerfKeySet::from_identity(&identity, &schemes).unwrap(); Fixture { rng: base.rng, @@ -315,24 +308,52 @@ mod tests { ); } - /// Sealing under ECDSA alone is refused. Such an envelope would be - /// indistinguishable by scheme set from one a caller expects in the frozen - /// layout, and the two are not interchangeable. + /// ECDSA alone is a legitimate scheme set for this layout. #[test] - fn sealing_under_the_ecdsa_singleton_is_refused() { - let mut f = fixture(PkeSchemeType::MlKem512, 400); - assert!(matches!( - seal( - &f.identity, - &[SigningSchemeType::Ecdsa256k1], - &f.enc_key, - &f.receiver_id, - &mut f.rng, - DSEP, - b"nope", + fn the_ecdsa_singleton_round_trips_and_still_does_not_cross() { + let mut f = fixture_under( + PkeSchemeType::MlKem512, + 400, + vec![SigningSchemeType::Ecdsa256k1], + ); + let cipher = seal_msg(&mut f, b"ecdsa alone"); + let opened = open_with(&f, &cipher).unwrap(); + assert_eq!(&*opened, b"ecdsa alone"); + + // The frozen reader, holding the very key that signed this envelope, + // still rejects it: the plaintext is a serialized `CompositeEnvelope`, + // so its trailing 32 bytes are not `H(sender verification key)`. + let legacy_verf = f + .keys + .require(SigningSchemeType::Ecdsa256k1) + .unwrap() + .clone(); + let legacy_ecdsa = match &legacy_verf { + crate::cryptography::signatures::UnifiedPublicSigKey::Ecdsa256k1(k) => k.clone(), + _ => unreachable!("the ECDSA member of the set is an ECDSA key"), + }; + let legacy_key = + UnifiedUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &legacy_ecdsa, &f.receiver_id); + assert!( + legacy_key.unsigncrypt::(DSEP, &cipher).is_err(), + "the frozen reader must not accept a single-ECDSA composite envelope" + ); + + // ...and the composite reader still rejects a frozen envelope, even now + // that the set it demands is exactly {ECDSA}. + let base = lock_fixture(PkeSchemeType::MlKem512, 400); + let mut rng = base.rng; + let frozen = + UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id) + .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) + .unwrap(); + assert!( + matches!( + open_with(&f, &frozen), + Err(CryptographyError::SerializationError(_)) ), - Err(CryptographyError::UnsupportedSigncryptionFormat(_)) - )); + "the composite reader must reject a frozen envelope on deserialization" + ); } /// A sender whose key set differs from the one the receiver holds is @@ -367,17 +388,7 @@ mod tests { let cipher = seal_msg(&mut f, b"bound to a receiver"); let other_id = b"a different receiver".to_vec(); - assert!( - open( - &f.dec_key, - &f.enc_key, - &f.keys, - &other_id, - DSEP, - &cipher, - ) - .is_err() - ); + assert!(open(&f.dec_key, &f.enc_key, &f.keys, &other_id, DSEP, &cipher,).is_err()); } #[test] From 4185577d31fbddca165fee83bf28da39f4b7c7a6 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 16:41:48 +0200 Subject: [PATCH 026/114] fix: backwards comp tests --- .../internal_custodian_recovery_output.bcode | 4 +- .../kms/internal_recovery_request.bcode | 4 +- .../0_15_0/kms/operator_backup_output.bcode | 4 +- .../data/0_15_0/kms/recovery_material.bcode | 4 +- .../0_15_0/kms/unified_signcryption.bcode | 4 +- .../generate-v0.15.0/Cargo.lock | 24 +++++----- .../generate-v0.15.0/Cargo.toml | 16 +++---- .../generate-v0.15.0/src/data_0_15.rs | 7 +-- fix-generator-v0-15-imports.patch | 45 +++++++++++++++++++ 9 files changed, 77 insertions(+), 35 deletions(-) create mode 100644 fix-generator-v0-15-imports.patch diff --git a/backward-compatibility/data/0_15_0/kms/internal_custodian_recovery_output.bcode b/backward-compatibility/data/0_15_0/kms/internal_custodian_recovery_output.bcode index 07be28e5a9..f459758603 100644 --- a/backward-compatibility/data/0_15_0/kms/internal_custodian_recovery_output.bcode +++ b/backward-compatibility/data/0_15_0/kms/internal_custodian_recovery_output.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:35ff134a4b07e9b1f04fe44e29c846de82c486380c5c65cc62484871b00838bd -size 148 +oid sha256:85540dba51296d9195682c67270c22a090d9b473d8d603be9d14b4af2160bc75 +size 136 diff --git a/backward-compatibility/data/0_15_0/kms/internal_recovery_request.bcode b/backward-compatibility/data/0_15_0/kms/internal_recovery_request.bcode index 6eeab97980..603c569e18 100644 --- a/backward-compatibility/data/0_15_0/kms/internal_recovery_request.bcode +++ b/backward-compatibility/data/0_15_0/kms/internal_recovery_request.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2ad2d4412629302157f9ebaf4b9cff51e25ab83eee01214b2d2d17135956c6e -size 2542 +oid sha256:4dd2db31c8dfed1d96798ba3770cc839f13699da93e5175446fb5f1e845eeb0d +size 2422 diff --git a/backward-compatibility/data/0_15_0/kms/operator_backup_output.bcode b/backward-compatibility/data/0_15_0/kms/operator_backup_output.bcode index f5a4be9ff3..7c9d025cb1 100644 --- a/backward-compatibility/data/0_15_0/kms/operator_backup_output.bcode +++ b/backward-compatibility/data/0_15_0/kms/operator_backup_output.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7faf82f144ba9c4a55bf3366240a5ba6bee6d28769851a4f21df98092d933221 -size 2193 +oid sha256:a81f0c46bb8a5349a1e7d02d4ed573947f441901de0582c7dd639b82bd9523ba +size 2181 diff --git a/backward-compatibility/data/0_15_0/kms/recovery_material.bcode b/backward-compatibility/data/0_15_0/kms/recovery_material.bcode index a6ea5e4c1a..99cf9880a2 100644 --- a/backward-compatibility/data/0_15_0/kms/recovery_material.bcode +++ b/backward-compatibility/data/0_15_0/kms/recovery_material.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:53da6c06104cf530009ff4cf69e4a8083ec71062532d5d55f56c08dca5fe0a8f -size 11736 +oid sha256:846a46407ab3681d31d5e792420eda50e2aab1409688260fd2cd0f03cc918f97 +size 11676 diff --git a/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode b/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode index 3eff3447dd..0e3e6d4f6e 100644 --- a/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode +++ b/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2de496fa1aa3f7dc42a9f2c5d52338e21666f4c571787c918e1b5135f10e38d3 -size 1031 +oid sha256:d5b40de437b213d7b1194c500cc6720fa812faf9d3667beca0ffc4bb2cf0747f +size 1019 diff --git a/backward-compatibility/generate-v0.15.0/Cargo.lock b/backward-compatibility/generate-v0.15.0/Cargo.lock index 6e28263496..bd8da92463 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.lock +++ b/backward-compatibility/generate-v0.15.0/Cargo.lock @@ -1832,7 +1832,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bc2wrap" version = "2.0.1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "bincode 2.0.1", "serde", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "error-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "anyhow", "tracing", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "kms" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "aes 0.9.3", "aes-gcm 0.11.1", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "kms-grpc" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -4633,7 +4633,7 @@ dependencies = [ [[package]] name = "observability" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "anyhow", "axum", @@ -6811,7 +6811,7 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "anyhow", "bc2wrap", @@ -6987,7 +6987,7 @@ dependencies = [ [[package]] name = "thread-handles" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "anyhow", "error-utils", @@ -7017,7 +7017,7 @@ dependencies = [ [[package]] name = "threshold-algebra" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "anyhow", "error-utils", @@ -7038,7 +7038,7 @@ dependencies = [ [[package]] name = "threshold-execution" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "aes 0.9.3", "aes-prng", @@ -7080,7 +7080,7 @@ dependencies = [ [[package]] name = "threshold-hashing" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "anyhow", "bc2wrap", @@ -7093,7 +7093,7 @@ dependencies = [ [[package]] name = "threshold-networking" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "anyhow", "async-trait", @@ -7130,7 +7130,7 @@ dependencies = [ [[package]] name = "threshold-types" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=64063f5924981cd2e1979f1b336f9826f7f0730f#64063f5924981cd2e1979f1b336f9826f7f0730f" +source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" dependencies = [ "anyhow", "async-trait", diff --git a/backward-compatibility/generate-v0.15.0/Cargo.toml b/backward-compatibility/generate-v0.15.0/Cargo.toml index 4220d0ca10..c3514a800d 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.toml +++ b/backward-compatibility/generate-v0.15.0/Cargo.toml @@ -9,7 +9,7 @@ license = "BSD-3-Clause-Clear" [dependencies] # Import the base backward-compatibility crate for shared types and utilities backward-compatibility = { path = ".." } -bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } +bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } # NOTE: Some dependencies below are duplicated from ../Cargo.toml # This is intentional - these crates are excluded from the workspace for isolation. @@ -19,13 +19,13 @@ bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev # NOTE: v0.15.0 is not tagged yet, so we pin a commit rev on `main`. Bump this rev to a # commit that contains every type this generator serializes, otherwise the generator will # not compile. -kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } -kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } -algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } -threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f", features = ["testing"] } -threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } -threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } -hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "64063f5924981cd2e1979f1b336f9826f7f0730f" } +kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } +kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } +algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } +threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5", features = ["testing"] } +threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } +threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } +hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } #error-utils_0_15_0 = { path = "./core/error-utils", default-features = false } #thread-handles_0_15_0 = { path = "./core/thread-handles" } diff --git a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs index acf3fb262e..692b956e06 100644 --- a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs +++ b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs @@ -27,7 +27,7 @@ use kms_0_15_0::cryptography::{ hybrid_ml_kem::HybridKemCt, signatures::{ compute_eip712_signature, gen_sig_keys, NodeSigningIdentity, RootSigningSeed, - SigningSchemeSet, SigningSchemeType, UnifiedPublicSigKey, + SigningSchemeType, StoredTypedSignature, UnifiedPublicSigKey, }, signcryption::{ Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned, @@ -36,7 +36,7 @@ use kms_0_15_0::cryptography::{ use kms_0_15_0::engine::base::{ CrsGenMetadata, CrsGenMetadataInner, CrsGenMetadataInnerV2, CrsSignedPayload, KeyGenMetadataInner, KeygenSignedPayload, KmsFheKeyHandles, PrepKeygenSignedPayload, - PublicDecSignedPayload, StoredEip712Domain, StoredTypedSignature, UserDecSignedPayload, + PublicDecSignedPayload, StoredEip712Domain, UserDecSignedPayload, }; use kms_0_15_0::engine::centralized::central_kms::generate_client_fhe_key; use kms_0_15_0::engine::context::{ @@ -1420,7 +1420,6 @@ impl KmsV0_15_0 { signcryption: UnifiedSigncryption { payload: payload.to_vec(), pke_type: BACKUP_PKE_SCHEME, - signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), }, }; cts.insert(cus_role, cts_out); @@ -1494,7 +1493,6 @@ impl KmsV0_15_0 { let signcryption = UnifiedSigncryption { payload: payload.to_vec(), pke_type: BACKUP_PKE_SCHEME, - signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), }; cts.insert(cur_role, InnerOperatorBackupOutput { signcryption }); } @@ -1760,7 +1758,6 @@ impl KmsV0_15_0 { let signcryption = UnifiedSigncryption { payload: buf.to_vec(), pke_type: BACKUP_PKE_SCHEME, - signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), }; let icro = InternalCustodianRecoveryOutput { signcryption, diff --git a/fix-generator-v0-15-imports.patch b/fix-generator-v0-15-imports.patch new file mode 100644 index 0000000000..b7de8257ef --- /dev/null +++ b/fix-generator-v0-15-imports.patch @@ -0,0 +1,45 @@ +diff --color -urN old/backward-compatibility/generate-v0.15.0/src/data_0_15.rs new/backward-compatibility/generate-v0.15.0/src/data_0_15.rs +--- a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs 2026-09-23 14:37:16.937553053 +0000 ++++ b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs 2026-09-23 14:37:17.105629636 +0000 +@@ -27,7 +27,7 @@ + hybrid_ml_kem::HybridKemCt, + signatures::{ + compute_eip712_signature, gen_sig_keys, NodeSigningIdentity, RootSigningSeed, +- SigningSchemeSet, SigningSchemeType, UnifiedPublicSigKey, ++ SigningSchemeType, StoredTypedSignature, UnifiedPublicSigKey, + }, + signcryption::{ + Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned, +@@ -36,7 +36,7 @@ + use kms_0_15_0::engine::base::{ + CrsGenMetadata, CrsGenMetadataInner, CrsGenMetadataInnerV2, CrsSignedPayload, + KeyGenMetadataInner, KeygenSignedPayload, KmsFheKeyHandles, PrepKeygenSignedPayload, +- PublicDecSignedPayload, StoredEip712Domain, StoredTypedSignature, UserDecSignedPayload, ++ PublicDecSignedPayload, StoredEip712Domain, UserDecSignedPayload, + }; + use kms_0_15_0::engine::centralized::central_kms::generate_client_fhe_key; + use kms_0_15_0::engine::context::{ +@@ -1420,7 +1420,6 @@ + signcryption: UnifiedSigncryption { + payload: payload.to_vec(), + pke_type: BACKUP_PKE_SCHEME, +- signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + }, + }; + cts.insert(cus_role, cts_out); +@@ -1494,7 +1493,6 @@ + let signcryption = UnifiedSigncryption { + payload: payload.to_vec(), + pke_type: BACKUP_PKE_SCHEME, +- signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + }; + cts.insert(cur_role, InnerOperatorBackupOutput { signcryption }); + } +@@ -1760,7 +1758,6 @@ + let signcryption = UnifiedSigncryption { + payload: buf.to_vec(), + pke_type: BACKUP_PKE_SCHEME, +- signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), + }; + let icro = InternalCustodianRecoveryOutput { + signcryption, From 223fb98d4eba519de6ef4cb040148de4355d8306 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 16:53:16 +0200 Subject: [PATCH 027/114] fix: lint --- core/service/src/backup/custodian.rs | 7 ++--- core/service/src/cryptography/signatures.rs | 7 ++--- .../src/cryptography/signcryption/mod.rs | 9 ++---- .../src/cryptography/signing/composite.rs | 15 ++-------- core/service/src/cryptography/signing/seed.rs | 1 - .../engine/storage_material_verification.rs | 5 ++-- .../tests/backward_compatibility_kms.rs | 30 +++++++------------ 7 files changed, 22 insertions(+), 52 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 2e737c019b..4f864f65bb 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -1,5 +1,5 @@ -use crate::backup::{BACKUP_PKE_SCHEME, VESTIGIAL_BACKUP_SIGNING_TYPE}; use crate::backup::operator::DSEP_BACKUP_MATERIAL; +use crate::backup::{BACKUP_PKE_SCHEME, VESTIGIAL_BACKUP_SIGNING_TYPE}; use crate::cryptography::{ encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}, signatures::PrivateSigKey, @@ -9,10 +9,7 @@ use crate::cryptography::{ }, }; use crate::engine::validation::{RequestIdParsingErr, parse_optional_grpc_request_id}; -use crate::{ - consts::SAFE_SER_SIZE_LIMIT, - cryptography::signatures::PublicSigKey, -}; +use crate::{consts::SAFE_SER_SIZE_LIMIT, cryptography::signatures::PublicSigKey}; use hashing::DomainSep; use kms_grpc::RequestId; use kms_grpc::kms::v1::{ diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index d14bf8cee6..6a09344079 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -17,10 +17,9 @@ pub use crate::cryptography::signing::ecdsa::{ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ - CompositeSignature, SchemeVerfKeys, Signature, - SigningError, SigningSchemeType, SigningSchemeTypeVersions, StoredTypedSignature, - StoredTypedSignatureVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, - canonical_schemes, + CompositeSignature, SchemeVerfKeys, Signature, SigningError, SigningSchemeType, + SigningSchemeTypeVersions, StoredTypedSignature, StoredTypedSignatureVersions, + UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, canonical_schemes, }; pub(crate) use crate::cryptography::signing::ecdsa::{ diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index ea4a37f04d..345a55e946 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -78,9 +78,7 @@ use crate::cryptography::encryption::{ HasPkeScheme, PkeSchemeType, UnifiedPrivateEncKey, UnifiedPublicEncKey, }; use crate::cryptography::error::CryptographyError; -use crate::cryptography::signatures::{ - PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet, -}; +use crate::cryptography::signatures::{PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet}; use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; use kms_grpc::kms::v1::TypedPlaintext; @@ -958,10 +956,7 @@ mod tests { // The upgrade is exactly what the constructor builds, so a // regenerated artifact still compares equal to a frozen one. - assert_eq!( - upgraded, - UnifiedSigncryption::new(v0.payload, v0.pke_type) - ); + assert_eq!(upgraded, UnifiedSigncryption::new(v0.payload, v0.pke_type)); } } diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 7beb2f70b3..53b038e688 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -110,10 +110,7 @@ impl CompositeSignature { /// Accept `entries` only if they are already ordered by scheme, carry no /// duplicate scheme, and are non-empty. pub fn from_canonical(entries: Vec) -> Result { - let schemes = entries - .iter() - .map(|entry| entry.scheme) - .collect::>(); + let schemes = entries.iter().map(|entry| entry.scheme).collect::>(); ensure_canonical(&schemes)?; Ok(Self(entries)) } @@ -345,10 +342,7 @@ mod tests { sig.verify_uniform(&keys, DSEP, b"a different message") .is_err() ); - assert!( - sig.verify_uniform(&keys, b"OTHERDSP", MSG) - .is_err() - ); + assert!(sig.verify_uniform(&keys, b"OTHERDSP", MSG).is_err()); } /// Signatures are checked against the key set presented, so another party's @@ -360,10 +354,7 @@ mod tests { let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); sig.verify_uniform(&keys, DSEP, MSG).unwrap(); - assert!( - sig.verify_uniform(&other_keys, DSEP, MSG) - .is_err() - ); + assert!(sig.verify_uniform(&other_keys, DSEP, MSG).is_err()); } /// A key set missing one of the schemes cannot verify, rather than skipping diff --git a/core/service/src/cryptography/signing/seed.rs b/core/service/src/cryptography/signing/seed.rs index 35bbf1124c..bf22e9d47d 100644 --- a/core/service/src/cryptography/signing/seed.rs +++ b/core/service/src/cryptography/signing/seed.rs @@ -127,7 +127,6 @@ impl RootSigningSeed { /// use the persisted [`PrivateSigKey`]. #[cfg(feature = "non-wasm")] pub(crate) fn derive_ecdsa_signing_key(&self) -> Result { - match self.derive_signing_key(SigningSchemeType::Ecdsa256k1)? { UnifiedPrivateSigKey::Ecdsa256k1(sk) => Ok(sk.clone()), other => Err(SigningError::KeyDerivation(format!( diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index 7e0699bce2..a223254305 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -44,9 +44,8 @@ use crate::cryptography::signing::{ }; use crate::engine::base::{ CrsGenMetadata, CrsGenMetadataInner, CurrentPublicMaterialLayout, DSEP_PUBDATA_CRS, - DSEP_PUBDATA_KEY, KeyGenMetadata, KeyGenMetadataInner, - classify_current_public_material, crs_payload_bytes, crs_sol_type, keygen_payload_bytes, - keygen_sol_type, + DSEP_PUBDATA_KEY, KeyGenMetadata, KeyGenMetadataInner, classify_current_public_material, + crs_payload_bytes, crs_sol_type, keygen_payload_bytes, keygen_sol_type, }; use crate::engine::context::ContextInfo; use crate::engine::material_integrity::{ diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index 80a223ea23..e5f3c93354 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -17,11 +17,11 @@ use backward_compatibility::{ MlKem1024P384PublicKeyTest, NodeInfoTest, OperatorBackupOutputTest, PrepKeygenSignedPayloadTest, PrivateSigKeyTest, PrssSetupCombinedTest, PublicDecSignedPayloadTest, PublicSigKeyTest, RecoveryValidationMaterialTest, - RootSigningSeedTest, SchemeDigestsTest, SigncryptionPayloadTest, - SoftwareVersionTest, StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataKMS, - TestType, Testcase, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedCipherTest, - UnifiedPublicSigKeyTest, UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, - UnifiedUnsigncryptionKeyTest, UserDecSignedPayloadTest, data_dir, + RootSigningSeedTest, SchemeDigestsTest, SigncryptionPayloadTest, SoftwareVersionTest, + StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataKMS, TestType, Testcase, + ThresholdFheKeysTest, TypedPlaintextTest, UnifiedCipherTest, UnifiedPublicSigKeyTest, + UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, UnifiedUnsigncryptionKeyTest, + UserDecSignedPayloadTest, data_dir, load::{DataFormat, TestFailure, TestResult, TestSuccess}, tests::{TestedModule, run_all_tests}, }; @@ -55,9 +55,8 @@ use kms_lib::{ }, hybrid_ml_kem::HybridKemCt, signatures::{ - NodeSigningIdentity, PrivateSigKey, PublicSigKey, RootSigningSeed, - SigningSchemeType, StoredTypedSignature, UnifiedPublicSigKey, compute_eip712_signature, - gen_sig_keys, + NodeSigningIdentity, PrivateSigKey, PublicSigKey, RootSigningSeed, SigningSchemeType, + StoredTypedSignature, UnifiedPublicSigKey, compute_eip712_signature, gen_sig_keys, }, signcryption::{ Signcrypt, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, @@ -1147,10 +1146,7 @@ fn test_recovery_material( let mut payload = [0_u8; 32]; rng.fill_bytes(&mut payload); let cts_out = InnerOperatorBackupOutput { - signcryption: UnifiedSigncryption::new( - payload.to_vec(), - BACKUP_PKE_SCHEME, - ), + signcryption: UnifiedSigncryption::new(payload.to_vec(), BACKUP_PKE_SCHEME), }; cts.insert(cus_role, cts_out.clone()); } @@ -1191,10 +1187,7 @@ fn test_internal_recovery_request( let cur_role = Role::indexed_from_one(role_j as usize); let mut payload = [0_u8; 32]; rng.fill_bytes(&mut payload); - let signcryption = UnifiedSigncryption::new( - payload.to_vec(), - BACKUP_PKE_SCHEME, - ); + let signcryption = UnifiedSigncryption::new(payload.to_vec(), BACKUP_PKE_SCHEME); cts.insert(cur_role, InnerOperatorBackupOutput { signcryption }); } let new_versionized = InternalRecoveryRequest::new(enc_key, verification_key, cts).unwrap(); @@ -1277,10 +1270,7 @@ fn test_internal_custodian_recovery_output( let mut rng = AesRng::seed_from_u64(test.state); let mut buf = [0u8; 100]; rng.fill_bytes(&mut buf); - let signcryption = UnifiedSigncryption::new( - buf.to_vec(), - BACKUP_PKE_SCHEME, - ); + let signcryption = UnifiedSigncryption::new(buf.to_vec(), BACKUP_PKE_SCHEME); let new_versionized = InternalCustodianRecoveryOutput { signcryption, From ce4c545626698202d271377a93a4f5a31cb3d530 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 17:07:38 +0200 Subject: [PATCH 028/114] fix: minor issues --- core/service/src/backup/custodian.rs | 5 ++-- core/service/src/backup/mod.rs | 16 +--------- core/service/src/backup/operator.rs | 16 +++++----- fix-generator-v0-15-imports.patch | 45 ---------------------------- 4 files changed, 13 insertions(+), 69 deletions(-) delete mode 100644 fix-generator-v0-15-imports.patch diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 4f864f65bb..fa95ffdaa5 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -1,5 +1,6 @@ +use crate::backup::BACKUP_PKE_SCHEME; use crate::backup::operator::DSEP_BACKUP_MATERIAL; -use crate::backup::{BACKUP_PKE_SCHEME, VESTIGIAL_BACKUP_SIGNING_TYPE}; +use crate::cryptography::signing::SigningSchemeType; use crate::cryptography::{ encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}, signatures::PrivateSigKey, @@ -90,7 +91,7 @@ impl TryFrom for CustodianRecoveryOutput { backup_output: Some(OperatorBackupOutput { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, - signing_type: VESTIGIAL_BACKUP_SIGNING_TYPE.as_wire(), + signing_type: SigningSchemeType::Ecdsa256k1.as_wire(), }), custodian_role: value.custodian_role.one_based() as u64, }) diff --git a/core/service/src/backup/mod.rs b/core/service/src/backup/mod.rs index b66cba0e28..6109cc3145 100644 --- a/core/service/src/backup/mod.rs +++ b/core/service/src/backup/mod.rs @@ -8,7 +8,6 @@ pub mod error; pub mod operator; pub mod secretsharing; pub mod seed_phrase; -use crate::cryptography::signatures::SigningSchemeType; use crate::cryptography::signcryption::UnifiedSigncryption; use kms_grpc::RequestId; use kms_grpc::kms::v1::OperatorBackupOutput; @@ -56,24 +55,11 @@ impl Named for BackupCiphertext { const NAME: &'static str = "cryptography::BackupCiphertext"; } -/// The value written to the vestigial `OperatorBackupOutput.signing_type`. -/// -/// A [`UnifiedSigncryption`] names neither a signing scheme nor a layout, so -/// there is nothing to map onto this field and a reader ignores it. -/// -/// TODO: drop `signing_type` from `OperatorBackupOutput` when the backup format -/// is reshaped. Nothing deployed reads it. -pub(crate) const VESTIGIAL_BACKUP_SIGNING_TYPE: SigningSchemeType = SigningSchemeType::Ecdsa256k1; - impl TryFrom for UnifiedSigncryption { type Error = anyhow::Error; fn try_from(value: OperatorBackupOutput) -> Result { - // Use the fallible conversion rather than prost's `pke_type()` accessor: - // that maps an unrecognised discriminant to the default variant, which - // would silently relabel material as ML-KEM-512 instead of reporting the - // unknown scheme. `signing_type` is deliberately not read; see - // `VESTIGIAL_BACKUP_SIGNING_TYPE`. + // TODO stop gap let pke_type = value.pke_type.try_into()?; Ok(UnifiedSigncryption::new(value.signcryption, pke_type)) } diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index 28e27ef27a..0f7b437a04 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -6,15 +6,17 @@ use super::{ use crate::{ anyhow_error_and_log, consts::SAFE_SER_SIZE_LIMIT, - cryptography::encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}, - cryptography::signatures::{PrivateSigKey, PublicSigKey, Signature}, - cryptography::signcryption::{ - Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, - Unsigncrypt, + cryptography::{ + encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}, + signatures::{PrivateSigKey, PublicSigKey, Signature}, + signcryption::{ + Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, + Unsigncrypt, + }, + signing::SigningSchemeType, }, }; use crate::{ - backup::VESTIGIAL_BACKUP_SIGNING_TYPE, backup::custodian::DSEP_BACKUP_CUSTODIAN, cryptography::signatures::{internal_sign, internal_verify_sig}, }; @@ -190,7 +192,7 @@ impl TryFrom for OperatorBackupOutput { Ok(Self { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, - signing_type: VESTIGIAL_BACKUP_SIGNING_TYPE.as_wire(), + signing_type: SigningSchemeType::Ecdsa256k1.as_wire(), }) } } diff --git a/fix-generator-v0-15-imports.patch b/fix-generator-v0-15-imports.patch deleted file mode 100644 index b7de8257ef..0000000000 --- a/fix-generator-v0-15-imports.patch +++ /dev/null @@ -1,45 +0,0 @@ -diff --color -urN old/backward-compatibility/generate-v0.15.0/src/data_0_15.rs new/backward-compatibility/generate-v0.15.0/src/data_0_15.rs ---- a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs 2026-09-23 14:37:16.937553053 +0000 -+++ b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs 2026-09-23 14:37:17.105629636 +0000 -@@ -27,7 +27,7 @@ - hybrid_ml_kem::HybridKemCt, - signatures::{ - compute_eip712_signature, gen_sig_keys, NodeSigningIdentity, RootSigningSeed, -- SigningSchemeSet, SigningSchemeType, UnifiedPublicSigKey, -+ SigningSchemeType, StoredTypedSignature, UnifiedPublicSigKey, - }, - signcryption::{ - Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned, -@@ -36,7 +36,7 @@ - use kms_0_15_0::engine::base::{ - CrsGenMetadata, CrsGenMetadataInner, CrsGenMetadataInnerV2, CrsSignedPayload, - KeyGenMetadataInner, KeygenSignedPayload, KmsFheKeyHandles, PrepKeygenSignedPayload, -- PublicDecSignedPayload, StoredEip712Domain, StoredTypedSignature, UserDecSignedPayload, -+ PublicDecSignedPayload, StoredEip712Domain, UserDecSignedPayload, - }; - use kms_0_15_0::engine::centralized::central_kms::generate_client_fhe_key; - use kms_0_15_0::engine::context::{ -@@ -1420,7 +1420,6 @@ - signcryption: UnifiedSigncryption { - payload: payload.to_vec(), - pke_type: BACKUP_PKE_SCHEME, -- signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), - }, - }; - cts.insert(cus_role, cts_out); -@@ -1494,7 +1493,6 @@ - let signcryption = UnifiedSigncryption { - payload: payload.to_vec(), - pke_type: BACKUP_PKE_SCHEME, -- signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), - }; - cts.insert(cur_role, InnerOperatorBackupOutput { signcryption }); - } -@@ -1760,7 +1758,6 @@ - let signcryption = UnifiedSigncryption { - payload: buf.to_vec(), - pke_type: BACKUP_PKE_SCHEME, -- signing_schemes: SigningSchemeSet::single(SigningSchemeType::Ecdsa256k1), - }; - let icro = InternalCustodianRecoveryOutput { - signcryption, From 1719d8a99fe792c087ad5783072c6e7a525a5498 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 17:49:41 +0200 Subject: [PATCH 029/114] chore: handled some more claude review findings --- .../src/cryptography/signcryption/common.rs | 2 +- .../cryptography/signcryption/composite_v1.rs | 25 ------------- .../src/cryptography/signcryption/mod.rs | 14 ++----- .../src/cryptography/signing/composite.rs | 37 ++++++------------- core/service/src/engine/base.rs | 5 +++ .../engine/storage_material_verification.rs | 3 +- core/service/tests/versioned_enum_coverage.rs | 4 ++ 7 files changed, 26 insertions(+), 64 deletions(-) diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index 8ec1d14720..729b8ce317 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -2,7 +2,7 @@ //! //! Signing and verifying have to build *identical* receiver bindings or nothing //! verifies at all, and both formats encrypt the same way. Anything both -//! [`super::ecdsa_v0`] and a future composite format need lives here, so the two +//! [`super::ecdsa_v0`] and [`super::composite_v1`] need lives here, so the two //! cannot drift apart and neither has to copy it. use crate::cryptography::encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}; diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 629828c74f..a13f433407 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -231,31 +231,6 @@ mod tests { assert_eq!(&*opened, b"downgrade me"); } - /// The unified reader picks its layout from `sender`, so the same key type - /// opens a multi-signature envelope and refuses it when built for ECDSA — - /// with no tag on the message involved either way. - #[test] - fn the_unified_key_dispatches_on_sender_auth() { - let mut f = fixture(PkeSchemeType::MlKem512, 270); - let cipher = seal_msg(&mut f, b"dispatched by key material"); - - let multi = - UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id); - assert_eq!( - &*multi.open(DSEP, &cipher).unwrap(), - b"dispatched by key material" - ); - - // The same envelope, read by a key built for the frozen layout. - let ecdsa = match f.keys.require(SigningSchemeType::Ecdsa256k1).unwrap() { - crate::cryptography::signatures::UnifiedPublicSigKey::Ecdsa256k1(k) => k.clone(), - _ => unreachable!("the ECDSA member of the set is an ECDSA key"), - }; - let frozen_reader = - UnifiedUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &ecdsa, &f.receiver_id); - assert!(frozen_reader.open(DSEP, &cipher).is_err()); - } - /// Neither reader accepts the other's envelope. /// /// This is what lets a [`UnifiedSigncryption`] carry no layout tag: the diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 345a55e946..a38dc276ee 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -45,10 +45,9 @@ //! two fixed-size tail fields, it cannot be made self-describing after the fact //! either. What is locked, and by what: //! -//! - The plaintext layout `msg ‖ sig ‖ H(sender verification key)`, by -//! `tests::ecdsa_v0_envelope_layout_is_locked`. -//! - The signed preimage `dsep ‖ msg ‖ receiver_id ‖ H(receiver enc key)`, by -//! `tests::ecdsa_v0_signed_preimage_is_locked`. +//! - The plaintext layout `msg ‖ sig ‖ H(sender verification key)` and the +//! signed preimage `dsep ‖ msg ‖ receiver_id ‖ H(receiver enc key)`, by +//! `ecdsa_v0::tests::ecdsa_v0_envelope_layout_is_locked`. //! - The [`SigncryptionPayload`] bincode layout, by //! `tests::test_signcryption_payload_v0_serialization_locked`. //! - The whole artifact, including **the order in which the RNG is drawn from**, @@ -60,12 +59,7 @@ //! //! The multi-signature layout carries one signature per scheme, for the //! custodian-backup chain. Reading it goes through [`Unsigncrypt`] like the -//! frozen layout, on a key built with -//! [`UnifiedUnsigncryptionKey::new_multi`]. Writing it does not yet: it needs a -//! [`NodeSigningIdentity`](crate::cryptography::signatures::NodeSigningIdentity) -//! rather than the single [`PrivateSigKey`] the signing key types hold, so it -//! stays behind [`composite_v1::seal`] until user decryption grows multi-scheme -//! signing support. +//! frozen layout, on a key built with [`UnifiedUnsigncryptionKey::new_multi`]. mod common; pub mod composite_v1; diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 53b038e688..19e50d3152 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -275,7 +275,9 @@ mod tests { sig.verify_uniform(&keys, DSEP, MSG).unwrap(); } - /// Removing a signature must not leave something that verifies under the remaining scheme. + /// Removing a signature must not leave something that verifies under the + /// remaining scheme, and a key set that names fewer schemes than the + /// signature must not verify it by skipping the ones it has no key for. #[test] fn a_stripped_signature_is_rejected() { let (identity, keys, schemes) = setup(2); @@ -297,6 +299,14 @@ mod tests { let ecdsa_only = VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); assert!(stripped.verify_uniform(&ecdsa_only, DSEP, MSG).is_err()); + + // The same mismatch from the other side: the *whole* pair signature + // against that ECDSA-only key set is refused for its shape rather than + // verified on the one entry the set holds a key for. + assert!(matches!( + sig.verify_uniform(&ecdsa_only, DSEP, MSG), + Err(SigningError::UnexpectedSchemeSet { .. }) + )); } /// A list that is not ordered by scheme, or repeats one, is refused on @@ -357,21 +367,6 @@ mod tests { assert!(sig.verify_uniform(&other_keys, DSEP, MSG).is_err()); } - /// A key set missing one of the schemes cannot verify, rather than skipping - /// the scheme it has no key for. - #[test] - fn a_key_set_missing_a_scheme_is_rejected() { - let (identity, _keys, schemes) = setup(8); - let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - - let ecdsa_only = - VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); - assert!(matches!( - sig.verify_uniform(&ecdsa_only, DSEP, MSG), - Err(SigningError::UnexpectedSchemeSet { .. }) - )); - } - /// An identity with no root seed can only do ECDSA, so asking it for the /// composite pair fails rather than producing a one-scheme signature. #[test] @@ -500,14 +495,4 @@ mod tests { Err(SigningError::EmptySchemeSet) )); } - - /// An empty policy is unrepresentable rather than rejected: the policy *is* - /// the key set, and a [`VerfKeySet`] cannot be empty. - #[test] - fn an_empty_policy_cannot_be_constructed() { - assert!(matches!( - VerfKeySet::new(std::collections::BTreeMap::new()), - Err(SigningError::EmptySchemeSet) - )); - } } diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index af3a927c5f..77cd9070a3 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -1984,6 +1984,11 @@ pub(crate) mod tests { // Entries come back ordered by scheme, whatever order was asked for. let ordered = canonical_schemes(&schemes).unwrap(); + assert_eq!( + sigs.signatures.len(), + ordered.len(), + "expected one entry per requested scheme" + ); for (scheme, scheme_sig) in ordered.iter().zip(&sigs.signatures) { // The wire tag matches the scheme, in canonical order. assert_eq!( diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index a223254305..543edca0ac 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -543,8 +543,7 @@ fn verify_metadata_signatures( } // The set is derived from the stored entries rather than requested from - // outside: at boot there is no request, and the question is whether the - // stored list is intact. + // outside: at boot there is no request to measure against. let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); let signed_bytes = crate::cryptography::signing::composite::scheme_bound_preimage( &stored_schemes, diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index 13919ba05e..cadf531274 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -66,6 +66,10 @@ const ALLOW_UNCOVERED: &[&str] = &[ // field. Covered (in serialized-and-signcrypted form) via // OperatorBackupOutputTest. "BackupMaterial", + // TODO stop gap + // Plaintext that composite_v1::seal safe_serializes into the + // UnifiedSigncryption payload. + "CompositeEnvelope", // Field of RecoveryValidationMaterial.payload. // Covered via RecoveryValidationMaterialTest. "RecoveryValidationMaterialPayload", From a7cd04e5048401ebbd84c14dbdf996ff6ec8cdd5 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 23 Sep 2026 21:34:52 +0200 Subject: [PATCH 030/114] refactor: limit format tests --- .../src/cryptography/signcryption/ecdsa_v0.rs | 113 ++---------------- .../src/cryptography/signcryption/mod.rs | 52 +------- .../tests/backward_compatibility_kms.rs | 37 ++++-- 3 files changed, 45 insertions(+), 157 deletions(-) diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 00dc5502a5..347170af9b 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -186,8 +186,8 @@ pub(crate) fn insecure_decrypt_ignoring_signature( #[cfg(test)] mod tests { + use super::super::Signcrypt; use super::super::common::{lock_fixture, receiver_enc_key_digest}; - use super::super::{Signcrypt, Unsigncrypt}; use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::PkeSchemeType; @@ -213,46 +213,21 @@ mod tests { ); } - /// The signed preimage is exactly `dsep ‖ msg ‖ receiver_id ‖ H(enc key)`. - /// - /// Rebuilt here from the hashing primitive rather than from - /// [`receiver_binding`], so that a reordering of the concatenation is caught - /// at the preimage level instead of only by a whole-artifact comparison. + /// The receiver binding is exactly `receiver_id ‖ H(receiver enc key)`, in + /// that order. #[test] - fn ecdsa_v0_signed_preimage_is_locked() { - const DSEP: &DomainSep = b"ECDSAV0T"; + fn ecdsa_v0_receiver_binding_is_locked() { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { let f = lock_fixture(scheme, 100); - let msg = b"the message a signcryption signs over"; - - let expected = [ - &DSEP[..], - msg.as_slice(), - f.receiver_id.as_slice(), - receiver_enc_key_digest(&f.enc_key).unwrap().as_slice(), - ] - .concat(); - - // What `inner_signcryption` signs: `dsep` (prepended by - // `internal_sign`) followed by the message and the receiver binding. - let binding = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); - let signed = [&DSEP[..], msg.as_slice(), binding.as_slice()].concat(); - assert_eq!(signed, expected, "{scheme}: signed preimage changed"); - - // ...and the verifier rebuilds exactly those bytes. - let sig = internal_sign( - DSEP, - &[msg.as_slice(), binding.as_slice()].concat(), - &f.signing_key, - ) - .unwrap(); - let unsign_key = UnifiedUnsigncryptionKey::new( - &f.dec_key, - &f.enc_key, - &f.sender_verf_key, - &f.receiver_id, + assert_eq!( + receiver_binding(&f.receiver_id, &f.enc_key).unwrap(), + [ + f.receiver_id.as_slice(), + receiver_enc_key_digest(&f.enc_key).unwrap().as_slice(), + ] + .concat(), + "{scheme}: the receiver binding changed" ); - check_format_and_signature(DSEP, msg, &sig, &unsign_key, &f.sender_verf_key).unwrap(); } } @@ -333,68 +308,4 @@ mod tests { ); } } - - /// Captures the frozen byte vectors the layout is locked against. - /// - /// Ignored by default because the constants below still have to be filled in - /// once, by hand: they are the output of the very code under test, so they - /// cannot be written before it has run. Run - /// - /// ```text - /// cargo test -p kms --lib \ - /// cryptography::signcryption::tests::ecdsa_v0_frozen_byte_vectors \ - /// -- --ignored --nocapture - /// ``` - /// - /// paste the printed literals into the constants, and delete the - /// `#[ignore]`. From then on this is the strongest guard here: it pins a - /// real ciphertext together with the key that opens it, so it proves current - /// code still *opens* material produced earlier. Every other test - /// establishes that only transitively, by regenerating and comparing. - #[test] - #[ignore = "golden vectors must be captured once; see the doc comment"] - fn ecdsa_v0_frozen_byte_vectors() { - const DSEP: &DomainSep = b"ECDSAV0T"; - // Hex of a `UnifiedSigncryption.payload` for ML-KEM-512, seed 200. - const FROZEN_MLKEM512: &str = ""; - // Hex of a `UnifiedSigncryption.payload` for MLKEM1024-P384, seed 200. - const FROZEN_MLKEM1024P384: &str = ""; - - for (scheme, frozen) in [ - (PkeSchemeType::MlKem512, FROZEN_MLKEM512), - (PkeSchemeType::MlKem1024P384, FROZEN_MLKEM1024P384), - ] { - let mut f = lock_fixture(scheme, 200); - let payload = TestType { i: 4711 }; - let signcrypt_key = - UnifiedSigncryptionKey::new(&f.signing_key, &f.enc_key, &f.receiver_id); - let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); - - assert!( - !frozen.is_empty(), - "{scheme}: paste this into the constant, then drop #[ignore]:\n{}", - hex::encode(&cipher.payload) - ); - - // The frozen ciphertext must still open under the same key... - let frozen_payload = hex::decode(frozen).expect("the constant must be valid hex"); - let unsign_key = UnifiedUnsigncryptionKey::new( - &f.dec_key, - &f.enc_key, - &f.sender_verf_key, - &f.receiver_id, - ); - let frozen_cipher = UnifiedSigncryption::new(frozen_payload.clone(), scheme); - let opened: TestType = unsign_key - .unsigncrypt(DSEP, &frozen_cipher) - .expect("current code must still open the frozen ciphertext"); - assert_eq!(opened, payload, "{scheme}"); - - // ...and today's code must still produce it bit for bit. - assert_eq!( - cipher.payload, frozen_payload, - "{scheme}: the produced envelope no longer matches the frozen vector" - ); - } - } } diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index a38dc276ee..1a322a7b8c 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -48,8 +48,6 @@ //! - The plaintext layout `msg ‖ sig ‖ H(sender verification key)` and the //! signed preimage `dsep ‖ msg ‖ receiver_id ‖ H(receiver enc key)`, by //! `ecdsa_v0::tests::ecdsa_v0_envelope_layout_is_locked`. -//! - The [`SigncryptionPayload`] bincode layout, by -//! `tests::test_signcryption_payload_v0_serialization_locked`. //! - The whole artifact, including **the order in which the RNG is drawn from**, //! by the backward-compatibility harness: `test_unified_signcryption` in //! `core/service/tests/backward_compatibility_kms.rs` regenerates a @@ -454,9 +452,9 @@ pub enum SigncryptionPayloadVersions { /// - V0 (current): Initial version with `plaintext: TypedPlaintext` and `link: Vec` /// /// ## Testing -/// - Unit test: `test_signcryption_payload_v0_serialization_locked` locks the binary format -/// - BC tests: Verify v0.11.x data can be deserialized by current version -/// - Both tests MUST pass before any changes to this type +/// - BC tests: `test_signcryption_payload` replays a fixture frozen at 0.13.0, which is +/// what locks the binary format, and verifies v0.11.x data still deserializes +/// - It MUST pass before any changes to this type // // TODO(zama-ai/tfhe-rs-internal/issues/1535) // we should also have ZeroizeOnDrop but this requires some changes on tfhe-rs @@ -707,7 +705,6 @@ mod tests { }; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; - use kms_grpc::kms::v1::TypedPlaintext; use rand::SeedableRng; use tfhe::FheTypes; @@ -953,47 +950,4 @@ mod tests { assert_eq!(upgraded, UnifiedSigncryption::new(v0.payload, v0.pke_type)); } } - - /// This test locks the binary serialization format of SigncryptionPayload. - /// - /// If this test fails, you have made a BREAKING CHANGE to SigncryptionPayload - /// that will prevent users from decrypting existing signcrypted ciphertexts. - /// - /// Breaking changes include: - /// - Reordering fields - /// - Changing field types - /// - Removing fields - /// - Renaming fields - /// - /// If you need to make changes, you MUST: - /// 1. Create a new version of the struct (e.g., SigncryptionPayloadV1) - /// 2. Implement migration logic from V0 to V1 - /// 3. Update all serialization/deserialization code to handle both versions - #[test] - fn test_signcryption_payload_v0_serialization_locked() { - let payload = SigncryptionPayload { - plaintext: TypedPlaintext { - bytes: vec![1, 2, 3, 4, 5], - fhe_type: 8, // FheTypes::Uint8 - }, - link: vec![222, 173, 190, 239], - }; - - let serialized = bc2wrap::serialize(&payload).expect("serialization should succeed"); - - // LOCKED V0 FORMAT - DO NOT CHANGE - let expected_bytes = vec![ - 5, 0, 0, 0, 0, 0, 0, 0, // plaintext.bytes length - 1, 2, 3, 4, 5, // plaintext.bytes content - 8, 0, 0, 0, // plaintext.fhe_type - 4, 0, 0, 0, 0, 0, 0, 0, // link length - 222, 173, 190, 239, // link content - ]; - - assert_eq!( - serialized, expected_bytes, - "BREAKING CHANGE: SigncryptionPayload format changed!\n\ - This will break user decryption for existing ciphertexts." - ); - } } diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index e5f3c93354..ee5e45cb4b 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -60,7 +60,7 @@ use kms_lib::{ }, signcryption::{ Signcrypt, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, - UnifiedUnsigncryptionKeyOwned, + UnifiedUnsigncryptionKeyOwned, Unsigncrypt, }, }, engine::{ @@ -816,23 +816,46 @@ fn test_unified_signcryption( let (verf_key, server_sig_key) = gen_sig_keys(&mut rng); let (client_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (_dec_key, enc_key) = encryption.keygen().unwrap(); + let (dec_key, enc_key) = encryption.keygen().unwrap(); + let receiver_id = client_verf_key.verf_key_id(); let signcrypt_key = - UnifiedSigncryptionKeyOwned::new(server_sig_key, enc_key, client_verf_key.verf_key_id()); + UnifiedSigncryptionKeyOwned::new(server_sig_key, enc_key.clone(), receiver_id.clone()); let new_versionized = signcrypt_key .signcrypt(&mut rng, b"TESTTEST", &verf_key) .unwrap(); if original_versionized != new_versionized { - Err(test.failure( + return Err(test.failure( format!( "Invalid UnifiedSigncryption:\n Expected :\n{original_versionized:?}\nGot:\n{new_versionized:?}" ), format, - )) - } else { - Ok(test.success(format)) + )); + } + + // Reading the stored bytes, not just reproducing them. The comparison above + // locks the writer; this locks the parser, which is the half that decides + // whether a node can still open material an earlier release produced. The + // frozen layout carries no version tag and is recovered by subtracting two + // fixed-size tail fields, so a change there is silent until something tries. + let unsign_key = + UnifiedUnsigncryptionKeyOwned::new(dec_key, enc_key, verf_key.clone(), receiver_id); + let opened: PublicSigKey = unsign_key + .unsigncrypt(b"TESTTEST", &original_versionized) + .map_err(|e| { + test.failure( + format!("the stored UnifiedSigncryption no longer opens: {e}"), + format, + ) + })?; + if opened != verf_key { + return Err(test.failure( + format!("the stored UnifiedSigncryption opened to the wrong message: {opened:?}"), + format, + )); } + + Ok(test.success(format)) } fn test_prss_setup_combined( From 3359709168a15cf9dbb7a13035e04e8be828d130 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 09:51:26 +0200 Subject: [PATCH 031/114] chore: remove weak key usage --- ai-docs/ARCHITECTURE.md | 14 ++-- core/grpc/proto/kms-service.v1.proto | 1 - core/service/src/backup/custodian.rs | 112 +-------------------------- docs/guides/backup.md | 14 ++-- 4 files changed, 12 insertions(+), 129 deletions(-) diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index de173a79ef..a6ea870a5e 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -135,10 +135,9 @@ The service crate is the main surface area. Key subdirectories under chain verification). Custodian backup uses MLKEM1024-P384 for all three of its keypairs — the custodian's long-term key, the operator's ephemeral recovery key, and the operator's per-context backup vault key — selected in one place, - `backup::BACKUP_PKE_SCHEME`. A new custodian context is rejected unless every - custodian encryption key, and the operator's own backup key, uses that scheme - (`InternalCustodianContext::new` / `validated_nodes`). - User decryption accepts ML-KEM-512 only. Randomly generated MLKEM1024-P384 + `backup::BACKUP_PKE_SCHEME`. Nothing rejects a peer that advertises a weaker + scheme: the signcryption carries its own `pke_type` tag, so a mixed-scheme + custodian context works. User decryption accepts ML-KEM-512 only. Randomly generated MLKEM1024-P384 keypairs use a 256-bit-seeded CSPRNG. The custodian key derives directly from 256-bit mnemonic entropy. Signing lives under [cryptography/signing/](../core/service/src/cryptography/signing/): a scheme-tagged `Signature` plus one backend per scheme — ECDSA/secp256k1 @@ -352,16 +351,13 @@ in server config and unified behind `KeychainProxy` with this keychain already, and the keychain can only encrypt once that call has installed a context, so a node configured for it makes no backups until its first context exists. New custodian contexts are rejected unless every custodian - encryption key and every custodian verification key is unique, and unless every - custodian encryption key uses `BACKUP_PKE_SCHEME`. + encryption key and every custodian verification key is unique. Every key in this path is MLKEM1024-P384 (`backup::BACKUP_PKE_SCHEME`), and the custodian's is derived from 256 bits of seed-phrase entropy — a 24-word mnemonic — so the phrase does not cap the scheme's security level. A vault written under an older ML-KEM-512 context is not readable by a node holding a composite key, but each ciphertext carries its own `pke_type`, so a vault spanning both schemes - decrypts as long as the matching key is installed. That remains true for - material already written; what is refused is *creating* a new context under a - weaker scheme. + decrypts as long as the matching key is installed. Custodian workflows are driven through the [kms-custodian](../core/service/src/bin/kms-custodian.rs) CLI and the diff --git a/core/grpc/proto/kms-service.v1.proto b/core/grpc/proto/kms-service.v1.proto index 075af85328..ead22cd718 100644 --- a/core/grpc/proto/kms-service.v1.proto +++ b/core/grpc/proto/kms-service.v1.proto @@ -474,7 +474,6 @@ service CoreServiceEndpoint { // * `context_id` in `new_context` must be present, valid, fresh and unique [`RequestId`]. // * `custodian_nodes` in `new_context` must be present and contain at least 2*`threshold` + 1 valid `CustodianSetupMessage`s. // * Every custodian encryption key and every custodian verification key must be unique within `new_context`. - // * Every custodian encryption key in `new_context` must use the backup encryption scheme (MLKEM1024-P384). // * `threshold` in `new_context` must be a positive integer satisfying 2*`threshold` < the amount of entries in `custodian_nodes`. // ## Post-condition: // * The `context_id` in `request` now represents a new custodian context. diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index fa95ffdaa5..9d09d78041 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -1,8 +1,7 @@ -use crate::backup::BACKUP_PKE_SCHEME; use crate::backup::operator::DSEP_BACKUP_MATERIAL; use crate::cryptography::signing::SigningSchemeType; use crate::cryptography::{ - encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}, + encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}, signatures::PrivateSigKey, signcryption::{ Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, @@ -37,10 +36,6 @@ const ERR_DUPLICATE_CUSTODIAN_ENCRYPTION_KEYS: &str = "Duplicate custodian encryption key found in custodian context"; const ERR_DUPLICATE_CUSTODIAN_VERIFICATION_KEYS: &str = "Duplicate custodian verification key found in custodian context"; -const ERR_WEAK_CUSTODIAN_ENCRYPTION_KEY: &str = - "Custodian encryption key does not use the backup encryption scheme"; -const ERR_WEAK_BACKUP_ENCRYPTION_KEY: &str = - "Backup encryption key does not use the backup encryption scheme"; #[derive(Clone, Serialize, Deserialize, VersionsDispatch)] pub enum InternalCustodianRecoveryOutputVersions { @@ -280,20 +275,6 @@ impl InternalCustodianContext { } let nodes = node_map.values().collect::>(); - for node in &nodes { - // Reject a custodian whose encryption key is weaker than the scheme - // this path is built around, rather than encrypting its share of the - // backup key under it anyway. - let scheme = node.public_enc_key.encryption_scheme_type(); - if scheme != BACKUP_PKE_SCHEME { - return Err(anyhow::anyhow!( - "{}: role {} published a {scheme} key, but {BACKUP_PKE_SCHEME} is required", - ERR_WEAK_CUSTODIAN_ENCRYPTION_KEY, - node.custodian_role, - )); - } - } - for (index, node) in nodes.iter().enumerate() { for previous_node in &nodes[..index] { if previous_node.public_enc_key == node.public_enc_key { @@ -322,12 +303,6 @@ impl InternalCustodianContext { custodian_context: CustodianContext, backup_enc_key: UnifiedPublicEncKey, ) -> anyhow::Result { - let backup_scheme = backup_enc_key.encryption_scheme_type(); - if backup_scheme != BACKUP_PKE_SCHEME { - return Err(anyhow::anyhow!( - "{ERR_WEAK_BACKUP_ENCRYPTION_KEY}: got {backup_scheme}, but {BACKUP_PKE_SCHEME} is required", - )); - } let node_map = Self::validated_nodes(&custodian_context)?; let context_id: RequestId = parse_optional_grpc_request_id( &custodian_context.custodian_context_id, @@ -539,7 +514,7 @@ mod tests { use super::*; use crate::backup::BACKUP_PKE_SCHEME; use crate::cryptography::{ - encryption::{Encryption, PkeScheme, PkeSchemeType}, + encryption::{Encryption, PkeScheme}, signatures::gen_sig_keys, }; use aes_prng::AesRng; @@ -716,89 +691,6 @@ mod tests { } } - /// A custodian that publishes a key weaker than [`BACKUP_PKE_SCHEME`] is - /// refused at context creation. - #[test] - fn custodian_encryption_key_below_the_backup_scheme_should_fail() { - let mut rng = AesRng::seed_from_u64(42); - let (_, backup_pk) = { - let mut enc = Encryption::new(BACKUP_PKE_SCHEME, &mut rng); - enc.keygen().unwrap() - }; - - let mut setup_messages = Vec::new(); - for role in 1..=3 { - // Role 2 publishes an ML-KEM-512 key; the others are correct, so the - // context fails on that role alone rather than on its overall shape. - let scheme = if role == 2 { - PkeSchemeType::MlKem512 - } else { - BACKUP_PKE_SCHEME - }; - let (_, public_enc_key) = { - let mut enc = Encryption::new(scheme, &mut rng); - enc.keygen().unwrap() - }; - let (public_verf_key, _) = gen_sig_keys(&mut rng); - setup_messages.push(InternalCustodianSetupMessage { - header: HEADER.to_string(), - custodian_role: Role::indexed_from_one(role), - name: format!("Custodian-{role}"), - random_value: [role as u8; 32], - timestamp: SystemTime::now(), - public_enc_key, - public_verf_key, - }); - } - - let context = CustodianContext { - custodian_nodes: setup_messages - .into_iter() - .map(|message| message.try_into().unwrap()) - .collect(), - custodian_context_id: None, - threshold: 1, - }; - - let error = InternalCustodianContext::new(context, backup_pk) - .expect_err("a custodian key below the backup scheme must be rejected"); - let error = error.to_string(); - assert!( - error.contains(ERR_WEAK_CUSTODIAN_ENCRYPTION_KEY), - "unexpected error: {error}" - ); - assert!( - error.contains("role 2"), - "the error does not name the role: {error}" - ); - } - - /// The operator's own backup key is checked too, so a future change that - /// threaded a weaker key through cannot produce a vault that looks healthy. - #[test] - fn backup_encryption_key_below_the_backup_scheme_should_fail() { - let mut rng = AesRng::seed_from_u64(43); - let (_, weak_backup_pk) = { - let mut enc = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - enc.keygen().unwrap() - }; - - // The node list is irrelevant here: the backup key is checked before - // the nodes are, so this fails on the key rather than on the shape. - let context = CustodianContext { - custodian_nodes: vec![], - custodian_context_id: None, - threshold: 1, - }; - - let error = InternalCustodianContext::new(context, weak_backup_pk) - .expect_err("a backup key below the backup scheme must be rejected"); - assert!( - error.to_string().contains(ERR_WEAK_BACKUP_ENCRYPTION_KEY), - "unexpected error: {error}" - ); - } - #[test] fn internal_custodian_context_role_greater_than_nodes_should_fail() { let mut rng = AesRng::seed_from_u64(40); diff --git a/docs/guides/backup.md b/docs/guides/backup.md index 6196b64f68..9cae019ab2 100644 --- a/docs/guides/backup.md +++ b/docs/guides/backup.md @@ -99,15 +99,11 @@ The alternative backup mode — wrapping the same key under an AWS KMS CMK — i User decryption is unaffected by any of this and remains ML-KEM-512. -Creating a custodian context requires every custodian encryption key — and the -operator's own backup key — to use MLKEM1024-P384. Every signcryption and -ciphertext carries its own `pke_type` tag, so a context whose custodians -published different schemes would otherwise work. -The check is on creation only — an existing context is still readable, so a -weak backup never becomes an unrecoverable one. - -There are no options to select a different scheme: a backup initiated with the -built-in KMS tools uses MLKEM1024-P384. +Nothing in the backup path rejects a peer that advertises a weaker public key +scheme. Every signcryption and ciphertext carries its own `pke_type` tag, so a +context whose custodians publish different schemes works. On the other hand, +there are no options to select a different scheme, so the backup process is +initiated using built-in tools from KMS, MLKEM1024-P384 will be used. ### Parties From 075f872bc7a703620212373aa1d1ff13434a231e Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 10:32:06 +0200 Subject: [PATCH 032/114] chore: updated backward --- .../generate-v0.15.0/Cargo.lock | 24 +++++++++---------- .../generate-v0.15.0/Cargo.toml | 16 ++++++------- core/service/src/backup/custodian.rs | 1 + core/service/src/backup/mod.rs | 2 +- core/service/tests/versioned_enum_coverage.rs | 2 +- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/backward-compatibility/generate-v0.15.0/Cargo.lock b/backward-compatibility/generate-v0.15.0/Cargo.lock index bd8da92463..9799e0076c 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.lock +++ b/backward-compatibility/generate-v0.15.0/Cargo.lock @@ -1832,7 +1832,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bc2wrap" version = "2.0.1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "bincode 2.0.1", "serde", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "error-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "anyhow", "tracing", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "kms" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "aes 0.9.3", "aes-gcm 0.11.1", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "kms-grpc" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -4633,7 +4633,7 @@ dependencies = [ [[package]] name = "observability" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "anyhow", "axum", @@ -6811,7 +6811,7 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "anyhow", "bc2wrap", @@ -6987,7 +6987,7 @@ dependencies = [ [[package]] name = "thread-handles" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "anyhow", "error-utils", @@ -7017,7 +7017,7 @@ dependencies = [ [[package]] name = "threshold-algebra" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "anyhow", "error-utils", @@ -7038,7 +7038,7 @@ dependencies = [ [[package]] name = "threshold-execution" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "aes 0.9.3", "aes-prng", @@ -7080,7 +7080,7 @@ dependencies = [ [[package]] name = "threshold-hashing" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "anyhow", "bc2wrap", @@ -7093,7 +7093,7 @@ dependencies = [ [[package]] name = "threshold-networking" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "anyhow", "async-trait", @@ -7130,7 +7130,7 @@ dependencies = [ [[package]] name = "threshold-types" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=445bdc8ab7d7893260384ccfeb13d936ff521fc5#445bdc8ab7d7893260384ccfeb13d936ff521fc5" +source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" dependencies = [ "anyhow", "async-trait", diff --git a/backward-compatibility/generate-v0.15.0/Cargo.toml b/backward-compatibility/generate-v0.15.0/Cargo.toml index c3514a800d..b78f2980ef 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.toml +++ b/backward-compatibility/generate-v0.15.0/Cargo.toml @@ -9,7 +9,7 @@ license = "BSD-3-Clause-Clear" [dependencies] # Import the base backward-compatibility crate for shared types and utilities backward-compatibility = { path = ".." } -bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } +bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } # NOTE: Some dependencies below are duplicated from ../Cargo.toml # This is intentional - these crates are excluded from the workspace for isolation. @@ -19,13 +19,13 @@ bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev # NOTE: v0.15.0 is not tagged yet, so we pin a commit rev on `main`. Bump this rev to a # commit that contains every type this generator serializes, otherwise the generator will # not compile. -kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } -kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } -algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } -threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5", features = ["testing"] } -threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } -threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } -hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "445bdc8ab7d7893260384ccfeb13d936ff521fc5" } +kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } +kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } +algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } +threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130", features = ["testing"] } +threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } +threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } +hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } #error-utils_0_15_0 = { path = "./core/error-utils", default-features = false } #thread-handles_0_15_0 = { path = "./core/thread-handles" } diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 9d09d78041..d9f199e4c8 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -83,6 +83,7 @@ impl TryFrom for CustodianRecoveryOutput { fn try_from(value: InternalCustodianRecoveryOutput) -> Result { Ok(CustodianRecoveryOutput { + // TODO stop gap https://github.com/zama-ai/kms-internal/issues/3168 backup_output: Some(OperatorBackupOutput { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, diff --git a/core/service/src/backup/mod.rs b/core/service/src/backup/mod.rs index 6109cc3145..fad22fc694 100644 --- a/core/service/src/backup/mod.rs +++ b/core/service/src/backup/mod.rs @@ -59,7 +59,7 @@ impl TryFrom for UnifiedSigncryption { type Error = anyhow::Error; fn try_from(value: OperatorBackupOutput) -> Result { - // TODO stop gap + // TODO stop gap https://github.com/zama-ai/kms-internal/issues/3168 let pke_type = value.pke_type.try_into()?; Ok(UnifiedSigncryption::new(value.signcryption, pke_type)) } diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index cadf531274..994dabaaac 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -66,7 +66,7 @@ const ALLOW_UNCOVERED: &[&str] = &[ // field. Covered (in serialized-and-signcrypted form) via // OperatorBackupOutputTest. "BackupMaterial", - // TODO stop gap + // TODO stop gap https://github.com/zama-ai/kms-internal/issues/3168 // Plaintext that composite_v1::seal safe_serializes into the // UnifiedSigncryption payload. "CompositeEnvelope", From a9cd27bd6793cb0ecab66de31385da029f1192ec Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 11:20:41 +0200 Subject: [PATCH 033/114] refactor: pruning --- .../src/cryptography/signcryption/common.rs | 18 ++++----- .../cryptography/signcryption/composite_v1.rs | 39 ++++++++----------- .../src/cryptography/signcryption/ecdsa_v0.rs | 11 ++---- 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index 729b8ce317..23bb3561fb 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -1,9 +1,7 @@ -//! Pieces shared by every signcryption envelope format. +//! Code shared by every signcryption envelope format. //! -//! Signing and verifying have to build *identical* receiver bindings or nothing -//! verifies at all, and both formats encrypt the same way. Anything both -//! [`super::ecdsa_v0`] and [`super::composite_v1`] need lives here, so the two -//! cannot drift apart and neither has to copy it. +//! Signing and verifying build *identical* receiver bindings or nothing +//! verifies at all, and both formats encrypt the same way. use crate::cryptography::encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; @@ -88,7 +86,7 @@ use aes_prng::AesRng; use rand::SeedableRng; #[cfg(test)] -pub(super) struct LockFixture { +pub(super) struct SigncryptionFixture { pub(super) rng: AesRng, pub(super) dec_key: UnifiedPrivateEncKey, pub(super) enc_key: UnifiedPublicEncKey, @@ -98,7 +96,7 @@ pub(super) struct LockFixture { } #[cfg(test)] -pub(super) fn lock_fixture(scheme: PkeSchemeType, seed: u64) -> LockFixture { +pub(super) fn signcryption_fixture(scheme: PkeSchemeType, seed: u64) -> SigncryptionFixture { let mut rng = AesRng::seed_from_u64(seed); let (sender_verf_key, signing_key) = gen_sig_keys(&mut rng); let (receiver_verf_key, _) = gen_sig_keys(&mut rng); @@ -107,7 +105,7 @@ pub(super) fn lock_fixture(scheme: PkeSchemeType, seed: u64) -> LockFixture { let mut encryption = Encryption::new(scheme, &mut rng); encryption.keygen().unwrap() }; - LockFixture { + SigncryptionFixture { rng, dec_key, enc_key, @@ -125,8 +123,8 @@ mod tests { /// the only thing tying a signature to who may open it. #[test] fn receiver_binding_separates_recipients() { - let f = lock_fixture(PkeSchemeType::MlKem512, 500); - let other = lock_fixture(PkeSchemeType::MlKem512, 501); + let f = signcryption_fixture(PkeSchemeType::MlKem512, 500); + let other = signcryption_fixture(PkeSchemeType::MlKem512, 501); let base = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); assert_eq!(base, receiver_binding(&f.receiver_id, &f.enc_key).unwrap()); diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index a13f433407..01694d8522 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -1,4 +1,4 @@ -//! The composite signcryption envelope: one signature per scheme. +//! The composite signcryption envelope. use super::UnifiedSigncryption; use super::common::{hybrid_decrypt, hybrid_encrypt, receiver_binding}; @@ -23,8 +23,6 @@ pub enum CompositeEnvelopeVersions { } /// The plaintext of a composite signcryption, before encryption. -/// -/// `signature` carries its own schemes, so this struct does not repeat them. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Versionize)] #[versionize(CompositeEnvelopeVersions)] pub struct CompositeEnvelope { @@ -78,7 +76,7 @@ pub fn seal( /// Open a composite signcryption, returning the message only once every /// constituent signature has verified. /// -/// `sender_keys` is the verifier's policy, not anything read off the message +/// `sender_keys` is the verifier's policy the signatures will be validated against. pub(super) fn open( decryption_key: &UnifiedPrivateEncKey, encryption_key: &UnifiedPublicEncKey, @@ -111,7 +109,7 @@ pub(super) fn open( #[cfg(test)] mod tests { - use super::super::common::lock_fixture; + use super::super::common::signcryption_fixture; use super::super::{Signcrypt, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt}; use super::*; use crate::cryptography::encryption::PkeSchemeType; @@ -126,7 +124,7 @@ mod tests { vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87] } - struct Fixture { + struct CompositeFixture { rng: AesRng, identity: NodeSigningIdentity, keys: VerfKeySet, @@ -136,16 +134,19 @@ mod tests { receiver_id: Vec, } - fn fixture(scheme: PkeSchemeType, seed: u64) -> Fixture { + fn fixture(scheme: PkeSchemeType, seed: u64) -> CompositeFixture { fixture_under(scheme, seed, pair()) } - fn fixture_under(scheme: PkeSchemeType, seed: u64, schemes: Vec) -> Fixture { - let base = lock_fixture(scheme, seed); - let mut rng = AesRng::seed_from_u64(seed ^ 0xC0FFEE); - let identity = seeded_identity(&mut rng); + fn fixture_under( + scheme: PkeSchemeType, + seed: u64, + schemes: Vec, + ) -> CompositeFixture { + let mut base = signcryption_fixture(scheme, seed); + let identity = seeded_identity(&mut base.rng); let keys = VerfKeySet::from_identity(&identity, &schemes).unwrap(); - Fixture { + CompositeFixture { rng: base.rng, identity, keys, @@ -156,7 +157,7 @@ mod tests { } } - fn seal_msg(f: &mut Fixture, msg: &[u8]) -> UnifiedSigncryption { + fn seal_msg(f: &mut CompositeFixture, msg: &[u8]) -> UnifiedSigncryption { seal( &f.identity, &f.schemes, @@ -170,7 +171,7 @@ mod tests { } fn open_with( - f: &Fixture, + f: &CompositeFixture, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id) @@ -232,12 +233,6 @@ mod tests { } /// Neither reader accepts the other's envelope. - /// - /// This is what lets a [`UnifiedSigncryption`] carry no layout tag: the - /// rejection comes from each parser on its own, not from a dispatch step - /// reading an unauthenticated field. Both directions are asserted on the - /// *specific* failure, so a future change that makes one of them succeed — - /// or fail for an unrelated reason — is caught here. #[test] fn the_two_formats_do_not_cross() { let mut f = fixture(PkeSchemeType::MlKem512, 300); @@ -269,7 +264,7 @@ mod tests { // Composite reader, handed a frozen envelope. It decrypts, but the // plaintext is `msg ‖ sig ‖ digest` rather than a serialized // `CompositeEnvelope`, so `safe_deserialize` refuses it on the header. - let base = lock_fixture(PkeSchemeType::MlKem512, 300); + let base = signcryption_fixture(PkeSchemeType::MlKem512, 300); let mut rng = base.rng; let ecdsa_key = UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id); @@ -316,7 +311,7 @@ mod tests { // ...and the composite reader still rejects a frozen envelope, even now // that the set it demands is exactly {ECDSA}. - let base = lock_fixture(PkeSchemeType::MlKem512, 400); + let base = signcryption_fixture(PkeSchemeType::MlKem512, 400); let mut rng = base.rng; let frozen = UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id) diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 347170af9b..6acfe42860 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -3,9 +3,6 @@ //! Every user-decryption ciphertext produced since 0.11 uses this layout and the //! deployed browser-side verifier parses it, so its bytes cannot change. See the //! module documentation of [`super`] for the full list of what pins them. -//! -//! Only the layout lives here. The receiver binding and the KEM/DEM plumbing are -//! shared with every other format and live in [`super::common`]. use super::common::{DSEP_SIGNCRYPTION, hybrid_decrypt, hybrid_encrypt, receiver_binding}; use super::{ @@ -187,7 +184,7 @@ pub(crate) fn insecure_decrypt_ignoring_signature( #[cfg(test)] mod tests { use super::super::Signcrypt; - use super::super::common::{lock_fixture, receiver_enc_key_digest}; + use super::super::common::{receiver_enc_key_digest, signcryption_fixture}; use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::PkeSchemeType; @@ -218,7 +215,7 @@ mod tests { #[test] fn ecdsa_v0_receiver_binding_is_locked() { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { - let f = lock_fixture(scheme, 100); + let f = signcryption_fixture(scheme, 100); assert_eq!( receiver_binding(&f.receiver_id, &f.enc_key).unwrap(), [ @@ -241,7 +238,7 @@ mod tests { fn ecdsa_v0_envelope_layout_is_locked() { const DSEP: &DomainSep = b"ECDSAV0T"; for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { - let mut f = lock_fixture(scheme, 200); + let mut f = signcryption_fixture(scheme, 200); let payload = TestType { i: 4711 }; let signcrypt_key = UnifiedSigncryptionKey::new(&f.signing_key, &f.enc_key, &f.receiver_id); @@ -296,7 +293,7 @@ mod tests { /// standing between a short plaintext and an out-of-bounds slice. #[test] fn a_short_plaintext_is_a_length_error() { - let f = lock_fixture(PkeSchemeType::MlKem512, 400); + let f = signcryption_fixture(PkeSchemeType::MlKem512, 400); for len in 0..(SIG_SIZE + DIGEST_BYTES) { let short = Zeroizing::new(vec![0u8; len]); assert!( From b6090645365f5cafd28208c0370941aed8259c85 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 11:27:01 +0200 Subject: [PATCH 034/114] chore: pruning --- core/service/src/backup/custodian.rs | 2 +- core/service/src/backup/operator.rs | 1 + .../src/cryptography/signcryption/ecdsa_v0.rs | 12 ++++-------- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index d9f199e4c8..620a4798cd 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -83,10 +83,10 @@ impl TryFrom for CustodianRecoveryOutput { fn try_from(value: InternalCustodianRecoveryOutput) -> Result { Ok(CustodianRecoveryOutput { - // TODO stop gap https://github.com/zama-ai/kms-internal/issues/3168 backup_output: Some(OperatorBackupOutput { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, + // TODO stop gap https://github.com/zama-ai/kms-internal/issues/3168 signing_type: SigningSchemeType::Ecdsa256k1.as_wire(), }), custodian_role: value.custodian_role.one_based() as u64, diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index 0f7b437a04..f077e0fd79 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -192,6 +192,7 @@ impl TryFrom for OperatorBackupOutput { Ok(Self { signcryption: value.signcryption.payload, pke_type: value.signcryption.pke_type as i32, + // TODO stop gap https://github.com/zama-ai/kms-internal/issues/3168 signing_type: SigningSchemeType::Ecdsa256k1.as_wire(), }) } diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 6acfe42860..05485a3b2c 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -33,8 +33,7 @@ fn sender_verf_key_digest(verf_key: &PublicSigKey) -> Result, Cryptograp // Implements the actual signcryption but without serialization // -// This is the FROZEN layout; see the module -// documentation for what depends on its bytes and on its RNG usage. +// This is the FROZEN layout. pub(super) fn inner_signcryption( signcrypt_key: &UnifiedSigncryptionKey, rng: &mut (impl CryptoRng + RngCore), @@ -71,8 +70,7 @@ pub(super) fn inner_signcryption( /// Implements the actual unsigncryption process, but without any deserialization /// -/// This is the FROZEN layout; see the module -/// documentation. +/// This is the FROZEN layout. pub(super) fn inner_unsigncrypt( unsign_key: &UnifiedUnsigncryptionKey, sender_verf_key: &PublicSigKey, @@ -232,8 +230,7 @@ mod tests { /// with the two tail fields fixed-size and in that order. /// /// This is the contract [`parse_msg`] relies on when it recovers `msg_len` - /// by subtracting from the end. Before this test, the layout was implied by - /// that arithmetic alone. + /// by subtracting from the end. #[test] fn ecdsa_v0_envelope_layout_is_locked() { const DSEP: &DomainSep = b"ECDSAV0T"; @@ -289,8 +286,7 @@ mod tests { } /// Truncating the plaintext below the two fixed tail fields is a length - /// error, not a panic. The arithmetic in [`parse_msg`] is the only thing - /// standing between a short plaintext and an out-of-bounds slice. + /// error, not a panic. #[test] fn a_short_plaintext_is_a_length_error() { let f = signcryption_fixture(PkeSchemeType::MlKem512, 400); From 0fd135a2371a759f68c80de028afc8af49f771f3 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 12:04:38 +0200 Subject: [PATCH 035/114] chore: changed composite to use safe serialization --- .../cryptography/signcryption/composite_v1.rs | 30 +++++++++---------- .../src/cryptography/signcryption/ecdsa_v0.rs | 2 +- .../src/cryptography/signcryption/mod.rs | 9 +++--- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 01694d8522..9a13755f01 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -66,9 +66,11 @@ pub fn seal( let plaintext = Zeroizing::new(plaintext); let ciphertext = hybrid_encrypt(rng, &plaintext, receiver_enc_key)?; + let mut payload = Vec::new(); + safe_serialize(&ciphertext, &mut payload, SAFE_SER_SIZE_LIMIT) + .map_err(|e| CryptographyError::SerializationError(e.to_string()))?; Ok(UnifiedSigncryption::new( - bc2wrap::serialize(&ciphertext) - .map_err(|e| CryptographyError::BincodeError(e.to_string()))?, + payload, receiver_enc_key.encryption_scheme_type(), )) } @@ -90,8 +92,9 @@ pub(super) fn open( "encryption type of cipher does not match the decryption key type".to_string(), )); } - let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) - .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; + let kem_ct: HybridKemCt = + safe_deserialize(std::io::Cursor::new(&cipher.payload), SAFE_SER_SIZE_LIMIT) + .map_err(CryptographyError::SerializationError)?; let plaintext = hybrid_decrypt(kem_ct, decryption_key)?; let envelope: CompositeEnvelope = safe_deserialize(std::io::Cursor::new(&*plaintext), SAFE_SER_SIZE_LIMIT) @@ -238,9 +241,7 @@ mod tests { let mut f = fixture(PkeSchemeType::MlKem512, 300); let composite = seal_msg(&mut f, b"composite payload"); - // Frozen reader, handed a composite envelope. It decrypts, then reads - // the trailing 32 bytes as `H(sender verification key)` — which they are - // not, being the tail of an ML-DSA signature. + // Frozen reader, handed a composite envelope. let legacy_verf = f .keys .require(SigningSchemeType::Ecdsa256k1) @@ -256,14 +257,13 @@ mod tests { .unsigncrypt::(DSEP, &composite) .unwrap_err(); assert!( - err.to_string() - .contains("unexpected verification key digest"), - "the frozen reader must reject a composite envelope on the key digest, got: {err}" + matches!(err, CryptographyError::BincodeError(_)), + "the frozen reader must reject a composite envelope on the KEM ciphertext, got: {err}" ); - // Composite reader, handed a frozen envelope. It decrypts, but the - // plaintext is `msg ‖ sig ‖ digest` rather than a serialized - // `CompositeEnvelope`, so `safe_deserialize` refuses it on the header. + // Composite reader, handed a frozen envelope. The frozen layout writes + // its `HybridKemCt` with bincode and no header, so `safe_deserialize` + // refuses it — again before anything is decrypted. let base = signcryption_fixture(PkeSchemeType::MlKem512, 300); let mut rng = base.rng; let ecdsa_key = @@ -291,8 +291,8 @@ mod tests { assert_eq!(&*opened, b"ecdsa alone"); // The frozen reader, holding the very key that signed this envelope, - // still rejects it: the plaintext is a serialized `CompositeEnvelope`, - // so its trailing 32 bytes are not `H(sender verification key)`. + // still rejects it: the payload is a safe-serialized KEM ciphertext, + // which its bincode parse refuses. let legacy_verf = f .keys .require(SigningSchemeType::Ecdsa256k1) diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 05485a3b2c..9da1962c6a 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -50,7 +50,7 @@ pub(super) fn inner_signcryption( let sig = internal_sign(dsep, &to_sign, signcrypt_key.signing_key) .map_err(|e| CryptographyError::SigningError(e.to_string()))?; - // Encrypt msg || sig || H(server_verification_key) || H(server_enc_pub_key) + // Encrypt msg || sig || H(server_verification_key) // OBSERVE: serialization is simply r concatenated with s. That is NOT an Ethereum compatible // signature since we preclude the v value. // The verification key is serialized based on the SEC1 standard. diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 1a322a7b8c..d8520dd874 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -23,10 +23,11 @@ //! A layout tag on the message was tried and removed: it travels outside the //! ciphertext, so it could never be trusted over the caller's own knowledge of //! which opener it invoked. The two parsers reject each other's output on their -//! own — a multi-signature envelope fails the frozen reader's -//! `H(sender verification key)` check, and a frozen envelope fails -//! `safe_deserialize` in the multi-signature reader. Both directions are checked -//! by `composite_v1::tests::the_two_formats_do_not_cross`. +//! own, and on the first field either one reads: the frozen layout writes its +//! `HybridKemCt` with bincode and the multi-signature layout safe-serializes +//! it, so neither reader gets as far as decrypting the other's envelope. Both +//! directions are checked by +//! `composite_v1::tests::the_two_formats_do_not_cross`. //! //! If a reader ever has to walk stored material of mixed vintage, prefer the //! type-name header `safe_serialize` already writes *inside* the ciphertext over From 9a8749f8582df1b9aaa562f4c80ed75c55731883 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 12:10:58 +0200 Subject: [PATCH 036/114] refactor: pruning --- .../src/cryptography/signing/composite.rs | 23 ++----------------- .../src/cryptography/signing/verf_key_set.rs | 10 ++------ 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 19e50d3152..9094b7db70 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -5,10 +5,6 @@ //! must not leave something a verifier accepts, or the hedge the composite was //! built for is gone. //! -//! What makes that hold is that the scheme set is inside the bytes each -//! signature covers. A signature produced under `{A, B}` attests to that set, so -//! it cannot be re-presented as a complete signature under `{A}`. - use super::identity::NodeSigningIdentity; use super::typed_signature::StoredTypedSignature; use super::verf_key_set::VerfKeySet; @@ -87,9 +83,7 @@ fn ensure_canonical(schemes: &[SigningSchemeType]) -> Result<(), SigningError> { /// /// Entries are ordered by scheme and carry no duplicate scheme. /// -/// The [`Deserialize`] impl *rejects* a non-canonical list rather than sorting it: -/// a value arriving from storage or the wire is attacker-chosen, and silently -/// reordering it would give one signature more than one encoding. +/// The [`Deserialize`] impl *rejects* a non-canonical list rather than sorting it. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(transparent)] pub struct CompositeSignature(Vec); @@ -187,25 +181,12 @@ impl CompositeSignature { /// The per-scheme signatures of a *result*: a keygen, CRS, preprocessing or /// decryption response. /// -/// ECDSA signs `eip712_hash` recoverably, producing the signature the fhevm -/// contracts verify on chain. Every other scheme signs +/// ECDSA signs `eip712_hash`, every other scheme signs /// [`scheme_bound_preimage`] over `payload_bytes`, so it commits to the scheme /// set as well as to the payload. /// /// `schemes` may be given in any order; the entries come back ordered by /// scheme. -/// -/// Returns a plain list rather than a [`CompositeSignature`] for two reasons, -/// and they are the only two places the shapes differ: -/// -/// - A result may legitimately request no schemes at all, which that type -/// refuses to represent. -/// - Its ECDSA entry is **not** scheme-bound. An EIP-712 hash is 32 bytes with -/// nowhere to put a prefix, and the fhevm contracts must be able to recover -/// the signer from it, so that entry signs the hash verbatim. -/// -/// WARNING: that unbound ECDSA entry is why a verifier must still check the -/// schemes it received against the ones it asked for. #[cfg(feature = "non-wasm")] pub fn sign_result_entries( identity: &NodeSigningIdentity, diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 20cd4bb626..197e26f64f 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -16,18 +16,12 @@ const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; /// signs under several schemes, so a verifier needs several keys, and needs them /// to travel together. /// +/// This type is not versioned as it is only meant to be an in-memory type. +/// /// # Invariants /// /// The set is non-empty, and every key is filed under the scheme it actually /// belongs to. -/// -/// # Versioning -/// -/// Not versioned, for the same reason as [`CompositeSignature`]: a -/// `#[versionize(..)]` derive requires every field to implement `Version`, and -/// `BTreeMap` does not. The stored form -/// is therefore the serde form — with `#[serde(transparent)]`, the bare map — -/// and a later change of shape has to be absorbed by the containing type. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(transparent)] pub struct VerfKeySet { From 63e917fbb14f967aab2766d091128eda02518f49 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 12:25:31 +0200 Subject: [PATCH 037/114] fix: handled copilot comment about client sign verification --- core/service/src/client/client_non_wasm.rs | 48 ++++++---------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index a029957319..65a359878c 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -195,6 +195,7 @@ mod tests { NodeSigningIdentity, RootSigningSeed, compute_eip712_signature, gen_sig_keys, }; use crate::cryptography::signing::SigningError; + use crate::cryptography::signing::composite::sign_result_entries; use crate::dummy_domain; use aes_prng::AesRng; use kms_grpc::RequestId; @@ -252,31 +253,17 @@ mod tests { client } - /// The signatures `identity` produces for `schemes` over `payload`, in the - /// forms `engine::base::scheme_signing_jobs` defines. + /// The signatures `identity` produces for `schemes` over `payload`. fn signatures_for( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], payload: &[u8], ) -> Vec { - let domain = dummy_domain(); - schemes + let eip712_hash = sol_type().eip712_signing_hash(&dummy_domain()); + sign_result_entries(identity, schemes, DSEP, eip712_hash.as_slice(), payload) + .unwrap() .iter() - .map(|&scheme| { - let signature = match scheme { - SigningSchemeType::Ecdsa256k1 => { - compute_eip712_signature(identity.ecdsa(), &sol_type(), &domain).unwrap() - } - _ => identity - .unified_sign_with(scheme, DSEP, payload) - .unwrap() - .to_bytes(), - }; - TypedSignature { - scheme: scheme.as_wire(), - signature, - } - }) + .map(TypedSignature::from) .collect() } @@ -531,12 +518,11 @@ mod tests { let requested = [SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa65]; let client = client_requesting(&identity, true, &requested); - let mut signatures = signatures_for(&identity, &[SigningSchemeType::Ecdsa256k1], PAYLOAD); - signatures.extend(signatures_for( - &other, - &[SigningSchemeType::MlDsa65], - PAYLOAD, - )); + // Both lists are signed under the whole requested set, so what is left + // for the verifier to object to is the two parties + let mine = signatures_for(&identity, &requested, PAYLOAD); + let theirs = signatures_for(&other, &requested, PAYLOAD); + let signatures = vec![mine[0].clone(), theirs[1].clone()]; assert!(verify(&client, &signatures, PAYLOAD).is_err()); } @@ -546,7 +532,6 @@ mod tests { fn requested_schemes_cannot_be_stripped() { let identity = seeded_identity(10); let every_scheme: Vec<_> = SigningSchemeType::iter().collect(); - let signatures = signatures_for(&identity, &every_scheme, PAYLOAD); let composite = vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa65]; for (case, requested, offered) in [ @@ -562,15 +547,8 @@ mod tests { if !requested.is_empty() { client.set_signing_schemes(&requested).unwrap(); } - let offered: Vec<_> = signatures - .iter() - .filter(|typed| { - offered - .iter() - .any(|scheme| scheme.as_wire() == typed.scheme) - }) - .cloned() - .collect(); + // Signed under exactly the set offered, not a larger list + let offered = signatures_for(&identity, &offered, PAYLOAD); assert_eq!( verify(&client, &offered, PAYLOAD).unwrap(), (PARTY, identity.verf_key().address()), From 7ecab3dc46d5c1d5411c64ac1af09f2f717e9ab7 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 12:36:45 +0200 Subject: [PATCH 038/114] fix: other test issue from copilot --- .../service/src/engine/validation_non_wasm.rs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 174b032d5c..25d2cf001b 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -1195,6 +1195,7 @@ fn unpack_new_mpc_epoch_req(req: NewMpcEpochRequest) -> anyhow::Result = sign_result_entries( + &identity, + &[scheme], + &DSEP_PUBLIC_DECRYPTION, + &[0u8; 32], + &payload_bytes, + ) + .unwrap() + .iter() + .map(TypedSignature::from) + .collect(); let server_pks = HashMap::from([(1u32, vk.clone())]); let scheme_verf_keys = HashMap::from([( From 388766f2c0178271430a448bcaa105a2f2a25acf Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 14:37:53 +0200 Subject: [PATCH 039/114] fix: add zeroize on payloads --- .../cryptography/signcryption/composite_v1.rs | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 9a13755f01..3e646deab6 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -9,13 +9,14 @@ use crate::cryptography::hybrid_ml_kem::HybridKemCt; use crate::cryptography::signatures::{ CompositeSignature, NodeSigningIdentity, SigningSchemeType, VerfKeySet, }; +use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use tfhe::named::Named; use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; use tfhe_versionable::{Versionize, VersionsDispatch}; -use zeroize::Zeroizing; +use zeroize::{Zeroize, Zeroizing}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] pub enum CompositeEnvelopeVersions { @@ -34,6 +35,13 @@ impl Named for CompositeEnvelope { const NAME: &'static str = "signcryption::CompositeEnvelope"; } +impl Zeroize for CompositeEnvelope { + fn zeroize(&mut self) { + // `signature` is public; `msg` is the secret this envelope exists to carry. + self.msg.zeroize(); + } +} + /// Signcrypt `msg` in the composite layout. /// /// Sign-then-encrypt, as in the frozen layout: each signature covers @@ -56,16 +64,18 @@ pub fn seal( let signed = Zeroizing::new([msg, binding.as_slice()].concat()); let signature = CompositeSignature::sign_uniform(identity, schemes, dsep, &signed)?; - let envelope = CompositeEnvelope { + let mut envelope = CompositeEnvelope { msg: msg.to_vec(), signature, }; - let mut plaintext = Vec::new(); - safe_serialize(&envelope, &mut plaintext, SAFE_SER_SIZE_LIMIT) - .map_err(|e| CryptographyError::SerializationError(e.to_string()))?; - let plaintext = Zeroizing::new(plaintext); - - let ciphertext = hybrid_encrypt(rng, &plaintext, receiver_enc_key)?; + let mut plaintext = ZeroizingWriter::new(); + let serialized = safe_serialize(&envelope, &mut plaintext, SAFE_SER_SIZE_LIMIT); + // The envelope owns a second copy of the payload and nothing wipes it on drop, + // so wipe it here, before either outcome leaves the function. + envelope.zeroize(); + serialized.map_err(|e| CryptographyError::SerializationError(e.to_string()))?; + + let ciphertext = hybrid_encrypt(rng, plaintext.as_slice(), receiver_enc_key)?; let mut payload = Vec::new(); safe_serialize(&ciphertext, &mut payload, SAFE_SER_SIZE_LIMIT) .map_err(|e| CryptographyError::SerializationError(e.to_string()))?; @@ -96,18 +106,20 @@ pub(super) fn open( safe_deserialize(std::io::Cursor::new(&cipher.payload), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError)?; let plaintext = hybrid_decrypt(kem_ct, decryption_key)?; - let envelope: CompositeEnvelope = + let mut envelope: CompositeEnvelope = safe_deserialize(std::io::Cursor::new(&*plaintext), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError)?; + let msg = Zeroizing::new(std::mem::take(&mut envelope.msg)); + let binding = receiver_binding(receiver_id, encryption_key)?; - let signed = Zeroizing::new([envelope.msg.as_slice(), binding.as_slice()].concat()); + let signed = Zeroizing::new([msg.as_slice(), binding.as_slice()].concat()); envelope .signature .verify_uniform(sender_keys, dsep, &signed) .map_err(|e| CryptographyError::VerificationError(e.to_string()))?; - Ok(Zeroizing::new(envelope.msg)) + Ok(msg) } #[cfg(test)] From 6fc66f0880731e8e2b4cf86a7ab40452ece228e3 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 14:59:57 +0200 Subject: [PATCH 040/114] test: removed unneeded tests --- .../cryptography/signcryption/composite_v1.rs | 196 +++++++----------- .../src/cryptography/signcryption/ecdsa_v0.rs | 20 +- .../src/cryptography/signcryption/mod.rs | 54 ++--- .../src/cryptography/signing/composite.rs | 17 +- core/service/src/cryptography/signing/mod.rs | 10 - .../src/cryptography/signing/verf_key_set.rs | 40 ++-- 6 files changed, 120 insertions(+), 217 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 3e646deab6..321513d754 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -128,6 +128,7 @@ mod tests { use super::super::{Signcrypt, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt}; use super::*; use crate::cryptography::encryption::PkeSchemeType; + use crate::cryptography::signatures::{PublicSigKey, UnifiedPublicSigKey}; use crate::cryptography::signing::test_support::seeded_identity; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; @@ -193,6 +194,15 @@ mod tests { .open(DSEP, cipher) } + /// The ECDSA member of the fixture's key set, in the form the frozen reader + /// takes. + fn ecdsa_member(f: &CompositeFixture) -> PublicSigKey { + match f.keys.require(SigningSchemeType::Ecdsa256k1).unwrap() { + UnifiedPublicSigKey::Ecdsa256k1(key) => key.clone(), + _ => unreachable!("the ECDSA member of the set is an ECDSA key"), + } + } + /// Round-trips for both PKE schemes the backup and user-decryption paths use. #[test] fn round_trip() { @@ -248,107 +258,67 @@ mod tests { } /// Neither reader accepts the other's envelope. + /// The single-ECDSA set holds the very key that signed the envelope and must still refuse + /// it, because the payload is a safe-serialized KEM ciphertext that its + /// bincode parse rejects before anything is decrypted. #[test] fn the_two_formats_do_not_cross() { - let mut f = fixture(PkeSchemeType::MlKem512, 300); - let composite = seal_msg(&mut f, b"composite payload"); - - // Frozen reader, handed a composite envelope. - let legacy_verf = f - .keys - .require(SigningSchemeType::Ecdsa256k1) - .unwrap() - .clone(); - let legacy_ecdsa = match &legacy_verf { - crate::cryptography::signatures::UnifiedPublicSigKey::Ecdsa256k1(k) => k.clone(), - _ => unreachable!("the ECDSA member of the set is an ECDSA key"), - }; - let legacy_key = - UnifiedUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &legacy_ecdsa, &f.receiver_id); - let err = legacy_key - .unsigncrypt::(DSEP, &composite) - .unwrap_err(); - assert!( - matches!(err, CryptographyError::BincodeError(_)), - "the frozen reader must reject a composite envelope on the KEM ciphertext, got: {err}" - ); - - // Composite reader, handed a frozen envelope. The frozen layout writes - // its `HybridKemCt` with bincode and no header, so `safe_deserialize` - // refuses it — again before anything is decrypted. - let base = signcryption_fixture(PkeSchemeType::MlKem512, 300); - let mut rng = base.rng; - let ecdsa_key = - UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id); - let frozen = ecdsa_key - .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) - .unwrap(); - let err = open_with(&f, &frozen).unwrap_err(); - assert!( - matches!(err, CryptographyError::SerializationError(_)), - "the composite reader must reject a frozen envelope on deserialization, got: {err}" - ); - } - - /// ECDSA alone is a legitimate scheme set for this layout. - #[test] - fn the_ecdsa_singleton_round_trips_and_still_does_not_cross() { - let mut f = fixture_under( - PkeSchemeType::MlKem512, - 400, - vec![SigningSchemeType::Ecdsa256k1], - ); - let cipher = seal_msg(&mut f, b"ecdsa alone"); - let opened = open_with(&f, &cipher).unwrap(); - assert_eq!(&*opened, b"ecdsa alone"); - - // The frozen reader, holding the very key that signed this envelope, - // still rejects it: the payload is a safe-serialized KEM ciphertext, - // which its bincode parse refuses. - let legacy_verf = f - .keys - .require(SigningSchemeType::Ecdsa256k1) - .unwrap() - .clone(); - let legacy_ecdsa = match &legacy_verf { - crate::cryptography::signatures::UnifiedPublicSigKey::Ecdsa256k1(k) => k.clone(), - _ => unreachable!("the ECDSA member of the set is an ECDSA key"), - }; - let legacy_key = - UnifiedUnsigncryptionKey::new(&f.dec_key, &f.enc_key, &legacy_ecdsa, &f.receiver_id); - assert!( - legacy_key.unsigncrypt::(DSEP, &cipher).is_err(), - "the frozen reader must not accept a single-ECDSA composite envelope" - ); - - // ...and the composite reader still rejects a frozen envelope, even now - // that the set it demands is exactly {ECDSA}. - let base = signcryption_fixture(PkeSchemeType::MlKem512, 400); - let mut rng = base.rng; - let frozen = - UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id) - .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) - .unwrap(); - assert!( - matches!( - open_with(&f, &frozen), - Err(CryptographyError::SerializationError(_)) - ), - "the composite reader must reject a frozen envelope on deserialization" - ); + for (seed, schemes) in [ + (300_u64, pair()), + (400_u64, vec![SigningSchemeType::Ecdsa256k1]), + ] { + let mut f = fixture_under(PkeSchemeType::MlKem512, seed, schemes); + let composite = seal_msg(&mut f, b"composite payload"); + + // The envelope does open for the reader it was made for, so each + // rejection below is the format mismatch and not a broken fixture. + assert_eq!(&*open_with(&f, &composite).unwrap(), b"composite payload"); + + // Frozen reader, handed a composite envelope. + let legacy_ecdsa = ecdsa_member(&f); + let legacy_key = UnifiedUnsigncryptionKey::new( + &f.dec_key, + &f.enc_key, + &legacy_ecdsa, + &f.receiver_id, + ); + let err = legacy_key + .unsigncrypt::(DSEP, &composite) + .unwrap_err(); + assert!( + matches!(err, CryptographyError::BincodeError(_)), + "the frozen reader must reject a composite envelope on the KEM ciphertext, got: {err}" + ); + + // Composite reader, handed a frozen envelope. The frozen layout + // writes its `HybridKemCt` with bincode and no header, so + // `safe_deserialize` refuses it — again before any decryption. + let base = signcryption_fixture(PkeSchemeType::MlKem512, seed); + let mut rng = base.rng; + let frozen = + UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id) + .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) + .unwrap(); + let err = open_with(&f, &frozen).unwrap_err(); + assert!( + matches!(err, CryptographyError::SerializationError(_)), + "the composite reader must reject a frozen envelope on deserialization, got: {err}" + ); + } } - /// A sender whose key set differs from the one the receiver holds is - /// detected, even though the ciphertext decrypts. There is no sender - /// identifier in the envelope to compare. + /// Opening fails on every deviation from what was sealed. #[test] - fn a_different_sender_key_set_is_rejected() { + fn open_rejects_any_deviation_from_what_was_sealed() { let mut f = fixture(PkeSchemeType::MlKem512, 500); - let cipher = seal_msg(&mut f, b"whose message is this"); + let cipher = seal_msg(&mut f, b"bound to one sender and one receiver"); + + // The untouched envelope opens, so each rejection below is the + // deviation and not an unrelated failure. + open_with(&f, &cipher).unwrap(); let mut rng = AesRng::seed_from_u64(999); - let other = seeded_identity(&mut rng); - let other_keys = VerfKeySet::from_identity(&other, &pair()).unwrap(); + let other_keys = VerfKeySet::from_identity(&seeded_identity(&mut rng), &f.schemes).unwrap(); assert!( open( &f.dec_key, @@ -356,31 +326,17 @@ mod tests { &other_keys, &f.receiver_id, DSEP, - &cipher, + &cipher ) - .is_err() + .is_err(), + "another party's key set opened the envelope" ); - } - - /// The receiver binding is covered by the signature, so opening with a - /// different receiver id fails. - #[test] - fn a_different_receiver_id_is_rejected() { - let mut f = fixture(PkeSchemeType::MlKem512, 600); - let cipher = seal_msg(&mut f, b"bound to a receiver"); let other_id = b"a different receiver".to_vec(); - assert!(open(&f.dec_key, &f.enc_key, &f.keys, &other_id, DSEP, &cipher,).is_err()); - } - - #[test] - fn a_tampered_ciphertext_or_dsep_fails() { - let mut f = fixture(PkeSchemeType::MlKem512, 700); - let cipher = seal_msg(&mut f, b"tamper with me"); - - let mut flipped = cipher.clone(); - flipped.payload[0] ^= 0x01; - assert!(open_with(&f, &flipped).is_err()); + assert!( + open(&f.dec_key, &f.enc_key, &f.keys, &other_id, DSEP, &cipher).is_err(), + "a different receiver id opened the envelope" + ); assert!( open( @@ -389,9 +345,17 @@ mod tests { &f.keys, &f.receiver_id, b"OTHERDSP", - &cipher, + &cipher ) - .is_err() + .is_err(), + "a different domain separator opened the envelope" + ); + + let mut flipped = cipher.clone(); + flipped.payload[0] ^= 0x01; + assert!( + open_with(&f, &flipped).is_err(), + "a tampered ciphertext opened the envelope" ); } } diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 9da1962c6a..01b7d8bac2 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -182,7 +182,7 @@ pub(crate) fn insecure_decrypt_ignoring_signature( #[cfg(test)] mod tests { use super::super::Signcrypt; - use super::super::common::{receiver_enc_key_digest, signcryption_fixture}; + use super::super::common::signcryption_fixture; use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::PkeSchemeType; @@ -208,24 +208,6 @@ mod tests { ); } - /// The receiver binding is exactly `receiver_id ‖ H(receiver enc key)`, in - /// that order. - #[test] - fn ecdsa_v0_receiver_binding_is_locked() { - for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { - let f = signcryption_fixture(scheme, 100); - assert_eq!( - receiver_binding(&f.receiver_id, &f.enc_key).unwrap(), - [ - f.receiver_id.as_slice(), - receiver_enc_key_digest(&f.enc_key).unwrap().as_slice(), - ] - .concat(), - "{scheme}: the receiver binding changed" - ); - } - } - /// The encrypted plaintext is exactly `msg ‖ sig(64) ‖ H(sender key)(32)`, /// with the two tail fields fixed-size and in that order. /// diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index d8520dd874..02959de7e1 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -743,37 +743,24 @@ mod tests { (rng, keys) } + /// Round-trips under every PKE scheme signcryption supports. #[test] fn sunshine() { - let (mut rng, client_signcryption_keys) = test_setup(); - let msg = TestType { i: 1333 }; - let cipher = client_signcryption_keys - .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) - .unwrap(); - assert_eq!(cipher.pke_type, PkeSchemeType::MlKem512); - let decrypted_msg = client_signcryption_keys - .unsigncryption_key - .unsigncrypt(b"TESTTEST", &cipher) - .unwrap(); - assert_eq!(msg, decrypted_msg); - } - - #[test] - fn sunshine_mlkem1024_p384() { - let (mut rng, keys) = test_setup_with_scheme(PkeSchemeType::MlKem1024P384); - let msg = TestType { i: 1333 }; - let cipher = keys - .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) - .unwrap(); - assert_eq!(cipher.pke_type, PkeSchemeType::MlKem1024P384); + for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { + let (mut rng, keys) = test_setup_with_scheme(scheme); + let msg = TestType { i: 1333 }; + let cipher = keys + .signcrypt_key + .signcrypt(&mut rng, b"TESTTEST", &msg) + .unwrap(); + assert_eq!(cipher.pke_type, scheme); - let decrypted_msg = keys - .unsigncryption_key - .unsigncrypt(b"TESTTEST", &cipher) - .unwrap(); - assert_eq!(msg, decrypted_msg); + let decrypted_msg = keys + .unsigncryption_key + .unsigncrypt(b"TESTTEST", &cipher) + .unwrap(); + assert_eq!(msg, decrypted_msg, "{scheme}"); + } } #[test] @@ -942,13 +929,14 @@ mod tests { signing_type: scheme, }; - let upgraded = v0.clone().upgrade().unwrap(); - assert_eq!(upgraded.payload, v0.payload); - assert_eq!(upgraded.pke_type, v0.pke_type); - // The upgrade is exactly what the constructor builds, so a // regenerated artifact still compares equal to a frozen one. - assert_eq!(upgraded, UnifiedSigncryption::new(v0.payload, v0.pke_type)); + let upgraded = v0.clone().upgrade().unwrap(); + assert_eq!( + upgraded, + UnifiedSigncryption::new(v0.payload, v0.pke_type), + "{scheme}" + ); } } } diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 9094b7db70..17ed089b5d 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -248,12 +248,17 @@ mod tests { (identity, keys, schemes) } + /// A signature verifies under the key set that made it, and under no other + /// party's. #[test] fn round_trip_sunshine() { let (identity, keys, schemes) = setup(1); let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); assert_eq!(sig.schemes(), schemes); sig.verify_uniform(&keys, DSEP, MSG).unwrap(); + + let (_, other_keys, _) = setup(7); + assert!(sig.verify_uniform(&other_keys, DSEP, MSG).is_err()); } /// Removing a signature must not leave something that verifies under the @@ -336,18 +341,6 @@ mod tests { assert!(sig.verify_uniform(&keys, b"OTHERDSP", MSG).is_err()); } - /// Signatures are checked against the key set presented, so another party's - /// keys do not verify them. - #[test] - fn another_partys_keys_do_not_verify() { - let (identity, keys, schemes) = setup(6); - let (_, other_keys, _) = setup(7); - let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - - sig.verify_uniform(&keys, DSEP, MSG).unwrap(); - assert!(sig.verify_uniform(&other_keys, DSEP, MSG).is_err()); - } - /// An identity with no root seed can only do ECDSA, so asking it for the /// composite pair fails rather than producing a one-scheme signature. #[test] diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 201b376b35..ea3311537b 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -782,16 +782,6 @@ mod tests { scheme.as_wire() ); } - - // Stated once more as the property the rest of the crate relies on, - // so a failure names the consequence rather than an index mismatch. - let declared: Vec<_> = SigningSchemeType::iter().collect(); - let mut by_wire = declared.clone(); - by_wire.sort_by_key(|scheme| scheme.as_wire()); - assert_eq!( - declared, by_wire, - "sorting by wire value reorders the schemes" - ); } #[test] diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 197e26f64f..5ad12ef3f4 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -154,39 +154,17 @@ mod tests { )); } + /// A misfiled key and an empty set are both refused, by the constructor and + /// on deserialization alike. #[test] - fn a_key_filed_under_the_wrong_scheme_is_rejected() { + fn the_invariants_hold_for_a_built_and_a_deserialized_set() { let mut rng = AesRng::seed_from_u64(3); let identity = seeded_identity(&mut rng); let ecdsa = identity .unified_verifying_key(SigningSchemeType::Ecdsa256k1) .unwrap(); - let mut keys = BTreeMap::new(); - keys.insert(SigningSchemeType::MlDsa87, ecdsa); - assert!(matches!( - VerfKeySet::new(keys), - Err(SigningError::SchemeMismatch { .. }) - )); - - assert!(matches!( - VerfKeySet::new(BTreeMap::new()), - Err(SigningError::EmptySchemeSet) - )); - } - - /// The invariants must hold for a set that is *read back*, not only for one - /// built in process. `#[serde(transparent)]` makes the encoding of a - /// `VerfKeySet` identical to that of its map, so a hand-crafted map is - /// exactly what an attacker would be able to present. - #[test] - fn a_deserialized_set_is_validated() { - let mut rng = AesRng::seed_from_u64(4); - let identity = seeded_identity(&mut rng); - let ecdsa = identity - .unified_verifying_key(SigningSchemeType::Ecdsa256k1) - .unwrap(); - + // A well-formed set survives the round trip. let good = VerfKeySet::new(BTreeMap::from([( SigningSchemeType::Ecdsa256k1, ecdsa.clone(), @@ -200,11 +178,19 @@ mod tests { // A key filed under a scheme it does not belong to. let misfiled = BTreeMap::from([(SigningSchemeType::MlDsa87, ecdsa)]); + assert!(matches!( + VerfKeySet::new(misfiled.clone()), + Err(SigningError::SchemeMismatch { .. }) + )); let bytes = bc2wrap::serialize(&misfiled).unwrap(); assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); - // An empty set, which would verify a signature against no keys at all. + // The empty set. let empty = BTreeMap::::new(); + assert!(matches!( + VerfKeySet::new(empty.clone()), + Err(SigningError::EmptySchemeSet) + )); let bytes = bc2wrap::serialize(&empty).unwrap(); assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); } From d104d6258ffa61c4e12e99156c9f7ee822638dc4 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 15:00:45 +0200 Subject: [PATCH 041/114] fix: allow empty meta data in case of rolling upgrade issue --- core/service/src/engine/storage_material_verification.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index 543edca0ac..e6edf1838d 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -542,6 +542,12 @@ fn verify_metadata_signatures( )?; } + // Metadata that names no scheme has no per-scheme entry to check, and no + // scheme set to bind a preimage to. + if signatures.is_empty() { + return Ok(()); + } + // The set is derived from the stored entries rather than requested from // outside: at boot there is no request to measure against. let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); From 534c1e6a35f31c76e891a71f23ff017dff8dbdd8 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 15:18:02 +0200 Subject: [PATCH 042/114] chore: prune --- .../cryptography/signcryption/composite_v1.rs | 3 -- .../src/cryptography/signcryption/mod.rs | 44 +------------------ 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 321513d754..10ca7dd6e3 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -48,9 +48,6 @@ impl Zeroize for CompositeEnvelope { /// `dsep ‖ schemes ‖ msg ‖ receiver_id ‖ H(receiver enc key)`, and the whole /// envelope is then encrypted to the receiver. #[cfg(feature = "non-wasm")] -#[allow(unknown_lints)] -// We allow modifying the rng before return -#[allow(non_local_effect_before_error_return)] pub fn seal( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 02959de7e1..faa7c16147 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -16,49 +16,7 @@ //! one. **A [`UnifiedSigncryption`] does not say which.** The layout follows from //! the key material a reader holds, named by [`SenderAuth`]: a single //! [`PublicSigKey`] can only open the frozen layout, and a [`VerfKeySet`] plus a -//! scheme policy can only open the multi-signature one. Those are the same -//! choice, so they are the same value, and there is nothing for a tag to -//! disagree with. -//! -//! A layout tag on the message was tried and removed: it travels outside the -//! ciphertext, so it could never be trusted over the caller's own knowledge of -//! which opener it invoked. The two parsers reject each other's output on their -//! own, and on the first field either one reads: the frozen layout writes its -//! `HybridKemCt` with bincode and the multi-signature layout safe-serializes -//! it, so neither reader gets as far as decrypting the other's envelope. Both -//! directions are checked by -//! `composite_v1::tests::the_two_formats_do_not_cross`. -//! -//! If a reader ever has to walk stored material of mixed vintage, prefer the -//! type-name header `safe_serialize` already writes *inside* the ciphertext over -//! reinstating an outer tag: an attacker cannot forge it without the decryption -//! key. -//! -//! One exception to "a signature names the schemes it was made under": the -//! frozen layout is **not** scheme-set-bound, because its preimage cannot take a -//! prefix. A reader of that layout learns the scheme only from the verification -//! key it chose. Every other layout binds the set into the signed bytes. -//! -//! The frozen layout is the original one: -//! every user-decryption ciphertext produced since 0.11 uses it, the deployed -//! browser-side verifier in `crate::client::user_decryption_wasm` parses it, and -//! because it carries no version tag of its own and is recovered by subtracting -//! two fixed-size tail fields, it cannot be made self-describing after the fact -//! either. What is locked, and by what: -//! -//! - The plaintext layout `msg ‖ sig ‖ H(sender verification key)` and the -//! signed preimage `dsep ‖ msg ‖ receiver_id ‖ H(receiver enc key)`, by -//! `ecdsa_v0::tests::ecdsa_v0_envelope_layout_is_locked`. -//! - The whole artifact, including **the order in which the RNG is drawn from**, -//! by the backward-compatibility harness: `test_unified_signcryption` in -//! `core/service/tests/backward_compatibility_kms.rs` regenerates a -//! signcryption from a seeded RNG and compares it byte-for-byte against a -//! fixture frozen at 0.13.0. Moving an RNG draw in `ecdsa_v0::inner_signcryption` -//! breaks it. -//! -//! The multi-signature layout carries one signature per scheme, for the -//! custodian-backup chain. Reading it goes through [`Unsigncrypt`] like the -//! frozen layout, on a key built with [`UnifiedUnsigncryptionKey::new_multi`]. +//! scheme policy can only open the multi-signature one. mod common; pub mod composite_v1; From 970dddafe6c3e291093083177a052692fd20d3c0 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 15:24:25 +0200 Subject: [PATCH 043/114] fix: malicious payload subtraction --- .../src/cryptography/signcryption/ecdsa_v0.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 01b7d8bac2..9d4ca01931 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -170,7 +170,19 @@ pub(crate) fn insecure_decrypt_ignoring_signature( let decrypted_plaintext = hybrid_decrypt(cipher, dec_key)?; // strip off the signature bytes (these are ignored here) - let msg_len = decrypted_plaintext.len() - DIGEST_BYTES - SIG_SIZE; + // The sender is not authenticated on this path, so the plaintext length is + // attacker-chosen and the subtraction is checked, as in `parse_msg`. + let msg_len = decrypted_plaintext + .len() + .checked_sub(DIGEST_BYTES) + .and_then(|len| len.checked_sub(SIG_SIZE)) + .ok_or_else(|| { + CryptographyError::LengthError(format!( + "Message is too short ({} bytes) to contain sig || H(server_verification_key) ({} bytes) ", + decrypted_plaintext.len(), + DIGEST_BYTES + SIG_SIZE + )) + })?; let msg = &decrypted_plaintext[..msg_len]; // LEGACY should be using safe_deserialization from tfhe-rs let signcrypted_msg: SigncryptionPayload = bc2wrap::deserialize_slice(msg) From df1f7e9d6103651f60963588aa789e6422df0548 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 15:25:50 +0200 Subject: [PATCH 044/114] fix: lint --- core/service/src/cryptography/signcryption/mod.rs | 9 ++++++--- core/service/src/cryptography/signing/verf_key_set.rs | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index faa7c16147..82cd863a07 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -16,7 +16,7 @@ //! one. **A [`UnifiedSigncryption`] does not say which.** The layout follows from //! the key material a reader holds, named by [`SenderAuth`]: a single //! [`PublicSigKey`] can only open the frozen layout, and a [`VerfKeySet`] plus a -//! scheme policy can only open the multi-signature one. +//! scheme policy can only open the multi-signature one. mod common; pub mod composite_v1; @@ -411,8 +411,11 @@ pub enum SigncryptionPayloadVersions { /// - V0 (current): Initial version with `plaintext: TypedPlaintext` and `link: Vec` /// /// ## Testing -/// - BC tests: `test_signcryption_payload` replays a fixture frozen at 0.13.0, which is -/// what locks the binary format, and verifies v0.11.x data still deserializes +/// - The BC test `test_signcryption_payload` replays the stored fixtures from 0.11.0 +/// onwards and compares each parsed value against its recorded metadata. It locks the +/// read path. Bincode writes no field tags, so a field that is added, removed, reordered +/// or retyped also breaks the replay. +/// - The fixtures are LFS objects. The test cannot run without them. /// - It MUST pass before any changes to this type // // TODO(zama-ai/tfhe-rs-internal/issues/1535) diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 5ad12ef3f4..6f62e7df81 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -16,7 +16,8 @@ const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; /// signs under several schemes, so a verifier needs several keys, and needs them /// to travel together. /// -/// This type is not versioned as it is only meant to be an in-memory type. +/// This type is in-memory only. The `impl_generic_versionize!` below gives it a +/// no-op `Versionize` so that it can sit inside a versionized struct. /// /// # Invariants /// From bce6ee2e8d9e10a20d077862a177a5784aebba01 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 15:27:27 +0200 Subject: [PATCH 045/114] refactor: reverted refactor --- core/service/src/cryptography/signatures.rs | 7 +++--- .../service/src/cryptography/signing/ecdsa.rs | 11 +++++---- .../src/cryptography/signing/identity.rs | 2 +- core/service/src/cryptography/signing/mod.rs | 23 +++++++++++++++---- core/service/src/cryptography/signing/seed.rs | 3 ++- .../src/cryptography/signing/verf_key_set.rs | 2 +- core/service/src/util/key_setup/mod.rs | 2 +- 7 files changed, 34 insertions(+), 16 deletions(-) diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index 6a09344079..7b5122246b 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -17,9 +17,10 @@ pub use crate::cryptography::signing::ecdsa::{ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ - CompositeSignature, SchemeVerfKeys, Signature, SigningError, SigningSchemeType, - SigningSchemeTypeVersions, StoredTypedSignature, StoredTypedSignatureVersions, - UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, canonical_schemes, + CompositeSignature, HasSigningScheme, SchemeVerfKeys, Signature, SigningError, + SigningSchemeType, SigningSchemeTypeVersions, StoredTypedSignature, + StoredTypedSignatureVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, + canonical_schemes, }; pub(crate) use crate::cryptography::signing::ecdsa::{ diff --git a/core/service/src/cryptography/signing/ecdsa.rs b/core/service/src/cryptography/signing/ecdsa.rs index 4c5b88580c..cbb0cf4cb9 100644 --- a/core/service/src/cryptography/signing/ecdsa.rs +++ b/core/service/src/cryptography/signing/ecdsa.rs @@ -1,6 +1,6 @@ //! ECDSA over secp256k1 signing backend. -use super::{Signature, SigningError, SigningScheme, SigningSchemeType}; +use super::{HasSigningScheme, Signature, SigningError, SigningScheme, SigningSchemeType}; use crate::anyhow_tracked; use crate::cryptography::error::CryptographyError; use crate::cryptography::internal_crypto_types::LegacySerialization; @@ -85,9 +85,10 @@ impl PublicSigKey { pub fn to_uncompressed_bytes(&self) -> Vec { self.pk.0.to_encoded_point(false).as_bytes().to_vec() } +} - /// The scheme this key belongs to: always ECDSA/secp256k1. - pub fn signing_scheme_type(&self) -> SigningSchemeType { +impl HasSigningScheme for PublicSigKey { + fn signing_scheme_type(&self) -> SigningSchemeType { SigningSchemeType::Ecdsa256k1 } } @@ -243,8 +244,10 @@ impl PrivateSigKey { })?; Ok(PrivateSigKey::new(key)) } +} - pub fn signing_scheme_type(&self) -> SigningSchemeType { +impl HasSigningScheme for PrivateSigKey { + fn signing_scheme_type(&self) -> SigningSchemeType { SigningSchemeType::Ecdsa256k1 } } diff --git a/core/service/src/cryptography/signing/identity.rs b/core/service/src/cryptography/signing/identity.rs index 930f88841e..25adb9514a 100644 --- a/core/service/src/cryptography/signing/identity.rs +++ b/core/service/src/cryptography/signing/identity.rs @@ -149,7 +149,7 @@ mod tests { use super::*; use crate::cryptography::signatures::gen_sig_keys; use crate::cryptography::signing::test_support::seeded_identity; - use crate::cryptography::signing::unified_verify; + use crate::cryptography::signing::{HasSigningScheme, unified_verify}; use aes_prng::AesRng; use rand::SeedableRng; use strum::IntoEnumIterator; diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index ea3311537b..e156d40240 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -114,6 +114,11 @@ pub enum SigningError { }, } +/// Trait for any value that is tied to a concrete signature scheme. +pub trait HasSigningScheme { + fn signing_scheme_type(&self) -> SigningSchemeType; +} + #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize, VersionsDispatch)] pub enum SigningSchemeTypeVersions { V0(SigningSchemeType), @@ -322,6 +327,12 @@ impl Signature { } } +impl HasSigningScheme for Signature { + fn signing_scheme_type(&self) -> SigningSchemeType { + self.scheme + } +} + /// A signing key tagged with the scheme it belongs to. /// Large types are boxed so the enum remains small to move. pub enum UnifiedPrivateSigKey { @@ -332,9 +343,8 @@ pub enum UnifiedPrivateSigKey { MlDsa87(Box>), } -impl UnifiedPrivateSigKey { - /// The scheme this key belongs to. - pub fn signing_scheme_type(&self) -> SigningSchemeType { +impl HasSigningScheme for UnifiedPrivateSigKey { + fn signing_scheme_type(&self) -> SigningSchemeType { match self { UnifiedPrivateSigKey::Ecdsa256k1(_) => SigningSchemeType::Ecdsa256k1, UnifiedPrivateSigKey::Ed25519(_) => SigningSchemeType::Ed25519, @@ -343,7 +353,9 @@ impl UnifiedPrivateSigKey { UnifiedPrivateSigKey::MlDsa87(_) => SigningSchemeType::MlDsa87, } } +} +impl UnifiedPrivateSigKey { /// Derive the matching public verification key, when the scheme supports it. /// /// Currently always `Ok` (every supported scheme can derive it); fallible so @@ -478,9 +490,10 @@ impl UnifiedPublicSigKey { other => format!("0x{}", hex::encode(other.digest())), } } +} - /// The scheme this key belongs to. - pub fn signing_scheme_type(&self) -> SigningSchemeType { +impl HasSigningScheme for UnifiedPublicSigKey { + fn signing_scheme_type(&self) -> SigningSchemeType { match self { UnifiedPublicSigKey::Ecdsa256k1(_) => SigningSchemeType::Ecdsa256k1, UnifiedPublicSigKey::Ed25519(_) => SigningSchemeType::Ed25519, diff --git a/core/service/src/cryptography/signing/seed.rs b/core/service/src/cryptography/signing/seed.rs index bf22e9d47d..0e0209f0cc 100644 --- a/core/service/src/cryptography/signing/seed.rs +++ b/core/service/src/cryptography/signing/seed.rs @@ -36,6 +36,7 @@ use super::{ DSEP_SIGKEY_DERIVE, SIGKEY_DERIVATION_VERSION, SigningError, SigningSchemeType, UnifiedPrivateSigKey, UnifiedPublicSigKey, }; +use crate::cryptography::signing::HasSigningScheme; use crate::impl_generic_versionize; use hashing::{DIGEST_BYTES, hash_element}; use ml_dsa::{MlDsa44, MlDsa65, MlDsa87}; @@ -313,7 +314,7 @@ impl Zeroize for DerivedKeyCache { mod tests { use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; - use crate::cryptography::signing::{unified_sign, unified_verify}; + use crate::cryptography::signing::{HasSigningScheme, unified_sign, unified_verify}; use aes_prng::AesRng; use hashing::DomainSep; use rand::SeedableRng; diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 6f62e7df81..9ec4d3df51 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -1,7 +1,7 @@ //! The verification keys one party publishes, one per signature scheme. use super::identity::NodeSigningIdentity; -use super::{SigningError, SigningSchemeType, UnifiedPublicSigKey}; +use super::{HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey}; use crate::impl_generic_versionize; use hashing::{DomainSep, hash_element}; use serde::{Deserialize, Deserializer, Serialize}; diff --git a/core/service/src/util/key_setup/mod.rs b/core/service/src/util/key_setup/mod.rs index f4beabe0ff..abecc06a4f 100644 --- a/core/service/src/util/key_setup/mod.rs +++ b/core/service/src/util/key_setup/mod.rs @@ -1716,7 +1716,7 @@ mod tests { use crate::cryptography::signatures::{PrivateSigKey, PublicSigKey, gen_sig_keys}; use crate::cryptography::signing::identity::NodeSigningIdentity; use crate::cryptography::signing::seed::RootSigningSeed; - use crate::cryptography::signing::{SigningSchemeType, unified_verify}; + use crate::cryptography::signing::{HasSigningScheme, SigningSchemeType, unified_verify}; use crate::engine::threshold::service::epoch_manager::EpochData; use crate::util::key_setup::{ all_verf_material_slots, delete_all_verf_material, non_legacy_verf_material_slots, From d735005d5ad2cfb77fbee5d572d60ddf3acc3848 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 15:32:38 +0200 Subject: [PATCH 046/114] chore: updated backwards --- .../generate-v0.15.0/Cargo.lock | 24 +++++++++---------- .../generate-v0.15.0/Cargo.toml | 16 ++++++------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/backward-compatibility/generate-v0.15.0/Cargo.lock b/backward-compatibility/generate-v0.15.0/Cargo.lock index 9799e0076c..3012cdc559 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.lock +++ b/backward-compatibility/generate-v0.15.0/Cargo.lock @@ -1832,7 +1832,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bc2wrap" version = "2.0.1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "bincode 2.0.1", "serde", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "error-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "anyhow", "tracing", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "kms" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "aes 0.9.3", "aes-gcm 0.11.1", @@ -4181,7 +4181,7 @@ dependencies = [ [[package]] name = "kms-grpc" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -4633,7 +4633,7 @@ dependencies = [ [[package]] name = "observability" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "anyhow", "axum", @@ -6811,7 +6811,7 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "anyhow", "bc2wrap", @@ -6987,7 +6987,7 @@ dependencies = [ [[package]] name = "thread-handles" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "anyhow", "error-utils", @@ -7017,7 +7017,7 @@ dependencies = [ [[package]] name = "threshold-algebra" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "anyhow", "error-utils", @@ -7038,7 +7038,7 @@ dependencies = [ [[package]] name = "threshold-execution" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "aes 0.9.3", "aes-prng", @@ -7080,7 +7080,7 @@ dependencies = [ [[package]] name = "threshold-hashing" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "anyhow", "bc2wrap", @@ -7093,7 +7093,7 @@ dependencies = [ [[package]] name = "threshold-networking" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "anyhow", "async-trait", @@ -7130,7 +7130,7 @@ dependencies = [ [[package]] name = "threshold-types" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=3359709168a15cf9dbb7a13035e04e8be828d130#3359709168a15cf9dbb7a13035e04e8be828d130" +source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" dependencies = [ "anyhow", "async-trait", diff --git a/backward-compatibility/generate-v0.15.0/Cargo.toml b/backward-compatibility/generate-v0.15.0/Cargo.toml index b78f2980ef..639d65b2f7 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.toml +++ b/backward-compatibility/generate-v0.15.0/Cargo.toml @@ -9,7 +9,7 @@ license = "BSD-3-Clause-Clear" [dependencies] # Import the base backward-compatibility crate for shared types and utilities backward-compatibility = { path = ".." } -bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } +bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } # NOTE: Some dependencies below are duplicated from ../Cargo.toml # This is intentional - these crates are excluded from the workspace for isolation. @@ -19,13 +19,13 @@ bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev # NOTE: v0.15.0 is not tagged yet, so we pin a commit rev on `main`. Bump this rev to a # commit that contains every type this generator serializes, otherwise the generator will # not compile. -kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } -kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } -algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } -threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130", features = ["testing"] } -threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } -threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } -hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "3359709168a15cf9dbb7a13035e04e8be828d130" } +kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } +kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } +algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } +threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01", features = ["testing"] } +threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } +threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } +hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } #error-utils_0_15_0 = { path = "./core/error-utils", default-features = false } #thread-handles_0_15_0 = { path = "./core/thread-handles" } From 9e9a02ca74fed3749077c989c1246d14dc0c5909 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 15:34:44 +0200 Subject: [PATCH 047/114] fix: added versioning --- .../src/cryptography/signing/composite.rs | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 17ed089b5d..68ca9d2314 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -9,9 +9,9 @@ use super::identity::NodeSigningIdentity; use super::typed_signature::StoredTypedSignature; use super::verf_key_set::VerfKeySet; use super::{Signature, SigningError, SigningSchemeType, unified_verify}; -use crate::impl_generic_versionize; use hashing::DomainSep; use serde::{Deserialize, Serialize}; +use tfhe_versionable::{Unversionize, UnversionizeError, Versionize, VersionizeOwned}; /// Sort `schemes` into canonical order and drop duplicates. /// @@ -83,12 +83,42 @@ fn ensure_canonical(schemes: &[SigningSchemeType]) -> Result<(), SigningError> { /// /// Entries are ordered by scheme and carry no duplicate scheme. /// -/// The [`Deserialize`] impl *rejects* a non-canonical list rather than sorting it. +/// Reading one back *rejects* a non-canonical list rather than sorting it, on +/// the serde path and on the versioned path alike. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(transparent)] pub struct CompositeSignature(Vec); -impl_generic_versionize!(CompositeSignature); +// Versioned by hand rather than by `#[derive(Versionize)]`. +// +// The versioned form is the one `Vec` already has, so +// every entry carries its own version dispatch. +impl Versionize for CompositeSignature { + type Versioned<'vers> + = as Versionize>::Versioned<'vers> + where + Self: 'vers; + + fn versionize(&self) -> Self::Versioned<'_> { + self.0.versionize() + } +} + +impl VersionizeOwned for CompositeSignature { + type VersionedOwned = as VersionizeOwned>::VersionedOwned; + + fn versionize_owned(self) -> Self::VersionedOwned { + self.0.versionize_owned() + } +} + +impl Unversionize for CompositeSignature { + fn unversionize(versioned: Self::VersionedOwned) -> Result { + let entries = as Unversionize>::unversionize(versioned)?; + Self::from_canonical(entries) + .map_err(|error| UnversionizeError::conversion("CompositeSignature", error)) + } +} impl<'de> Deserialize<'de> for CompositeSignature { fn deserialize(deserializer: D) -> Result @@ -305,12 +335,26 @@ mod tests { let mut reversed = entries.clone(); reversed.reverse(); - assert!(CompositeSignature::from_canonical(reversed).is_err()); + assert!(CompositeSignature::from_canonical(reversed.clone()).is_err()); let duplicated = vec![entries[0].clone(), entries[0].clone()]; assert!(CompositeSignature::from_canonical(duplicated).is_err()); assert!(CompositeSignature::from_canonical(Vec::new()).is_err()); + + // The versioned path validates too. `Unversionize` is hand-written + // precisely so that a reversed list placed in storage does not read back + // as a valid signature. + assert!(matches!( + CompositeSignature::unversionize(reversed.versionize_owned()), + Err(UnversionizeError::Conversion { .. }) + )); + + // ...while the canonical one survives the round trip unchanged. + assert_eq!( + CompositeSignature::unversionize(sig.clone().versionize_owned()).unwrap(), + sig + ); } /// Every constituent signature has to verify; one bad one fails the whole. From c4ab2b9ff32b11f36f64fdc7cf9c949544c99311 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 16:02:31 +0200 Subject: [PATCH 048/114] fix: wasm visibility --- .../cryptography/signcryption/composite_v1.rs | 16 +++++++++++----- .../src/cryptography/signing/composite.rs | 2 +- core/service/src/cryptography/signing/seed.rs | 1 + 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 10ca7dd6e3..14bd75fc3d 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -1,20 +1,26 @@ //! The composite signcryption envelope. use super::UnifiedSigncryption; -use super::common::{hybrid_decrypt, hybrid_encrypt, receiver_binding}; +#[cfg(feature = "non-wasm")] +use super::common::hybrid_encrypt; +use super::common::{hybrid_decrypt, receiver_binding}; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; -use crate::cryptography::signatures::{ - CompositeSignature, NodeSigningIdentity, SigningSchemeType, VerfKeySet, -}; +use crate::cryptography::signatures::{CompositeSignature, VerfKeySet}; +#[cfg(feature = "non-wasm")] +use crate::cryptography::signatures::{NodeSigningIdentity, SigningSchemeType}; +#[cfg(feature = "non-wasm")] use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; +#[cfg(feature = "non-wasm")] use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use tfhe::named::Named; -use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; +use tfhe::safe_serialization::safe_deserialize; +#[cfg(feature = "non-wasm")] +use tfhe::safe_serialization::safe_serialize; use tfhe_versionable::{Versionize, VersionsDispatch}; use zeroize::{Zeroize, Zeroizing}; diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 68ca9d2314..9423ac3348 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -4,7 +4,7 @@ //! its parts is present. Dropping the post-quantum half of an ECDSA+ML-DSA pair //! must not leave something a verifier accepts, or the hedge the composite was //! built for is gone. -//! +#[cfg(feature = "non-wasm")] use super::identity::NodeSigningIdentity; use super::typed_signature::StoredTypedSignature; use super::verf_key_set::VerfKeySet; diff --git a/core/service/src/cryptography/signing/seed.rs b/core/service/src/cryptography/signing/seed.rs index 0e0209f0cc..81d969a7ae 100644 --- a/core/service/src/cryptography/signing/seed.rs +++ b/core/service/src/cryptography/signing/seed.rs @@ -36,6 +36,7 @@ use super::{ DSEP_SIGKEY_DERIVE, SIGKEY_DERIVATION_VERSION, SigningError, SigningSchemeType, UnifiedPrivateSigKey, UnifiedPublicSigKey, }; +#[cfg(feature = "non-wasm")] use crate::cryptography::signing::HasSigningScheme; use crate::impl_generic_versionize; use hashing::{DIGEST_BYTES, hash_element}; From c111547cc9b4716ee4ee7310da23d95d9d37afb9 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 16:10:10 +0200 Subject: [PATCH 049/114] chore: remove compositeSignature --- core/service/src/cryptography/signatures.rs | 2 +- .../cryptography/signcryption/composite_v1.rs | 13 +- .../src/cryptography/signing/composite.rs | 280 ++++++------------ core/service/src/cryptography/signing/mod.rs | 5 +- 4 files changed, 103 insertions(+), 197 deletions(-) diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index 7b5122246b..d1cf56d84c 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -17,7 +17,7 @@ pub use crate::cryptography::signing::ecdsa::{ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ - CompositeSignature, HasSigningScheme, SchemeVerfKeys, Signature, SigningError, + HasSigningScheme, SchemeVerfKeys, Signature, SigningError, SigningSchemeType, SigningSchemeTypeVersions, StoredTypedSignature, StoredTypedSignatureVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, canonical_schemes, diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 14bd75fc3d..8317de83d5 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -8,9 +8,12 @@ use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; -use crate::cryptography::signatures::{CompositeSignature, VerfKeySet}; +use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signatures::{NodeSigningIdentity, SigningSchemeType}; +use crate::cryptography::signing::composite::verify_uniform; +#[cfg(feature = "non-wasm")] +use crate::cryptography::signing::composite::sign_uniform; #[cfg(feature = "non-wasm")] use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; @@ -34,7 +37,7 @@ pub enum CompositeEnvelopeVersions { #[versionize(CompositeEnvelopeVersions)] pub struct CompositeEnvelope { pub msg: Vec, - pub signature: CompositeSignature, + pub signature: Vec, } impl Named for CompositeEnvelope { @@ -65,7 +68,7 @@ pub fn seal( ) -> Result { let binding = receiver_binding(receiver_id, receiver_enc_key)?; let signed = Zeroizing::new([msg, binding.as_slice()].concat()); - let signature = CompositeSignature::sign_uniform(identity, schemes, dsep, &signed)?; + let signature = sign_uniform(identity, schemes, dsep, &signed)?; let mut envelope = CompositeEnvelope { msg: msg.to_vec(), @@ -117,9 +120,7 @@ pub(super) fn open( let binding = receiver_binding(receiver_id, encryption_key)?; let signed = Zeroizing::new([msg.as_slice(), binding.as_slice()].concat()); - envelope - .signature - .verify_uniform(sender_keys, dsep, &signed) + verify_uniform(&envelope.signature, sender_keys, dsep, &signed) .map_err(|e| CryptographyError::VerificationError(e.to_string()))?; Ok(msg) diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 9423ac3348..11905b4854 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -10,8 +10,6 @@ use super::typed_signature::StoredTypedSignature; use super::verf_key_set::VerfKeySet; use super::{Signature, SigningError, SigningSchemeType, unified_verify}; use hashing::DomainSep; -use serde::{Deserialize, Serialize}; -use tfhe_versionable::{Unversionize, UnversionizeError, Versionize, VersionizeOwned}; /// Sort `schemes` into canonical order and drop duplicates. /// @@ -66,146 +64,69 @@ pub fn scheme_bound_preimage( Ok([canonical_scheme_bytes(&schemes).as_slice(), msg].concat()) } -/// Accept `schemes` only if it is already canonical. -fn ensure_canonical(schemes: &[SigningSchemeType]) -> Result<(), SigningError> { - let canonical = canonical_schemes(schemes)?; - if canonical != schemes { - return Err(SigningError::NonCanonicalSchemeSet(format!( - "expected {}, got {}", - render_schemes(&canonical), - render_schemes(schemes) - ))); - } - Ok(()) +/// The schemes `entries` were made under, in the order they are stored. +pub fn entry_schemes(entries: &[StoredTypedSignature]) -> Vec { + entries.iter().map(|entry| entry.scheme).collect() } -/// One signature per scheme, over a message that commits to the scheme set. +/// Sign `msg` under every scheme in `schemes`, each over the same bytes. /// -/// Entries are ordered by scheme and carry no duplicate scheme. -/// -/// Reading one back *rejects* a non-canonical list rather than sorting it, on -/// the serde path and on the versioned path alike. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(transparent)] -pub struct CompositeSignature(Vec); - -// Versioned by hand rather than by `#[derive(Versionize)]`. -// -// The versioned form is the one `Vec` already has, so -// every entry carries its own version dispatch. -impl Versionize for CompositeSignature { - type Versioned<'vers> - = as Versionize>::Versioned<'vers> - where - Self: 'vers; - - fn versionize(&self) -> Self::Versioned<'_> { - self.0.versionize() - } -} - -impl VersionizeOwned for CompositeSignature { - type VersionedOwned = as VersionizeOwned>::VersionedOwned; - - fn versionize_owned(self) -> Self::VersionedOwned { - self.0.versionize_owned() - } -} - -impl Unversionize for CompositeSignature { - fn unversionize(versioned: Self::VersionedOwned) -> Result { - let entries = as Unversionize>::unversionize(versioned)?; - Self::from_canonical(entries) - .map_err(|error| UnversionizeError::conversion("CompositeSignature", error)) - } -} - -impl<'de> Deserialize<'de> for CompositeSignature { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let entries = Vec::::deserialize(deserializer)?; - Self::from_canonical(entries).map_err(serde::de::Error::custom) - } +/// The entries come back ordered by scheme, with no duplicate scheme, which is +/// the shape [`verify_uniform`] requires. +#[cfg(feature = "non-wasm")] +pub fn sign_uniform( + identity: &NodeSigningIdentity, + schemes: &[SigningSchemeType], + dsep: &DomainSep, + msg: &[u8], +) -> Result, SigningError> { + let schemes = canonical_schemes(schemes)?; + identity.ensure_supported(&schemes)?; + let preimage = scheme_bound_preimage(&schemes, msg)?; + schemes + .iter() + .map(|&scheme| { + identity + .unified_sign_with(scheme, dsep, &preimage) + .map(|signature| StoredTypedSignature { + scheme, + signature: signature.to_bytes(), + }) + }) + .collect() } -impl CompositeSignature { - /// Accept `entries` only if they are already ordered by scheme, carry no - /// duplicate scheme, and are non-empty. - pub fn from_canonical(entries: Vec) -> Result { - let schemes = entries.iter().map(|entry| entry.scheme).collect::>(); - ensure_canonical(&schemes)?; - Ok(Self(entries)) - } - - /// The entries, ordered by scheme. - pub fn entries(&self) -> &[StoredTypedSignature] { - &self.0 - } - - /// Sign `msg` under every scheme in `schemes`, each over the same bytes. - #[cfg(feature = "non-wasm")] - pub fn sign_uniform( - identity: &NodeSigningIdentity, - schemes: &[SigningSchemeType], - dsep: &DomainSep, - msg: &[u8], - ) -> Result { - let schemes = canonical_schemes(schemes)?; - identity.ensure_supported(&schemes)?; - let preimage = scheme_bound_preimage(&schemes, msg)?; - let entries = schemes - .iter() - .map(|&scheme| { - identity - .unified_sign_with(scheme, dsep, &preimage) - .map(|signature| StoredTypedSignature { - scheme, - signature: signature.to_bytes(), - }) - }) - .collect::, _>>()?; - // `schemes` is canonical, so the entries built from it are too. - Ok(Self(entries)) - } - - /// Check every signature against `keys`, having first checked that this - /// signature was made under exactly the schemes `keys` holds keys for. - /// - /// Every signature must verify. - pub fn verify_uniform( - &self, - keys: &VerfKeySet, - dsep: &DomainSep, - msg: &[u8], - ) -> Result<(), SigningError> { - let expected = keys.schemes(); - // The scheme-set comparison happens before any cryptography, - // so a composite signature presented with one of its parts removed is - // rejected for being the wrong shape - let schemes = self.schemes(); - if schemes != expected { - return Err(SigningError::UnexpectedSchemeSet { - expected: render_schemes(&expected), - actual: render_schemes(&schemes), - }); - } - let preimage = scheme_bound_preimage(&schemes, msg)?; - for entry in &self.0 { - let signature = Signature::new(entry.scheme, entry.signature.clone()); - // Cannot fail: `schemes` equals `keys.schemes()` on this path. - unified_verify(dsep, &preimage, &signature, keys.require(entry.scheme)?)?; - } - Ok(()) +/// Check every signature in `entries` against `keys`, having first checked that +/// they were made under exactly the schemes `keys` holds keys for. +/// +/// Every signature must verify. `entries` is untrusted: it may come straight +/// from storage or from the network, so its shape is checked here rather than +/// assumed. +pub fn verify_uniform( + entries: &[StoredTypedSignature], + keys: &VerfKeySet, + dsep: &DomainSep, + msg: &[u8], +) -> Result<(), SigningError> { + let expected = keys.schemes(); + // The scheme-set comparison happens before any cryptography, so a composite + // signature presented with one of its parts removed, reordered or repeated + // is rejected for being the wrong shape. `expected` is canonical and + // non-empty by the `VerfKeySet` invariants. + let schemes = entry_schemes(entries); + if schemes != expected { + return Err(SigningError::UnexpectedSchemeSet { + expected: render_schemes(&expected), + actual: render_schemes(&schemes), + }); } - - /// The schemes this signature was made under, derived from its entries. - /// - /// Canonical by construction: every constructor validates the entry order. - pub fn schemes(&self) -> Vec { - self.0.iter().map(|entry| entry.scheme).collect() + let preimage = scheme_bound_preimage(&schemes, msg)?; + for entry in entries { + let signature = Signature::new(entry.scheme, entry.signature.clone()); + // Cannot fail: `schemes` equals `keys.schemes()` on this path. + unified_verify(dsep, &preimage, &signature, keys.require(entry.scheme)?)?; } + Ok(()) } /// The per-scheme signatures of a *result*: a keygen, CRS, preprocessing or @@ -283,12 +204,12 @@ mod tests { #[test] fn round_trip_sunshine() { let (identity, keys, schemes) = setup(1); - let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - assert_eq!(sig.schemes(), schemes); - sig.verify_uniform(&keys, DSEP, MSG).unwrap(); + let sig = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + assert_eq!(entry_schemes(&sig), schemes); + verify_uniform(&sig, &keys, DSEP, MSG).unwrap(); let (_, other_keys, _) = setup(7); - assert!(sig.verify_uniform(&other_keys, DSEP, MSG).is_err()); + assert!(verify_uniform(&sig, &other_keys, DSEP, MSG).is_err()); } /// Removing a signature must not leave something that verifies under the @@ -297,14 +218,14 @@ mod tests { #[test] fn a_stripped_signature_is_rejected() { let (identity, keys, schemes) = setup(2); - let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + let sig = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); // Drop the ML-DSA half and relabel the set as ECDSA-only - let stripped = CompositeSignature::from_canonical(vec![sig.entries()[0].clone()]).unwrap(); + let stripped = vec![sig[0].clone()]; // Against the original policy it is the wrong scheme set... assert!(matches!( - stripped.verify_uniform(&keys, DSEP, MSG), + verify_uniform(&stripped, &keys, DSEP, MSG), Err(SigningError::UnexpectedSchemeSet { .. }) )); @@ -314,61 +235,53 @@ mod tests { // *pair*, which does not match the single-scheme preimage. let ecdsa_only = VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); - assert!(stripped.verify_uniform(&ecdsa_only, DSEP, MSG).is_err()); + assert!(verify_uniform(&stripped, &ecdsa_only, DSEP, MSG).is_err()); // The same mismatch from the other side: the *whole* pair signature // against that ECDSA-only key set is refused for its shape rather than // verified on the one entry the set holds a key for. assert!(matches!( - sig.verify_uniform(&ecdsa_only, DSEP, MSG), + verify_uniform(&sig, &ecdsa_only, DSEP, MSG), Err(SigningError::UnexpectedSchemeSet { .. }) )); } - /// A list that is not ordered by scheme, or repeats one, is refused on - /// deserialization rather than normalised. + /// An entry list that is reordered, repeats a scheme, or is empty is refused. #[test] fn a_non_canonical_entry_list_is_rejected() { - let (identity, _keys, schemes) = setup(3); - let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - let entries = sig.entries().to_vec(); + let (identity, keys, schemes) = setup(3); + let sig = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - let mut reversed = entries.clone(); + let mut reversed = sig.clone(); reversed.reverse(); - assert!(CompositeSignature::from_canonical(reversed.clone()).is_err()); - - let duplicated = vec![entries[0].clone(), entries[0].clone()]; - assert!(CompositeSignature::from_canonical(duplicated).is_err()); + let duplicated = vec![sig[0].clone(), sig[0].clone()]; - assert!(CompositeSignature::from_canonical(Vec::new()).is_err()); - - // The versioned path validates too. `Unversionize` is hand-written - // precisely so that a reversed list placed in storage does not read back - // as a valid signature. - assert!(matches!( - CompositeSignature::unversionize(reversed.versionize_owned()), - Err(UnversionizeError::Conversion { .. }) - )); - - // ...while the canonical one survives the round trip unchanged. - assert_eq!( - CompositeSignature::unversionize(sig.clone().versionize_owned()).unwrap(), - sig - ); + for (case, entries) in [ + ("reversed", reversed), + ("duplicated", duplicated), + ("empty", Vec::new()), + ] { + assert!( + matches!( + verify_uniform(&entries, &keys, DSEP, MSG), + Err(SigningError::UnexpectedSchemeSet { .. }) + ), + "a {case} entry list was not rejected" + ); + } } /// Every constituent signature has to verify; one bad one fails the whole. #[test] fn one_tampered_signature_fails_the_composite() { let (identity, keys, schemes) = setup(4); - let base = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + let base = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - for index in 0..base.entries().len() { - let mut entries = base.entries().to_vec(); - entries[index].signature[0] ^= 0x01; - let tampered = CompositeSignature::from_canonical(entries).unwrap(); + for index in 0..base.len() { + let mut tampered = base.clone(); + tampered[index].signature[0] ^= 0x01; assert!( - tampered.verify_uniform(&keys, DSEP, MSG).is_err(), + verify_uniform(&tampered, &keys, DSEP, MSG).is_err(), "tampering with signature {index} was not detected" ); } @@ -377,12 +290,9 @@ mod tests { #[test] fn a_tampered_message_or_dsep_fails() { let (identity, keys, schemes) = setup(5); - let sig = CompositeSignature::sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - assert!( - sig.verify_uniform(&keys, DSEP, b"a different message") - .is_err() - ); - assert!(sig.verify_uniform(&keys, b"OTHERDSP", MSG).is_err()); + let sig = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + assert!(verify_uniform(&sig, &keys, DSEP, b"a different message").is_err()); + assert!(verify_uniform(&sig, &keys, b"OTHERDSP", MSG).is_err()); } /// An identity with no root seed can only do ECDSA, so asking it for the @@ -392,7 +302,7 @@ mod tests { let mut rng = AesRng::seed_from_u64(9); let identity = NodeSigningIdentity::ecdsa_only(gen_sig_keys(&mut rng).1); assert!(matches!( - CompositeSignature::sign_uniform(&identity, &pair(), DSEP, MSG), + sign_uniform(&identity, &pair(), DSEP, MSG), Err(SigningError::MissingRootSeed(_)) )); } @@ -434,7 +344,7 @@ mod tests { } } - /// Result entries use the same ordering convention as [`CompositeSignature`]: + /// Result entries use the same ordering convention as [`sign_uniform`]: /// by scheme, duplicate-free, whatever order the request arrived in. #[test] fn result_entries_are_ordered_by_scheme() { @@ -455,12 +365,10 @@ mod tests { entries.iter().map(|e| e.scheme).collect::>(), canonical ); - // ...and that is exactly the list `CompositeSignature` would accept. - CompositeSignature::from_canonical(entries).unwrap(); } /// No schemes requested means no per-scheme entries, which is why this - /// returns a plain list rather than a `CompositeSignature`. + /// returns a plain list of entries. #[test] fn result_entries_tolerate_an_empty_request() { let mut rng = AesRng::seed_from_u64(21); diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index e156d40240..235ce50de4 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -12,7 +12,7 @@ pub mod seed; pub mod typed_signature; pub mod verf_key_set; -pub use composite::{CompositeSignature, canonical_schemes}; +pub use composite::canonical_schemes; pub use typed_signature::{StoredTypedSignature, StoredTypedSignatureVersions}; pub use verf_key_set::VerfKeySet; @@ -100,9 +100,6 @@ pub enum SigningError { /// verified" vacuously true. #[error("a signing scheme set must name at least one scheme")] EmptySchemeSet, - /// A set of signing schemes was not in its canonical encoding. - #[error("signing scheme set is not canonical: {0}")] - NonCanonicalSchemeSet(String), /// A composite signature carried a different set of schemes than the /// verifier required. #[error("signature was made under schemes {actual}, but {expected} were required")] From dddf827591e55a920cabac3702bdb1862dc201cc Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 24 Sep 2026 16:31:55 +0200 Subject: [PATCH 050/114] feat: versioning of VerfKeySet --- core/service/src/cryptography/signatures.rs | 7 +- .../cryptography/signcryption/composite_v1.rs | 4 +- .../src/cryptography/signing/verf_key_set.rs | 106 +++++++++++++++++- core/service/tests/versioned_enum_coverage.rs | 4 + 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/core/service/src/cryptography/signatures.rs b/core/service/src/cryptography/signatures.rs index d1cf56d84c..5d7f742854 100644 --- a/core/service/src/cryptography/signatures.rs +++ b/core/service/src/cryptography/signatures.rs @@ -17,10 +17,9 @@ pub use crate::cryptography::signing::ecdsa::{ pub use crate::cryptography::signing::identity::NodeSigningIdentity; pub use crate::cryptography::signing::seed::{RootSigningSeed, RootSigningSeedVersions}; pub use crate::cryptography::signing::{ - HasSigningScheme, SchemeVerfKeys, Signature, SigningError, - SigningSchemeType, SigningSchemeTypeVersions, StoredTypedSignature, - StoredTypedSignatureVersions, UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, - canonical_schemes, + HasSigningScheme, SchemeVerfKeys, Signature, SigningError, SigningSchemeType, + SigningSchemeTypeVersions, StoredTypedSignature, StoredTypedSignatureVersions, + UnifiedPublicSigKey, UnifiedPublicSigKeyVersions, VerfKeySet, canonical_schemes, }; pub(crate) use crate::cryptography::signing::ecdsa::{ diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 8317de83d5..6a4d2d251a 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -8,12 +8,12 @@ use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; -use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signatures::{NodeSigningIdentity, SigningSchemeType}; -use crate::cryptography::signing::composite::verify_uniform; +use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signing::composite::sign_uniform; +use crate::cryptography::signing::composite::verify_uniform; #[cfg(feature = "non-wasm")] use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 9ec4d3df51..cfbca686cc 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -2,10 +2,14 @@ use super::identity::NodeSigningIdentity; use super::{HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey}; -use crate::impl_generic_versionize; use hashing::{DomainSep, hash_element}; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; +use tfhe::named::Named; +use tfhe_versionable::{ + Unversionize, UnversionizeError, Version, Versionize, VersionizeOwned, VersionsDispatch, + derived_traits::VersionsDispatch as VersionsDispatchTrait, +}; /// Domain separator for the digest that identifies a whole verification-key set. const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; @@ -16,8 +20,10 @@ const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; /// signs under several schemes, so a verifier needs several keys, and needs them /// to travel together. /// -/// This type is in-memory only. The `impl_generic_versionize!` below gives it a -/// no-op `Versionize` so that it can sit inside a versionized struct. +/// The set is persisted, so it is versioned. Its own shape is tagged by +/// [`VerfKeySetVersions`], and the scheme tags and keys inside it each carry +/// their own dispatch, so a later change to [`UnifiedPublicSigKey`] is handled +/// by `UnifiedPublicSigKeyVersions` without a new version here. /// /// # Invariants /// @@ -29,7 +35,83 @@ pub struct VerfKeySet { keys: BTreeMap, } -impl_generic_versionize!(VerfKeySet); +impl Named for VerfKeySet { + const NAME: &'static str = "VerfKeySet"; +} + +/// The borrowed versioned form of [`VerfKeySet`]. +#[derive(Serialize)] +pub struct VerfKeySetRef<'vers>( + as Versionize>::Versioned<'vers>, +); + +impl<'vers> From<&'vers VerfKeySet> for VerfKeySetRef<'vers> { + fn from(value: &'vers VerfKeySet) -> Self { + Self(value.keys.versionize()) + } +} + +/// The owned versioned form of [`VerfKeySet`]. +#[derive(Serialize, Deserialize)] +pub struct VerfKeySetOwned( + as VersionizeOwned>::VersionedOwned, +); + +impl From for VerfKeySetOwned { + fn from(value: VerfKeySet) -> Self { + Self(value.keys.versionize_owned()) + } +} + +/// Reading a set back re-establishes the invariants. +/// +/// This is the whole reason the versionable traits are written out rather than +/// derived: the derive converts the mirror type straight back into the struct, +/// so the validating [`Deserialize`] impl below would not run on the versioned +/// path. +impl TryFrom for VerfKeySet { + type Error = UnversionizeError; + + fn try_from(versioned: VerfKeySetOwned) -> Result { + let keys = + as Unversionize>::unversionize( + versioned.0, + )?; + Self::new(keys).map_err(|error| UnversionizeError::conversion("VerfKeySet", error)) + } +} + +impl Version for VerfKeySet { + type Ref<'vers> = VerfKeySetRef<'vers>; + type Owned = VerfKeySetOwned; +} + +#[derive(VersionsDispatch)] +pub enum VerfKeySetVersions { + V0(VerfKeySet), +} + +impl Versionize for VerfKeySet { + type Versioned<'vers> = >::Ref<'vers>; + + fn versionize(&self) -> Self::Versioned<'_> { + self.into() + } +} + +impl VersionizeOwned for VerfKeySet { + type VersionedOwned = >::Owned; + + fn versionize_owned(self) -> Self::VersionedOwned { + self.into() + } +} + +impl Unversionize for VerfKeySet { + fn unversionize(versioned: Self::VersionedOwned) -> Result { + versioned.try_into() + } +} impl<'de> Deserialize<'de> for VerfKeySet { fn deserialize(deserializer: D) -> Result @@ -186,7 +268,8 @@ mod tests { let bytes = bc2wrap::serialize(&misfiled).unwrap(); assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); - // The empty set. + // The empty set, which would make `verify_uniform` accept a signature + // list having checked nothing. let empty = BTreeMap::::new(); assert!(matches!( VerfKeySet::new(empty.clone()), @@ -194,6 +277,19 @@ mod tests { )); let bytes = bc2wrap::serialize(&empty).unwrap(); assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); + + // The versioned path re-checks both invariants as well. + assert_eq!( + VerfKeySet::unversionize(good.clone().versionize_owned()).unwrap(), + good + ); + for bad in [misfiled, empty] { + let smuggled = VerfKeySetOwned(bad.versionize_owned()); + assert!(matches!( + VerfKeySet::try_from(smuggled), + Err(UnversionizeError::Conversion { .. }) + )); + } } /// The id names the whole set: swapping any single member key changes it. diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index 994dabaaac..f911426fba 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -70,6 +70,10 @@ const ALLOW_UNCOVERED: &[&str] = &[ // Plaintext that composite_v1::seal safe_serializes into the // UnifiedSigncryption payload. "CompositeEnvelope", + // TODO(zama-ai/kms-internal#3168): needs its own fixture once the follow-up + // actually writes a key set to storage. The generator pins a rev, so the + // type has to reach main before a fixture can be produced for it. + "VerfKeySet", // Field of RecoveryValidationMaterial.payload. // Covered via RecoveryValidationMaterialTest. "RecoveryValidationMaterialPayload", From 2ff0f2dd8cd1512f79769357be40287b1abe260f Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 10:07:33 +0200 Subject: [PATCH 051/114] chore: ietf comp incremental --- ai-docs/ARCHITECTURE.md | 4 +- .../src/cryptography/signcryption/common.rs | 33 ----- .../cryptography/signcryption/composite_v1.rs | 35 +++-- .../src/cryptography/signcryption/ecdsa_v0.rs | 31 +++- .../src/cryptography/signing/composite.rs | 137 ++++++++++++++++-- .../engine/storage_material_verification.rs | 3 +- core/service/src/engine/validation_wasm.rs | 6 +- 7 files changed, 183 insertions(+), 66 deletions(-) diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index ab8ddef9ce..21939d27da 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -591,8 +591,8 @@ Legacy metadata has no digest, so its public objects receive a raw presence chec with a stored domain reconstruct their signed Solidity payload and must recover the node's signing address. Older metadata versions upgrade with no domain and stay unverifiable. The entries of the other schemes sign the serialized result payload (`keygen_payload_bytes`, -`crs_payload_bytes`) prefixed by the canonical scheme set, which needs no domain, so they are -checked for every current entry. A node that cannot derive a scheme's key, because it holds no +`crs_payload_bytes`) inside a composite preimage, which binds the canonical scheme set and the +role and needs no domain, so they are checked for every current entry. A node that cannot derive a scheme's key, because it holds no root seed, fails boot on such an entry rather than passing it over. `PubDataType::DecompressionKey` has no private-storage counterpart at all diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index 23bb3561fb..570ea0077d 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -31,15 +31,6 @@ pub(super) fn receiver_enc_key_digest( } } -/// `receiver_id ‖ H(receiver public encryption key)`: the suffix that binds a -/// signcryption to who it was made for. -pub(super) fn receiver_binding( - receiver_id: &[u8], - enc_key: &UnifiedPublicEncKey, -) -> Result, CryptographyError> { - Ok([receiver_id, receiver_enc_key_digest(enc_key)?.as_slice()].concat()) -} - /// Encrypt `msg` under `enc_key` with the hybrid KEM/DEM matching its scheme. pub(super) fn hybrid_encrypt( rng: &mut (impl CryptoRng + RngCore), @@ -114,27 +105,3 @@ pub(super) fn signcryption_fixture(scheme: PkeSchemeType, seed: u64) -> Signcryp receiver_id: receiver_verf_key.verf_key_id(), } } - -#[cfg(test)] -mod tests { - use super::*; - - /// The binding must separate recipients on *both* of its inputs, since it is - /// the only thing tying a signature to who may open it. - #[test] - fn receiver_binding_separates_recipients() { - let f = signcryption_fixture(PkeSchemeType::MlKem512, 500); - let other = signcryption_fixture(PkeSchemeType::MlKem512, 501); - - let base = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); - assert_eq!(base, receiver_binding(&f.receiver_id, &f.enc_key).unwrap()); - assert_ne!( - base, - receiver_binding(&other.receiver_id, &f.enc_key).unwrap() - ); - assert_ne!( - base, - receiver_binding(&f.receiver_id, &other.enc_key).unwrap() - ); - } -} diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 6a4d2d251a..aca85646fa 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -3,7 +3,7 @@ use super::UnifiedSigncryption; #[cfg(feature = "non-wasm")] use super::common::hybrid_encrypt; -use super::common::{hybrid_decrypt, receiver_binding}; +use super::common::{hybrid_decrypt, receiver_enc_key_digest}; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; @@ -13,7 +13,7 @@ use crate::cryptography::signatures::{NodeSigningIdentity, SigningSchemeType}; use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signing::composite::sign_uniform; -use crate::cryptography::signing::composite::verify_uniform; +use crate::cryptography::signing::composite::{CompositeRole, verify_uniform}; #[cfg(feature = "non-wasm")] use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; @@ -53,9 +53,10 @@ impl Zeroize for CompositeEnvelope { /// Signcrypt `msg` in the composite layout. /// -/// Sign-then-encrypt, as in the frozen layout: each signature covers -/// `dsep ‖ schemes ‖ msg ‖ receiver_id ‖ H(receiver enc key)`, and the whole -/// envelope is then encrypted to the receiver. +/// Sign-then-encrypt, as in the frozen layout. Each signature covers the message, +/// the receiver id and the digest of the receiver encryption key, as the three +/// fields of a [`CompositeRole::Signcryption`] preimage. The whole envelope is +/// then encrypted to the receiver. #[cfg(feature = "non-wasm")] pub fn seal( identity: &NodeSigningIdentity, @@ -66,9 +67,14 @@ pub fn seal( dsep: &DomainSep, msg: &[u8], ) -> Result { - let binding = receiver_binding(receiver_id, receiver_enc_key)?; - let signed = Zeroizing::new([msg, binding.as_slice()].concat()); - let signature = sign_uniform(identity, schemes, dsep, &signed)?; + let enc_key_digest = receiver_enc_key_digest(receiver_enc_key)?; + let signature = sign_uniform( + identity, + schemes, + CompositeRole::Signcryption, + dsep, + &[msg, receiver_id, enc_key_digest.as_slice()], + )?; let mut envelope = CompositeEnvelope { msg: msg.to_vec(), @@ -118,10 +124,15 @@ pub(super) fn open( let msg = Zeroizing::new(std::mem::take(&mut envelope.msg)); - let binding = receiver_binding(receiver_id, encryption_key)?; - let signed = Zeroizing::new([msg.as_slice(), binding.as_slice()].concat()); - verify_uniform(&envelope.signature, sender_keys, dsep, &signed) - .map_err(|e| CryptographyError::VerificationError(e.to_string()))?; + let enc_key_digest = receiver_enc_key_digest(encryption_key)?; + verify_uniform( + &envelope.signature, + sender_keys, + CompositeRole::Signcryption, + dsep, + &[msg.as_slice(), receiver_id, enc_key_digest.as_slice()], + ) + .map_err(|e| CryptographyError::VerificationError(e.to_string()))?; Ok(msg) } diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 9d4ca01931..9cf9f9b570 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -8,12 +8,13 @@ use super::common::{DSEP_SIGNCRYPTION, hybrid_decrypt, hybrid_encrypt, receiver_ use super::{ SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, }; -use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey}; +use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; use crate::cryptography::signatures::{ PublicSigKey, SIG_SIZE, Signature, check_normalized, internal_sign, }; +use crate::cryptography::signcryption::common::receiver_enc_key_digest; use ::signature::Verifier; use hashing::{DIGEST_BYTES, DomainSep, serialize_hash_element}; use kms_grpc::kms::v1::TypedPlaintext; @@ -155,6 +156,15 @@ fn check_format_and_signature( .map_err(|e| CryptographyError::VerificationError(e.to_string())) } +/// `receiver_id ‖ H(receiver public encryption key)`: the suffix that binds a +/// signcryption to who it was made for. +fn receiver_binding( + receiver_id: &[u8], + enc_key: &UnifiedPublicEncKey, +) -> Result, CryptographyError> { + Ok([receiver_id, receiver_enc_key_digest(enc_key)?.as_slice()].concat()) +} + /// Decrypt a signcrypted message and ignore the signature /// /// This function does *not* do any verification and is thus insecure and should be used only for @@ -295,4 +305,23 @@ mod tests { ); } } + + /// The binding must separate recipients on *both* of its inputs, since it is + /// the only thing tying a signature to who may open it. + #[test] + fn receiver_binding_separates_recipients() { + let f = signcryption_fixture(PkeSchemeType::MlKem512, 500); + let other = signcryption_fixture(PkeSchemeType::MlKem512, 501); + + let base = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); + assert_eq!(base, receiver_binding(&f.receiver_id, &f.enc_key).unwrap()); + assert_ne!( + base, + receiver_binding(&other.receiver_id, &f.enc_key).unwrap() + ); + assert_ne!( + base, + receiver_binding(&f.receiver_id, &other.enc_key).unwrap() + ); + } } diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 11905b4854..1f66d8a436 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -4,12 +4,79 @@ //! its parts is present. Dropping the post-quantum half of an ECDSA+ML-DSA pair //! must not leave something a verifier accepts, or the hedge the composite was //! built for is gone. +//! +//! # Relation to the IETF composite signature draft +//! +//! The IETF supports exactly one legacy scheme and ML-DSA, whereas we want more +//! flexibility and hence will support both a single scheme choice and more than 2. +//! Our construction follows `draft-ietf-lamps-pq-composite-sigs` in its form but +//! does not interoperate with the draft. +//! +//! Concretely we transform a message to be signed, M, to another one, M', as follows: +//! ```text +//! M' = COMPOSITE_PREFIX ‖ CompositeRole ‖ scheme count ‖ scheme tags ‖ field count ‖ (length ‖ field)* +//! ``` +//! In the IETF draft the format is as follows: +//! ```text +//! M' = COMPOSITE_PREFIX || Label || len(ctx) || ctx || Hash( M ) +//! ``` +//! +//! Each backend prepends the domain separator, so a component signs +//! `dsep ‖ preimage`. The parts of the draft map onto this encoding: +//! +//! - **[`COMPOSITE_PREFIX`]** marks the bytes as a composite +//! preimage, so no component reads as a signature of another construction. The +//! node's ECDSA key also signs EIP-712 messages and the frozen signcryption +//! layout, so the draft's prohibition on key reuse does not hold here. The +//! draft names the Prefix as what covers that case. +//! - **Label** is [`CompositeRole`] which sets the context of signature usage, +//! e.g. for use in signcryption or for signing a result (without encryption). +//! Thus unlike the draft, our Label does not encode the choice of scheme itself. +//! This is instead captured by the additional "scheme count" and "scheme tags". +//! - **ctx** is the domain separator. A [`DomainSep`] is exactly 8 bytes, so the +//! length prefix the draft puts on ctx is not necessary. +//! - **Hash(M)** is the message itself, in one or more length-prefixed fields. The +//! draft pre-hashes the message and assumes that the hash resists collisions. +//! The message keeps the same unforgeability argument without that assumption. +//! +//! # What the encoding gives, and what it does not +//! +//! - Verification is all or nothing. [`verify_uniform`] requires the set of +//! entries to equal the set of keys, then checks every entry. +//! - Every component of a [`sign_uniform`] signature names its scheme set and its +//! role in the bytes it signs, so none of them moves to another set, to another +//! role, or out of the composite. +//! - The composite is unforgeable if any one component is, as in the draft. +//! - The ECDSA entry of [`sign_result_entries`] is the deliberate exception. It +//! signs the EIP-712 hash, so it binds neither the set nor the role, and it +//! moves freely between them. #[cfg(feature = "non-wasm")] use super::identity::NodeSigningIdentity; use super::typed_signature::StoredTypedSignature; use super::verf_key_set::VerfKeySet; use super::{Signature, SigningError, SigningSchemeType, unified_verify}; use hashing::DomainSep; +use zeroize::Zeroizing; + +/// The marker every composite preimage starts with. +pub const COMPOSITE_PREFIX: &[u8; 32] = b"ZamaKmsCompositeSignature2026_v1"; + +/// What a composite signature signs over. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum CompositeRole { + /// The signed part of a signcryption envelope. + Signcryption = 1, + /// The per-scheme signatures of a keygen, CRS, preprocessing or other result. + Result = 2, +} + +impl CompositeRole { + /// The role's stable 4-byte tag, as it appears in the preimage. + fn tag(self) -> [u8; 4] { + (self as u32).to_le_bytes() + } +} /// Sort `schemes` into canonical order and drop duplicates. /// @@ -51,17 +118,37 @@ fn render_schemes(schemes: &[SigningSchemeType]) -> String { ) } -/// The bytes a scheme-set-bound signature covers: the canonical scheme set, -/// length-prefixed, followed by the message. +/// The bytes one component of a composite signature covers. +/// +/// The layout and the reason for each part are in the module documentation. Every +/// field of `fields` carries its own length, so no two field lists produce the +/// same bytes, whatever the lengths of the fields inside them. /// /// `schemes` is canonicalised here, so callers may pass it in any order and /// still agree on the bytes. +/// +/// The result may hold a secret, because a field may. It is wiped on drop. pub fn scheme_bound_preimage( schemes: &[SigningSchemeType], - msg: &[u8], -) -> Result, SigningError> { + role: CompositeRole, + fields: &[&[u8]], +) -> Result>, SigningError> { let schemes = canonical_schemes(schemes)?; - Ok([canonical_scheme_bytes(&schemes).as_slice(), msg].concat()) + let payload_len: usize = fields.iter().map(|field| field.len() + 8).sum(); + // Exactly the final length, so the buffer never grows. A reallocation would + // leave a copy of a field behind that the `Zeroizing` wrapper cannot reach. + let mut out = Vec::with_capacity(COMPOSITE_PREFIX.len() + 12 + 4 * schemes.len() + payload_len); + out.extend_from_slice(COMPOSITE_PREFIX); + out.extend_from_slice(&role.tag()); + out.extend_from_slice(&canonical_scheme_bytes(&schemes)); + // Bounded by the number of fields a call site writes out by hand, so the cast + // cannot truncate. + out.extend_from_slice(&(fields.len() as u32).to_le_bytes()); + for field in fields { + out.extend_from_slice(&(field.len() as u64).to_le_bytes()); + out.extend_from_slice(field); + } + Ok(Zeroizing::new(out)) } /// The schemes `entries` were made under, in the order they are stored. @@ -69,20 +156,22 @@ pub fn entry_schemes(entries: &[StoredTypedSignature]) -> Vec entries.iter().map(|entry| entry.scheme).collect() } -/// Sign `msg` under every scheme in `schemes`, each over the same bytes. +/// Sign `fields` under every scheme in `schemes`, each over the same bytes. /// /// The entries come back ordered by scheme, with no duplicate scheme, which is -/// the shape [`verify_uniform`] requires. +/// the shape [`verify_uniform`] requires. `verify_uniform` has to be called with +/// the same `role` and the same `fields`, in the same order. #[cfg(feature = "non-wasm")] pub fn sign_uniform( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], + role: CompositeRole, dsep: &DomainSep, - msg: &[u8], + fields: &[&[u8]], ) -> Result, SigningError> { let schemes = canonical_schemes(schemes)?; identity.ensure_supported(&schemes)?; - let preimage = scheme_bound_preimage(&schemes, msg)?; + let preimage = scheme_bound_preimage(&schemes, role, fields)?; schemes .iter() .map(|&scheme| { @@ -105,8 +194,9 @@ pub fn sign_uniform( pub fn verify_uniform( entries: &[StoredTypedSignature], keys: &VerfKeySet, + role: CompositeRole, dsep: &DomainSep, - msg: &[u8], + fields: &[&[u8]], ) -> Result<(), SigningError> { let expected = keys.schemes(); // The scheme-set comparison happens before any cryptography, so a composite @@ -120,7 +210,7 @@ pub fn verify_uniform( actual: render_schemes(&schemes), }); } - let preimage = scheme_bound_preimage(&schemes, msg)?; + let preimage = scheme_bound_preimage(&schemes, role, fields)?; for entry in entries { let signature = Signature::new(entry.scheme, entry.signature.clone()); // Cannot fail: `schemes` equals `keys.schemes()` on this path. @@ -132,9 +222,26 @@ pub fn verify_uniform( /// The per-scheme signatures of a *result*: a keygen, CRS, preprocessing or /// decryption response. /// -/// ECDSA signs `eip712_hash`, every other scheme signs -/// [`scheme_bound_preimage`] over `payload_bytes`, so it commits to the scheme -/// set as well as to the payload. +/// **A scheme determines what its signature covers.** This mapping is the +/// contract every verifier relies on, so it lives here alone: +/// +/// - [`SigningSchemeType::Ecdsa256k1`] signs `eip712_hash`, producing the +/// recoverable, on-chain-verifiable signature the fhevm contracts verify. It is +/// byte-identical to the result's deprecated `external_signature`, so that +/// `signatures` still carries it once that field goes away. The two match +/// because the caller derives both from one hash and ECDSA signing here is +/// deterministic. +/// - Every other scheme signs [`scheme_bound_preimage`] over `payload_bytes`, so +/// it commits to the scheme set and the role as well as to the payload. +/// +/// The ECDSA entry is therefore the one component that binds neither the set nor +/// the role, and a verifier cannot read it as evidence of either. EIP-712 is an +/// EVM and secp256k1 construction that a post-quantum scheme has no reason to be +/// bound to, so the asymmetry stays. It costs less than it appears to, because +/// `ensure_requested_verified` requires every requested scheme to verify, so a +/// set-bound entry pins the set as soon as a verifier asks for more than ECDSA. A +/// request for ECDSA alone, which is what an empty request resolves to, pins +/// neither the set nor the role. /// /// `schemes` may be given in any order; the entries come back ordered by /// scheme. @@ -152,7 +259,7 @@ pub fn sign_result_entries( let schemes = canonical_schemes(schemes)?; // Every non-ECDSA entry commits to the scheme set, so one cannot be lifted // out of a larger response and presented as a complete smaller one. - let signed = scheme_bound_preimage(&schemes, payload_bytes)?; + let signed = scheme_bound_preimage(&schemes, CompositeRole::Result, &[payload_bytes])?; schemes .iter() .map(|&scheme| { diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index e6edf1838d..c1e7209c31 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -553,7 +553,8 @@ fn verify_metadata_signatures( let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); let signed_bytes = crate::cryptography::signing::composite::scheme_bound_preimage( &stored_schemes, - payload_bytes, + crate::cryptography::signing::composite::CompositeRole::Result, + &[payload_bytes], )?; for stored in signatures { diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 3fc9d7848b..62d95da02a 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -444,10 +444,12 @@ pub(crate) fn verify_response_signatures( )); } // Bound to the set this verifier requested, so an entry lifted from a - // response signed under a larger set does not verify here. + // response signed under a larger set does not verify here. The ECDSA entry is + // the exception; see `sign_result_entries`. let signed_bytes = crate::cryptography::signing::composite::scheme_bound_preimage( requested, - payloads.payload_bytes, + crate::cryptography::signing::composite::CompositeRole::Result, + &[payloads.payload_bytes], ) .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))?; let mut verified: Vec = Vec::with_capacity(sigs.list.len() + 1); From ec3dad35ef21a4b88a57b66cf6ac6a6703598634 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 10:28:24 +0200 Subject: [PATCH 052/114] refactor: serialization of signcryption payload --- .../cryptography/signcryption/composite_v1.rs | 67 +++++++++++++++---- .../src/cryptography/signing/composite.rs | 47 ++++++------- .../engine/storage_material_verification.rs | 2 +- core/service/src/engine/validation_wasm.rs | 2 +- core/service/tests/versioned_enum_coverage.rs | 5 ++ 5 files changed, 82 insertions(+), 41 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index aca85646fa..ea494008b2 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -14,16 +14,13 @@ use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signing::composite::sign_uniform; use crate::cryptography::signing::composite::{CompositeRole, verify_uniform}; -#[cfg(feature = "non-wasm")] use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; #[cfg(feature = "non-wasm")] use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use tfhe::named::Named; -use tfhe::safe_serialization::safe_deserialize; -#[cfg(feature = "non-wasm")] -use tfhe::safe_serialization::safe_serialize; +use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; use tfhe_versionable::{Versionize, VersionsDispatch}; use zeroize::{Zeroize, Zeroizing}; @@ -51,12 +48,58 @@ impl Zeroize for CompositeEnvelope { } } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] +pub enum SigncryptionSignedPayloadVersions { + V0(SigncryptionSignedPayload), +} + +/// What every signature of a composite signcryption covers: the message, and the +/// two values that tie it to one receiver. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Versionize)] +#[versionize(SigncryptionSignedPayloadVersions)] +pub struct SigncryptionSignedPayload { + pub msg: Vec, + /// The receiver's identifier, e.g. a blockchain address. + pub receiver_id: Vec, + pub enc_key_digest: Vec, +} + +impl Named for SigncryptionSignedPayload { + const NAME: &'static str = "signcryption::SigncryptionSignedPayload"; +} + +impl Zeroize for SigncryptionSignedPayload { + fn zeroize(&mut self) { + // The receiver id and the digest are public; `msg` is the secret. + self.msg.zeroize(); + } +} + +/// The bytes every signature of a composite signcryption covers. +fn signcryption_signed_bytes( + msg: &[u8], + receiver_id: &[u8], + encryption_key: &UnifiedPublicEncKey, +) -> Result { + let mut payload = SigncryptionSignedPayload { + msg: msg.to_vec(), + receiver_id: receiver_id.to_vec(), + enc_key_digest: receiver_enc_key_digest(encryption_key)?, + }; + let mut bytes = ZeroizingWriter::new(); + let serialized = safe_serialize(&payload, &mut bytes, SAFE_SER_SIZE_LIMIT); + // The payload owns a second copy of the message and nothing wipes it on + // drop, so wipe it here, before either outcome leaves the function. + payload.zeroize(); + serialized.map_err(|e| CryptographyError::SerializationError(e.to_string()))?; + Ok(bytes) +} + /// Signcrypt `msg` in the composite layout. /// -/// Sign-then-encrypt, as in the frozen layout. Each signature covers the message, -/// the receiver id and the digest of the receiver encryption key, as the three -/// fields of a [`CompositeRole::Signcryption`] preimage. The whole envelope is -/// then encrypted to the receiver. +/// Sign-then-encrypt, as in the frozen layout. Each signature covers a +/// [`SigncryptionSignedPayload`], under the [`CompositeRole::Signcryption`] +/// role. The whole envelope is then encrypted to the receiver. #[cfg(feature = "non-wasm")] pub fn seal( identity: &NodeSigningIdentity, @@ -67,13 +110,13 @@ pub fn seal( dsep: &DomainSep, msg: &[u8], ) -> Result { - let enc_key_digest = receiver_enc_key_digest(receiver_enc_key)?; + let signed = signcryption_signed_bytes(msg, receiver_id, receiver_enc_key)?; let signature = sign_uniform( identity, schemes, CompositeRole::Signcryption, dsep, - &[msg, receiver_id, enc_key_digest.as_slice()], + signed.as_slice(), )?; let mut envelope = CompositeEnvelope { @@ -124,13 +167,13 @@ pub(super) fn open( let msg = Zeroizing::new(std::mem::take(&mut envelope.msg)); - let enc_key_digest = receiver_enc_key_digest(encryption_key)?; + let signed = signcryption_signed_bytes(&msg, receiver_id, encryption_key)?; verify_uniform( &envelope.signature, sender_keys, CompositeRole::Signcryption, dsep, - &[msg.as_slice(), receiver_id, enc_key_digest.as_slice()], + signed.as_slice(), ) .map_err(|e| CryptographyError::VerificationError(e.to_string()))?; diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 1f66d8a436..6b0a7fb7a1 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -14,7 +14,7 @@ //! //! Concretely we transform a message to be signed, M, to another one, M', as follows: //! ```text -//! M' = COMPOSITE_PREFIX ‖ CompositeRole ‖ scheme count ‖ scheme tags ‖ field count ‖ (length ‖ field)* +//! M' = COMPOSITE_PREFIX ‖ CompositeRole ‖ scheme count ‖ scheme tags ‖ M //! ``` //! In the IETF draft the format is as follows: //! ```text @@ -31,13 +31,15 @@ //! draft names the Prefix as what covers that case. //! - **Label** is [`CompositeRole`] which sets the context of signature usage, //! e.g. for use in signcryption or for signing a result (without encryption). -//! Thus unlike the draft, our Label does not encode the choice of scheme itself. +//! Thus, unlike the draft, our Label does not encode the choice of scheme itself. //! This is instead captured by the additional "scheme count" and "scheme tags". //! - **ctx** is the domain separator. A [`DomainSep`] is exactly 8 bytes, so the //! length prefix the draft puts on ctx is not necessary. -//! - **Hash(M)** is the message itself, in one or more length-prefixed fields. The -//! draft pre-hashes the message and assumes that the hash resists collisions. -//! The message keeps the same unforgeability argument without that assumption. +//! - **Hash(M)** is the message itself. The draft pre-hashes the message and +//! assumes that the hash resists collisions. The message keeps the same +//! unforgeability argument without that assumption. Everything before it is +//! fixed width once the scheme set is known, so it carries no length of its +//! own. //! //! # What the encoding gives, and what it does not //! @@ -120,34 +122,25 @@ fn render_schemes(schemes: &[SigningSchemeType]) -> String { /// The bytes one component of a composite signature covers. /// -/// The layout and the reason for each part are in the module documentation. Every -/// field of `fields` carries its own length, so no two field lists produce the -/// same bytes, whatever the lengths of the fields inside them. +/// The layout and the reason for each part are in the module documentation. /// /// `schemes` is canonicalised here, so callers may pass it in any order and /// still agree on the bytes. /// -/// The result may hold a secret, because a field may. It is wiped on drop. +/// The result may hold a secret, because `msg` may. It is wiped on drop. pub fn scheme_bound_preimage( schemes: &[SigningSchemeType], role: CompositeRole, - fields: &[&[u8]], + msg: &[u8], ) -> Result>, SigningError> { let schemes = canonical_schemes(schemes)?; - let payload_len: usize = fields.iter().map(|field| field.len() + 8).sum(); // Exactly the final length, so the buffer never grows. A reallocation would - // leave a copy of a field behind that the `Zeroizing` wrapper cannot reach. - let mut out = Vec::with_capacity(COMPOSITE_PREFIX.len() + 12 + 4 * schemes.len() + payload_len); + // leave a copy of `msg` behind that the `Zeroizing` wrapper cannot reach. + let mut out = Vec::with_capacity(COMPOSITE_PREFIX.len() + 8 + 4 * schemes.len() + msg.len()); out.extend_from_slice(COMPOSITE_PREFIX); out.extend_from_slice(&role.tag()); out.extend_from_slice(&canonical_scheme_bytes(&schemes)); - // Bounded by the number of fields a call site writes out by hand, so the cast - // cannot truncate. - out.extend_from_slice(&(fields.len() as u32).to_le_bytes()); - for field in fields { - out.extend_from_slice(&(field.len() as u64).to_le_bytes()); - out.extend_from_slice(field); - } + out.extend_from_slice(msg); Ok(Zeroizing::new(out)) } @@ -156,22 +149,22 @@ pub fn entry_schemes(entries: &[StoredTypedSignature]) -> Vec entries.iter().map(|entry| entry.scheme).collect() } -/// Sign `fields` under every scheme in `schemes`, each over the same bytes. +/// Sign `msg` under every scheme in `schemes`, each over the same bytes. /// /// The entries come back ordered by scheme, with no duplicate scheme, which is /// the shape [`verify_uniform`] requires. `verify_uniform` has to be called with -/// the same `role` and the same `fields`, in the same order. +/// the same `role` and the same `msg`. #[cfg(feature = "non-wasm")] pub fn sign_uniform( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], role: CompositeRole, dsep: &DomainSep, - fields: &[&[u8]], + msg: &[u8], ) -> Result, SigningError> { let schemes = canonical_schemes(schemes)?; identity.ensure_supported(&schemes)?; - let preimage = scheme_bound_preimage(&schemes, role, fields)?; + let preimage = scheme_bound_preimage(&schemes, role, msg)?; schemes .iter() .map(|&scheme| { @@ -196,7 +189,7 @@ pub fn verify_uniform( keys: &VerfKeySet, role: CompositeRole, dsep: &DomainSep, - fields: &[&[u8]], + msg: &[u8], ) -> Result<(), SigningError> { let expected = keys.schemes(); // The scheme-set comparison happens before any cryptography, so a composite @@ -210,7 +203,7 @@ pub fn verify_uniform( actual: render_schemes(&schemes), }); } - let preimage = scheme_bound_preimage(&schemes, role, fields)?; + let preimage = scheme_bound_preimage(&schemes, role, msg)?; for entry in entries { let signature = Signature::new(entry.scheme, entry.signature.clone()); // Cannot fail: `schemes` equals `keys.schemes()` on this path. @@ -259,7 +252,7 @@ pub fn sign_result_entries( let schemes = canonical_schemes(schemes)?; // Every non-ECDSA entry commits to the scheme set, so one cannot be lifted // out of a larger response and presented as a complete smaller one. - let signed = scheme_bound_preimage(&schemes, CompositeRole::Result, &[payload_bytes])?; + let signed = scheme_bound_preimage(&schemes, CompositeRole::Result, payload_bytes)?; schemes .iter() .map(|&scheme| { diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index c1e7209c31..11af0bd6ab 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -554,7 +554,7 @@ fn verify_metadata_signatures( let signed_bytes = crate::cryptography::signing::composite::scheme_bound_preimage( &stored_schemes, crate::cryptography::signing::composite::CompositeRole::Result, - &[payload_bytes], + payload_bytes, )?; for stored in signatures { diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 62d95da02a..7b0276358d 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -449,7 +449,7 @@ pub(crate) fn verify_response_signatures( let signed_bytes = crate::cryptography::signing::composite::scheme_bound_preimage( requested, crate::cryptography::signing::composite::CompositeRole::Result, - &[payloads.payload_bytes], + payloads.payload_bytes, ) .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))?; let mut verified: Vec = Vec::with_capacity(sigs.list.len() + 1); diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index f911426fba..43d8ee2e33 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -70,6 +70,11 @@ const ALLOW_UNCOVERED: &[&str] = &[ // Plaintext that composite_v1::seal safe_serializes into the // UnifiedSigncryption payload. "CompositeEnvelope", + // TODO stop gap https://github.com/zama-ai/kms-internal/issues/3168 + // What every signature of a composite signcryption covers. Signed, never + // stored or transmitted, so no artifact holds it. The generator pins a rev, + // so the type has to reach main before a fixture can be produced for it. + "SigncryptionSignedPayload", // TODO(zama-ai/kms-internal#3168): needs its own fixture once the follow-up // actually writes a key set to storage. The generator pins a rev, so the // type has to reach main before a fixture can be produced for it. From 54138ea77e7e59f01f5cccab06ee8103adcc15b5 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 10:51:18 +0200 Subject: [PATCH 053/114] refactor: ensured compatibility with ietf --- core/service/src/client/client_non_wasm.rs | 18 ++- core/service/src/client/crs_gen.rs | 13 +- core/service/src/client/key_gen.rs | 10 +- .../src/client/user_decryption_wasm.rs | 4 +- .../cryptography/signcryption/composite_v1.rs | 67 ++++----- .../src/cryptography/signcryption/ecdsa_v0.rs | 2 +- .../src/cryptography/signing/composite.rs | 140 +++++++++--------- core/service/src/cryptography/signing/mod.rs | 2 + .../src/cryptography/zeroizing_writer.rs | 5 + core/service/src/engine/base.rs | 99 ++++++------- core/service/src/engine/signed_payload.rs | 33 +---- .../engine/storage_material_verification.rs | 34 ++--- .../service/src/engine/validation_non_wasm.rs | 6 +- core/service/src/engine/validation_wasm.rs | 51 +++---- 14 files changed, 226 insertions(+), 258 deletions(-) diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index 65a359878c..4b4a7aca08 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -116,8 +116,8 @@ impl Client { /// * `domain` - the EIP-712 domain of the request the result answers. /// * `dsep` - the domain separator of the result kind, which the non-ECDSA entries /// sign under. - /// * `payload_bytes` - the serialized result payload the non-ECDSA entries sign, as - /// the `*_payload_bytes` helpers of [`crate::engine::base`] build it. + /// * `payload` - the result payload the non-ECDSA entries sign, as the + /// `*_payload` helpers of [`crate::engine::base`] build it. /// /// Returns the party id and address of the signer. /// @@ -126,15 +126,19 @@ impl Client { /// Fails when nothing about the result can be authenticated, when an entry names a /// scheme this client holds no key for, when an entry does not verify, when the /// entries do not agree on one party, and when a requested scheme ends up unverified. - pub fn verify_result_signatures( + pub fn verify_result_signatures( &self, signatures: &[TypedSignature], external_signature: &[u8], - sol_type: &T, + sol_type: &S, domain: &Eip712Domain, dsep: &DomainSep, - payload_bytes: &[u8], - ) -> anyhow::Result<(u32, alloy_primitives::Address)> { + payload: &T, + ) -> anyhow::Result<(u32, alloy_primitives::Address)> + where + S: SolStruct, + T: serde::Serialize + tfhe::Versionize + tfhe::named::Named, + { if signatures.is_empty() && external_signature.is_empty() { return Err(anyhow_error_and_log( "the response carries no signatures and no legacy external signature".to_string(), @@ -150,7 +154,7 @@ impl Client { &SignedPayloads { dsep, internal_bytes: &[], - payload_bytes, + payload, eip712_hash: Some(sol_type.eip712_signing_hash(domain)), }, &self.signing_schemes, diff --git a/core/service/src/client/crs_gen.rs b/core/service/src/client/crs_gen.rs index 753a2c11d1..dccb362008 100644 --- a/core/service/src/client/crs_gen.rs +++ b/core/service/src/client/crs_gen.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use crate::client::client_wasm::Client; use crate::consts::{DEFAULT_EPOCH_ID, DEFAULT_MPC_CONTEXT}; -use crate::engine::base::{DSEP_PUBDATA_CRS, crs_payload_bytes}; +use crate::engine::base::{DSEP_PUBDATA_CRS, crs_payload}; use crate::engine::utils::make_extra_data; use crate::engine::validation::RequestIdParsingErr; use crate::engine::validation::parse_optional_grpc_request_id; @@ -128,15 +128,14 @@ impl Client { actual_digest.clone(), extra_data.clone(), ); - let payload_bytes = - crs_payload_bytes(request_id, max_num_bits as u32, &actual_digest, &extra_data)?; + let payload = crs_payload(request_id, max_num_bits as u32, &actual_digest, &extra_data); let (_party_id, signer) = match self.verify_result_signatures( &result.signatures, &result.external_signature, &sol_type, domain, &DSEP_PUBDATA_CRS, - &payload_bytes, + &payload, ) { Ok(found) => found, Err(e) => { @@ -228,19 +227,19 @@ impl Client { actual_digest.clone(), extra_data.clone(), ); - let payload_bytes = crs_payload_bytes( + let payload = crs_payload( &request_id, max_num_bits as u32, &actual_digest, &extra_data, - )?; + ); if let Err(e) = self.verify_result_signatures( &crs_gen_result.signatures, &crs_gen_result.external_signature, &sol_type, domain, &DSEP_PUBDATA_CRS, - &payload_bytes, + &payload, ) { tracing::warn!( "Could not verify the signatures for crs handle {}: {e}", diff --git a/core/service/src/client/key_gen.rs b/core/service/src/client/key_gen.rs index 8cc1f33fea..b63de933e2 100644 --- a/core/service/src/client/key_gen.rs +++ b/core/service/src/client/key_gen.rs @@ -3,7 +3,7 @@ use std::io::Cursor; use crate::client::client_wasm::Client; use crate::consts::{DEFAULT_EPOCH_ID, DEFAULT_MPC_CONTEXT}; -use crate::engine::base::{DSEP_PUBDATA_KEY, keygen_payload_bytes, preproc_payload_bytes}; +use crate::engine::base::{DSEP_PUBDATA_KEY, keygen_payload, preproc_payload}; use crate::engine::utils::make_extra_data; use crate::engine::validation::RequestIdParsingErr; use crate::engine::validation::parse_optional_grpc_request_id; @@ -180,14 +180,14 @@ impl Client { ))); } - let payload_bytes = preproc_payload_bytes(preproc_id, sol_type.extraData.as_ref())?; + let payload = preproc_payload(preproc_id, sol_type.extraData.as_ref()); self.verify_result_signatures( &resp.signatures, &resp.external_signature, &sol_type, domain, &DSEP_PUBDATA_KEY, - &payload_bytes, + &payload, ) .map(|_signer| ()) } @@ -399,7 +399,7 @@ impl Client { )); } } - let payload_bytes = keygen_payload_bytes(preproc_id, key_id, &key_digests, extra_data)?; + let payload = keygen_payload(preproc_id, key_id, &key_digests, extra_data); self.verify_result_signatures( &key_gen_result.signatures, @@ -407,7 +407,7 @@ impl Client { sol_type, domain, &DSEP_PUBDATA_KEY, - &payload_bytes, + &payload, ) .map(|_signer| ()) } diff --git a/core/service/src/client/user_decryption_wasm.rs b/core/service/src/client/user_decryption_wasm.rs index 696df9e8e6..0b003e0dea 100644 --- a/core/service/src/client/user_decryption_wasm.rs +++ b/core/service/src/client/user_decryption_wasm.rs @@ -8,7 +8,7 @@ use crate::cryptography::{ signcryption::{UnifiedUnsigncryptionKey, UnsigncryptFHEPlaintext}, signing::SigningSchemeType, }; -use crate::engine::signed_payload::user_dec_payload_bytes; +use crate::engine::signed_payload::user_dec_payload; use crate::engine::validation::{ DSEP_USER_DECRYPTION, ERR_VALIDATE_USER_DECRYPTION_MISMATCH_EXTRA_DATA, ExpectedSigner, RejectedUserDecResponse, ResponseSignatures, SignedPayloads, UserDecRejectReason, @@ -278,7 +278,7 @@ impl Client { &SignedPayloads { dsep: &DSEP_USER_DECRYPTION, internal_bytes: &response_bytes, - payload_bytes: &user_dec_payload_bytes(&response_bytes, &resp.extra_data)?, + payload: &user_dec_payload(&response_bytes, &resp.extra_data), eip712_hash: Some(user_decrypt_eip712_hash(&payload, request, eip712_domain)?), }, request.signing_schemes(), diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index ea494008b2..46d49eddd7 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -13,14 +13,17 @@ use crate::cryptography::signatures::{NodeSigningIdentity, SigningSchemeType}; use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signing::composite::sign_uniform; -use crate::cryptography::signing::composite::{CompositeRole, verify_uniform}; +use crate::cryptography::signing::composite::verify_uniform; +#[cfg(feature = "non-wasm")] use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; #[cfg(feature = "non-wasm")] use rand::{CryptoRng, RngCore}; use serde::{Deserialize, Serialize}; use tfhe::named::Named; -use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; +use tfhe::safe_serialization::safe_deserialize; +#[cfg(feature = "non-wasm")] +use tfhe::safe_serialization::safe_serialize; use tfhe_versionable::{Versionize, VersionsDispatch}; use zeroize::{Zeroize, Zeroizing}; @@ -75,31 +78,26 @@ impl Zeroize for SigncryptionSignedPayload { } } -/// The bytes every signature of a composite signcryption covers. -fn signcryption_signed_bytes( - msg: &[u8], - receiver_id: &[u8], - encryption_key: &UnifiedPublicEncKey, -) -> Result { - let mut payload = SigncryptionSignedPayload { - msg: msg.to_vec(), - receiver_id: receiver_id.to_vec(), - enc_key_digest: receiver_enc_key_digest(encryption_key)?, - }; - let mut bytes = ZeroizingWriter::new(); - let serialized = safe_serialize(&payload, &mut bytes, SAFE_SER_SIZE_LIMIT); - // The payload owns a second copy of the message and nothing wipes it on - // drop, so wipe it here, before either outcome leaves the function. - payload.zeroize(); - serialized.map_err(|e| CryptographyError::SerializationError(e.to_string()))?; - Ok(bytes) +impl SigncryptionSignedPayload { + /// What the signatures of a signcryption of `msg` to this receiver cover. + fn new( + msg: &[u8], + receiver_id: &[u8], + encryption_key: &UnifiedPublicEncKey, + ) -> Result { + Ok(Self { + msg: msg.to_vec(), + receiver_id: receiver_id.to_vec(), + enc_key_digest: receiver_enc_key_digest(encryption_key)?, + }) + } } /// Signcrypt `msg` in the composite layout. /// /// Sign-then-encrypt, as in the frozen layout. Each signature covers a -/// [`SigncryptionSignedPayload`], under the [`CompositeRole::Signcryption`] -/// role. The whole envelope is then encrypted to the receiver. +/// [`SigncryptionSignedPayload`]. The whole envelope is then encrypted to the +/// receiver. #[cfg(feature = "non-wasm")] pub fn seal( identity: &NodeSigningIdentity, @@ -110,14 +108,10 @@ pub fn seal( dsep: &DomainSep, msg: &[u8], ) -> Result { - let signed = signcryption_signed_bytes(msg, receiver_id, receiver_enc_key)?; - let signature = sign_uniform( - identity, - schemes, - CompositeRole::Signcryption, - dsep, - signed.as_slice(), - )?; + let mut signed = SigncryptionSignedPayload::new(msg, receiver_id, receiver_enc_key)?; + let signature = sign_uniform(identity, schemes, dsep, &signed); + signed.zeroize(); + let signature = signature?; let mut envelope = CompositeEnvelope { msg: msg.to_vec(), @@ -167,15 +161,10 @@ pub(super) fn open( let msg = Zeroizing::new(std::mem::take(&mut envelope.msg)); - let signed = signcryption_signed_bytes(&msg, receiver_id, encryption_key)?; - verify_uniform( - &envelope.signature, - sender_keys, - CompositeRole::Signcryption, - dsep, - signed.as_slice(), - ) - .map_err(|e| CryptographyError::VerificationError(e.to_string()))?; + let mut signed = SigncryptionSignedPayload::new(&msg, receiver_id, encryption_key)?; + let verified = verify_uniform(&envelope.signature, sender_keys, dsep, &signed); + signed.zeroize(); + verified.map_err(|e| CryptographyError::VerificationError(e.to_string()))?; Ok(msg) } diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 9cf9f9b570..4de30f7658 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -4,7 +4,7 @@ //! deployed browser-side verifier parses it, so its bytes cannot change. See the //! module documentation of [`super`] for the full list of what pins them. -use super::common::{DSEP_SIGNCRYPTION, hybrid_decrypt, hybrid_encrypt, receiver_binding}; +use super::common::{DSEP_SIGNCRYPTION, hybrid_decrypt, hybrid_encrypt}; use super::{ SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, }; diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 6b0a7fb7a1..a7363cc639 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -14,7 +14,7 @@ //! //! Concretely we transform a message to be signed, M, to another one, M', as follows: //! ```text -//! M' = COMPOSITE_PREFIX ‖ CompositeRole ‖ scheme count ‖ scheme tags ‖ M +//! M' = COMPOSITE_PREFIX ‖ scheme count ‖ scheme tags ‖ safe_serialize(M) //! ``` //! In the IETF draft the format is as follows: //! ```text @@ -29,57 +29,53 @@ //! node's ECDSA key also signs EIP-712 messages and the frozen signcryption //! layout, so the draft's prohibition on key reuse does not hold here. The //! draft names the Prefix as what covers that case. -//! - **Label** is [`CompositeRole`] which sets the context of signature usage, -//! e.g. for use in signcryption or for signing a result (without encryption). -//! Thus, unlike the draft, our Label does not encode the choice of scheme itself. -//! This is instead captured by the additional "scheme count" and "scheme tags". +//! - **Label** is the scheme count and the scheme tags, together with the name +//! the message type carries inside `safe_serialize(M)`. The draft's Label names +//! one registered combination of schemes and one usage. Ours names the set of +//! schemes, which the draft fixes per combination, and the usage, which the +//! message type states: `SigncryptionSignedPayload` for a signcryption, +//! `KeygenSignedPayload` or `CrsSignedPayload` for a result, and so on. //! - **ctx** is the domain separator. A [`DomainSep`] is exactly 8 bytes, so the //! length prefix the draft puts on ctx is not necessary. //! - **Hash(M)** is the message itself. The draft pre-hashes the message and //! assumes that the hash resists collisions. The message keeps the same -//! unforgeability argument without that assumption. Everything before it is -//! fixed width once the scheme set is known, so it carries no length of its -//! own. +//! unforgeability argument without that assumption. +//! +//! The message is a type rather than bytes, and this module serializes it. That +//! is what lets the type name do the Label's second job, and it means every value +//! the message binds is framed by `safe_serialize` rather than concatenated. A +//! caller cannot pass bytes that name no usage, or declare a usage that does not +//! match what it signs. //! //! # What the encoding gives, and what it does not //! //! - Verification is all or nothing. [`verify_uniform`] requires the set of //! entries to equal the set of keys, then checks every entry. -//! - Every component of a [`sign_uniform`] signature names its scheme set and its -//! role in the bytes it signs, so none of them moves to another set, to another -//! role, or out of the composite. +//! - Every component of a [`sign_uniform`] signature names its scheme set and the +//! type of what it signs, so none of them moves to another set, to another +//! usage, or out of the composite. //! - The composite is unforgeable if any one component is, as in the draft. //! - The ECDSA entry of [`sign_result_entries`] is the deliberate exception. It -//! signs the EIP-712 hash, so it binds neither the set nor the role, and it +//! signs the EIP-712 hash, so it binds neither the set nor the usage, and it //! moves freely between them. #[cfg(feature = "non-wasm")] use super::identity::NodeSigningIdentity; use super::typed_signature::StoredTypedSignature; use super::verf_key_set::VerfKeySet; use super::{Signature, SigningError, SigningSchemeType, unified_verify}; +use crate::consts::SAFE_SER_SIZE_LIMIT; +use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; +use serde::Serialize; +use std::io::Write; +use tfhe::Versionize; +use tfhe::named::Named; +use tfhe::safe_serialization::safe_serialize; use zeroize::Zeroizing; /// The marker every composite preimage starts with. pub const COMPOSITE_PREFIX: &[u8; 32] = b"ZamaKmsCompositeSignature2026_v1"; -/// What a composite signature signs over. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u32)] -pub enum CompositeRole { - /// The signed part of a signcryption envelope. - Signcryption = 1, - /// The per-scheme signatures of a keygen, CRS, preprocessing or other result. - Result = 2, -} - -impl CompositeRole { - /// The role's stable 4-byte tag, as it appears in the preimage. - fn tag(self) -> [u8; 4] { - (self as u32).to_le_bytes() - } -} - /// Sort `schemes` into canonical order and drop duplicates. /// /// Errors when `schemes` is empty. @@ -127,21 +123,24 @@ fn render_schemes(schemes: &[SigningSchemeType]) -> String { /// `schemes` is canonicalised here, so callers may pass it in any order and /// still agree on the bytes. /// -/// The result may hold a secret, because `msg` may. It is wiped on drop. -pub fn scheme_bound_preimage( +/// The result may hold a secret, because `payload` may. It is wiped on drop, and +/// so is every buffer used to build it. +pub fn scheme_bound_preimage( schemes: &[SigningSchemeType], - role: CompositeRole, - msg: &[u8], -) -> Result>, SigningError> { + payload: &T, +) -> Result>, SigningError> +where + T: Serialize + Versionize + Named, +{ let schemes = canonical_schemes(schemes)?; - // Exactly the final length, so the buffer never grows. A reallocation would - // leave a copy of `msg` behind that the `Zeroizing` wrapper cannot reach. - let mut out = Vec::with_capacity(COMPOSITE_PREFIX.len() + 8 + 4 * schemes.len() + msg.len()); - out.extend_from_slice(COMPOSITE_PREFIX); - out.extend_from_slice(&role.tag()); - out.extend_from_slice(&canonical_scheme_bytes(&schemes)); - out.extend_from_slice(msg); - Ok(Zeroizing::new(out)) + let mut out = ZeroizingWriter::new(); + let framed = |e: std::io::Error| SigningError::Serialization(e.to_string()); + out.write_all(COMPOSITE_PREFIX).map_err(framed)?; + out.write_all(&canonical_scheme_bytes(&schemes)) + .map_err(framed)?; + safe_serialize(payload, &mut out, SAFE_SER_SIZE_LIMIT) + .map_err(|e| SigningError::Serialization(e.to_string()))?; + Ok(out.into_inner()) } /// The schemes `entries` were made under, in the order they are stored. @@ -149,22 +148,24 @@ pub fn entry_schemes(entries: &[StoredTypedSignature]) -> Vec entries.iter().map(|entry| entry.scheme).collect() } -/// Sign `msg` under every scheme in `schemes`, each over the same bytes. +/// Sign `payload` under every scheme in `schemes`, each over the same bytes. /// /// The entries come back ordered by scheme, with no duplicate scheme, which is /// the shape [`verify_uniform`] requires. `verify_uniform` has to be called with -/// the same `role` and the same `msg`. +/// an equal `payload` of the same type. #[cfg(feature = "non-wasm")] -pub fn sign_uniform( +pub fn sign_uniform( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], - role: CompositeRole, dsep: &DomainSep, - msg: &[u8], -) -> Result, SigningError> { + payload: &T, +) -> Result, SigningError> +where + T: Serialize + Versionize + Named, +{ let schemes = canonical_schemes(schemes)?; identity.ensure_supported(&schemes)?; - let preimage = scheme_bound_preimage(&schemes, role, msg)?; + let preimage = scheme_bound_preimage(&schemes, payload)?; schemes .iter() .map(|&scheme| { @@ -184,13 +185,15 @@ pub fn sign_uniform( /// Every signature must verify. `entries` is untrusted: it may come straight /// from storage or from the network, so its shape is checked here rather than /// assumed. -pub fn verify_uniform( +pub fn verify_uniform( entries: &[StoredTypedSignature], keys: &VerfKeySet, - role: CompositeRole, dsep: &DomainSep, - msg: &[u8], -) -> Result<(), SigningError> { + payload: &T, +) -> Result<(), SigningError> +where + T: Serialize + Versionize + Named, +{ let expected = keys.schemes(); // The scheme-set comparison happens before any cryptography, so a composite // signature presented with one of its parts removed, reordered or repeated @@ -203,7 +206,7 @@ pub fn verify_uniform( actual: render_schemes(&schemes), }); } - let preimage = scheme_bound_preimage(&schemes, role, msg)?; + let preimage = scheme_bound_preimage(&schemes, payload)?; for entry in entries { let signature = Signature::new(entry.scheme, entry.signature.clone()); // Cannot fail: `schemes` equals `keys.schemes()` on this path. @@ -224,35 +227,38 @@ pub fn verify_uniform( /// `signatures` still carries it once that field goes away. The two match /// because the caller derives both from one hash and ECDSA signing here is /// deterministic. -/// - Every other scheme signs [`scheme_bound_preimage`] over `payload_bytes`, so -/// it commits to the scheme set and the role as well as to the payload. +/// - Every other scheme signs [`scheme_bound_preimage`] over `payload`, so it +/// commits to the scheme set and the payload type as well as to the payload. /// /// The ECDSA entry is therefore the one component that binds neither the set nor -/// the role, and a verifier cannot read it as evidence of either. EIP-712 is an -/// EVM and secp256k1 construction that a post-quantum scheme has no reason to be -/// bound to, so the asymmetry stays. It costs less than it appears to, because -/// `ensure_requested_verified` requires every requested scheme to verify, so a -/// set-bound entry pins the set as soon as a verifier asks for more than ECDSA. A -/// request for ECDSA alone, which is what an empty request resolves to, pins -/// neither the set nor the role. +/// the payload type, and a verifier cannot read it as evidence of either. EIP-712 +/// is an EVM and secp256k1 construction that a post-quantum scheme has no reason +/// to be bound to, so the asymmetry stays. It costs less than it appears to, +/// because `ensure_requested_verified` requires every requested scheme to verify, +/// so a set-bound entry pins the set as soon as a verifier asks for more than +/// ECDSA. A request for ECDSA alone, which is what an empty request resolves to, +/// pins nothing. /// /// `schemes` may be given in any order; the entries come back ordered by /// scheme. #[cfg(feature = "non-wasm")] -pub fn sign_result_entries( +pub fn sign_result_entries( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], dsep: &DomainSep, eip712_hash: &[u8], - payload_bytes: &[u8], -) -> Result, SigningError> { + payload: &T, +) -> Result, SigningError> +where + T: Serialize + Versionize + Named, +{ if schemes.is_empty() { return Ok(Vec::new()); } let schemes = canonical_schemes(schemes)?; // Every non-ECDSA entry commits to the scheme set, so one cannot be lifted // out of a larger response and presented as a complete smaller one. - let signed = scheme_bound_preimage(&schemes, CompositeRole::Result, payload_bytes)?; + let signed = scheme_bound_preimage(&schemes, payload)?; schemes .iter() .map(|&scheme| { diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 235ce50de4..60bc240608 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -96,6 +96,8 @@ pub enum SigningError { expected = SigningSchemeType::VARIANTS.join(", ") )] UnknownSchemeName(String), + #[error("could not serialize the signed payload: {0}")] + Serialization(String), /// A set of signing schemes was empty, which would make "every signature /// verified" vacuously true. #[error("a signing scheme set must name at least one scheme")] diff --git a/core/service/src/cryptography/zeroizing_writer.rs b/core/service/src/cryptography/zeroizing_writer.rs index 35c004ec96..17b3021d14 100644 --- a/core/service/src/cryptography/zeroizing_writer.rs +++ b/core/service/src/cryptography/zeroizing_writer.rs @@ -24,6 +24,11 @@ impl ZeroizingWriter { &self.buf } + /// Take the bytes written so far, still wiped on drop. + pub(crate) fn into_inner(self) -> Zeroizing> { + self.buf + } + /// Grow without releasing an unwiped allocation. fn grow_for(&mut self, additional: usize) { let required = self.buf.len() + additional; diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index 77cd9070a3..c12b7da367 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -1,5 +1,5 @@ pub use super::signed_payload::UserDecSignedPayload; -use super::signed_payload::{signed_payload_bytes, user_dec_payload_bytes}; +use super::signed_payload::user_dec_payload; use super::traits::BaseKms; use crate::consts::ID_LENGTH; use crate::consts::SAFE_SER_SIZE_LIMIT; @@ -307,23 +307,23 @@ impl Named for CrsSignedPayload { const NAME: &'static str = "CrsSignedPayload"; } -/// The canonical bytes a non-ECDSA scheme signs for a keygen result. +/// What every non-ECDSA scheme signs for a keygen result. /// /// Shared between signing and after-the-fact verification (see /// [`crate::engine::storage_material_verification`]) so there is exactly one definition of /// what was signed. -pub fn keygen_payload_bytes( +pub fn keygen_payload( prep_id: &RequestId, key_id: &RequestId, key_digests: &BTreeMap>, extra_data: &[u8], -) -> anyhow::Result> { - signed_payload_bytes(&KeygenSignedPayload { +) -> KeygenSignedPayload { + KeygenSignedPayload { prep_id: *prep_id, key_id: *key_id, key_digests: key_digests.clone(), extra_data: extra_data.to_vec(), - }) + } } /// The result payload that every non-ECDSA scheme signs for a public decryption @@ -347,45 +347,39 @@ impl Named for PublicDecSignedPayload { const NAME: &'static str = "PublicDecSignedPayload"; } -/// The canonical bytes a non-ECDSA scheme signs for a public decryption result. -pub fn public_dec_payload_bytes( - response_bytes: &[u8], - extra_data: &[u8], -) -> anyhow::Result> { - signed_payload_bytes(&PublicDecSignedPayload { +/// What every non-ECDSA scheme signs for a public decryption result. +pub fn public_dec_payload(response_bytes: &[u8], extra_data: &[u8]) -> PublicDecSignedPayload { + PublicDecSignedPayload { response_bytes: response_bytes.to_vec(), extra_data: extra_data.to_vec(), - }) + } } -/// The canonical bytes a non-ECDSA scheme signs for a preprocessing result. -pub(crate) fn preproc_payload_bytes( - prep_id: &RequestId, - extra_data: &[u8], -) -> anyhow::Result> { - signed_payload_bytes(&PrepKeygenSignedPayload { +/// What every non-ECDSA scheme signs for a preprocessing result. +pub(crate) fn preproc_payload(prep_id: &RequestId, extra_data: &[u8]) -> PrepKeygenSignedPayload { + PrepKeygenSignedPayload { prep_id: *prep_id, extra_data: extra_data.to_vec(), - }) + } } -/// The canonical bytes a non-ECDSA scheme signs for a CRS result. +/// What every non-ECDSA scheme signs for a CRS result. /// /// Shared between signing and after-the-fact verification (see /// [`crate::engine::storage_material_verification`]) so there is exactly one definition of /// what was signed. -pub fn crs_payload_bytes( +pub fn crs_payload( crs_id: &RequestId, max_num_bits: u32, crs_digest: &[u8], extra_data: &[u8], -) -> anyhow::Result> { - signed_payload_bytes(&CrsSignedPayload { +) -> CrsSignedPayload { + CrsSignedPayload { crs_id: *crs_id, max_num_bits, crs_digest: crs_digest.to_vec(), extra_data: extra_data.to_vec(), - }) + } } pub(crate) const ERR_INVALID_CURRENT_PUBLIC_KEY_SHAPE: &str = @@ -498,14 +492,18 @@ pub(crate) fn stored_scheme_signatures_to_proto( /// /// `external_signature` is produced whether or not ECDSA was requested, because /// it is part of the released wire contract until it goes away in 0.16. -fn sign_result( +fn sign_result( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], - payload_bytes: &[u8], + payload: &T, sol_type: &D, domain: &Eip712Domain, dsep: &DomainSep, -) -> anyhow::Result<(Vec, Vec)> { +) -> anyhow::Result<(Vec, Vec)> +where + D: SolStruct, + T: serde::Serialize + tfhe::Versionize + tfhe::named::Named, +{ let eip712_hash = sol_type.eip712_signing_hash(domain); let external_signature = crate::cryptography::signatures::eip712_sign_hash(identity.ecdsa(), &eip712_hash)?; @@ -514,7 +512,7 @@ fn sign_result( schemes, dsep, eip712_hash.as_slice(), - payload_bytes, + payload, )?; Ok((external_signature, signatures)) } @@ -552,11 +550,11 @@ pub(crate) fn compute_info_crs_from_digest( extra_data: Vec, ) -> anyhow::Result { let sol_type = crs_sol_type(crs_id, &crs_digest, max_num_bits as u32, &extra_data); - let payload_bytes = crs_payload_bytes(crs_id, max_num_bits as u32, &crs_digest, &extra_data)?; + let payload = crs_payload(crs_id, max_num_bits as u32, &crs_digest, &extra_data); let (external_signature, signatures) = sign_result( identity, schemes, - &payload_bytes, + &payload, &sol_type, domain, &DSEP_PUBDATA_CRS, @@ -581,12 +579,12 @@ pub(crate) fn compute_preprocessing_signatures( domain: &alloy_sol_types::Eip712Domain, extra_data: Vec, ) -> anyhow::Result<(Vec, Vec)> { - let payload_bytes = preproc_payload_bytes(prep_id, &extra_data)?; + let payload = preproc_payload(prep_id, &extra_data); let sol_type = PrepKeygenVerification::new(prep_id, extra_data); sign_result( identity, schemes, - &payload_bytes, + &payload, &sol_type, domain, &DSEP_PUBDATA_KEY, @@ -647,11 +645,11 @@ pub(crate) fn compute_info_keygen_from_digests( extra_data: Vec, ) -> anyhow::Result { let sol_type = keygen_sol_type(layout, prep_id, key_id, &key_digests, &extra_data)?; - let payload_bytes = keygen_payload_bytes(prep_id, key_id, &key_digests, &extra_data)?; + let payload = keygen_payload(prep_id, key_id, &key_digests, &extra_data); let (external_signature, signatures) = sign_result( identity, schemes, - &payload_bytes, + &payload, &sol_type, domain, &DSEP_PUBDATA_KEY, @@ -713,11 +711,11 @@ pub(crate) fn compute_info_decompression_keygen( extraData: extra_data.clone().into(), }; let key_digests = BTreeMap::from([(PubDataType::DecompressionKey, key_digest)]); - let payload_bytes = keygen_payload_bytes(prep_id, key_id, &key_digests, &extra_data)?; + let payload = keygen_payload(prep_id, key_id, &key_digests, &extra_data); let (external_signature, signatures) = sign_result( identity, schemes, - &payload_bytes, + &payload, &sol_type, domain, &DSEP_PUBDATA_KEY, @@ -1037,7 +1035,7 @@ pub(crate) fn sign_public_decryption_result( &sol_type, eip712_domain, &DSEP_PUBLIC_DECRYPTION, - public_dec_payload_bytes, + public_dec_payload, ) } @@ -1061,7 +1059,7 @@ pub(crate) fn sign_user_decryption_result( &sol_type, eip712_domain, &crate::engine::validation::DSEP_USER_DECRYPTION, - user_dec_payload_bytes, + user_dec_payload, ) } @@ -1072,7 +1070,7 @@ pub(crate) fn sign_user_decryption_result( /// signature covers `bc2wrap::serialize` of the response payload alone, because /// those exact bytes are part of the released wire contract. #[expect(clippy::too_many_arguments)] -fn sign_decryption_result( +fn sign_decryption_result( server_sk: &NodeSigningIdentity, schemes: &[SigningSchemeType], payload: P, @@ -1080,19 +1078,18 @@ fn sign_decryption_result( sol_type: &D, eip712_domain: &Eip712Domain, dsep: &DomainSep, - signed_payload: fn(&[u8], &[u8]) -> anyhow::Result>, -) -> anyhow::Result> { + signed_payload: fn(&[u8], &[u8]) -> T, +) -> anyhow::Result> +where + P: Serialize, + D: SolStruct, + T: Serialize + tfhe::Versionize + tfhe::named::Named, +{ let response_bytes = bc2wrap::serialize(&payload)?; let signature = internal_sign(dsep, &response_bytes, server_sk.ecdsa())?.to_bytes(); - let payload_bytes = signed_payload(&response_bytes, &extra_data)?; - let (external_signature, stored) = sign_result( - server_sk, - schemes, - &payload_bytes, - sol_type, - eip712_domain, - dsep, - )?; + let signed = signed_payload(&response_bytes, &extra_data); + let (external_signature, stored) = + sign_result(server_sk, schemes, &signed, sol_type, eip712_domain, dsep)?; Ok(DecryptionCallValues { payload, signature, diff --git a/core/service/src/engine/signed_payload.rs b/core/service/src/engine/signed_payload.rs index fe278b27a5..8611af798d 100644 --- a/core/service/src/engine/signed_payload.rs +++ b/core/service/src/engine/signed_payload.rs @@ -1,32 +1,14 @@ -//! The user decryption result in the form non-ECDSA schemes sign it, and the -//! serialization every signed result payload uses. +//! The user decryption result in the form non-ECDSA schemes sign it. //! //! A wasm client verifies user decryption results, and [`super::base`] is not compiled //! for wasm, so this module holds the user decryption payload. The payloads of the other //! result kinds are in `base`. -use crate::consts::SAFE_SER_SIZE_LIMIT; use serde::{Deserialize, Serialize}; use tfhe::Versionize; use tfhe::named::Named; -use tfhe::safe_serialization::safe_serialize; use tfhe_versionable::VersionsDispatch; -/// The canonical bytes a non-ECDSA scheme signs for a public result. -/// -/// Serialized with `safe_serialize`, so the type name and version are part of -/// what gets signed: changing a payload's layout later produces a new version -/// tag rather than silently making old signatures unverifiable against the new -/// reconstruction. -pub(crate) fn signed_payload_bytes(payload: &T) -> anyhow::Result> -where - T: Serialize + Versionize + Named, -{ - let mut buf = Vec::new(); - safe_serialize(payload, &mut buf, SAFE_SER_SIZE_LIMIT)?; - Ok(buf) -} - /// The result payload that every non-ECDSA scheme signs for a user decryption /// result. #[derive(Clone, Serialize, Deserialize, VersionsDispatch)] @@ -46,15 +28,12 @@ impl Named for UserDecSignedPayload { const NAME: &'static str = "UserDecSignedPayload"; } -/// The canonical bytes a non-ECDSA scheme signs for a user decryption result. +/// What every non-ECDSA scheme signs for a user decryption result. /// -/// See [`super::base::public_dec_payload_bytes`]; this is the user-decryption twin. -pub(crate) fn user_dec_payload_bytes( - response_bytes: &[u8], - extra_data: &[u8], -) -> anyhow::Result> { - signed_payload_bytes(&UserDecSignedPayload { +/// See [`super::base::public_dec_payload`]; this is the user-decryption twin. +pub(crate) fn user_dec_payload(response_bytes: &[u8], extra_data: &[u8]) -> UserDecSignedPayload { + UserDecSignedPayload { response_bytes: response_bytes.to_vec(), extra_data: extra_data.to_vec(), - }) + } } diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index 11af0bd6ab..612f801fde 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -45,7 +45,7 @@ use crate::cryptography::signing::{ use crate::engine::base::{ CrsGenMetadata, CrsGenMetadataInner, CurrentPublicMaterialLayout, DSEP_PUBDATA_CRS, DSEP_PUBDATA_KEY, KeyGenMetadata, KeyGenMetadataInner, classify_current_public_material, - crs_payload_bytes, crs_sol_type, keygen_payload_bytes, keygen_sol_type, + crs_payload, crs_sol_type, keygen_payload, keygen_sol_type, }; use crate::engine::context::ContextInfo; use crate::engine::material_integrity::{ @@ -407,13 +407,12 @@ fn verify_keygen_metadata_signature( identity: &NodeSigningIdentity, ) -> anyhow::Result<()> { let extra_data = metadata.extra_data.as_deref().unwrap_or_default(); - let payload_bytes = keygen_payload_bytes( + let payload = keygen_payload( &metadata.preprocessing_id, &metadata.key_id, &metadata.key_digest_map, extra_data, - ) - .map_err(|e| anyhow::anyhow!("Invalid private keygen metadata for id={key_id}: {e}"))?; + ); // TODO(github.com/zama-ai/kms-internal/issues/3201) // After we upgrade to 0.16 (and have migrated keys in 0.15), @@ -453,7 +452,7 @@ fn verify_keygen_metadata_signature( &metadata.signatures, eip712_hash.as_ref(), &DSEP_PUBDATA_KEY, - &payload_bytes, + &payload, identity, ) } @@ -470,13 +469,12 @@ fn verify_crs_metadata_signature( identity: &NodeSigningIdentity, ) -> anyhow::Result<()> { let extra_data = metadata.extra_data.as_deref().unwrap_or_default(); - let payload_bytes = crs_payload_bytes( + let payload = crs_payload( &metadata.crs_id, metadata.max_num_bits, &metadata.crs_digest, extra_data, - ) - .map_err(|e| anyhow::anyhow!("Invalid private CRS metadata for id={crs_id}: {e}"))?; + ); // TODO(github.com/zama-ai/kms-internal/issues/3201) // After we can retire the default epoch, we can remove the backward compatibility support @@ -507,7 +505,7 @@ fn verify_crs_metadata_signature( &metadata.signatures, eip712_hash.as_ref(), &DSEP_PUBDATA_CRS, - &payload_bytes, + &payload, identity, ) } @@ -516,21 +514,24 @@ fn verify_crs_metadata_signature( /// /// Both ECDSA forms, the `external_signature` and the ECDSA entry of `signatures`, /// recover their signer from `eip712_hash`, so they are skipped when no stored -/// domain yields one. Every other scheme signs `payload_bytes` under `dsep` and is +/// domain yields one. Every other scheme signs `payload` under `dsep` and is /// checked against the key `identity` derives for it. A scheme `identity` cannot /// derive a key for is an error rather than a skip: an entry nobody can check must /// not pass as one that was checked. #[expect(clippy::too_many_arguments)] -fn verify_metadata_signatures( +fn verify_metadata_signatures( metadata_kind: &str, metadata_id: &RequestId, external_signature: &[u8], signatures: &[StoredTypedSignature], eip712_hash: Option<&B256>, dsep: &DomainSep, - payload_bytes: &[u8], + payload: &T, identity: &NodeSigningIdentity, -) -> anyhow::Result<()> { +) -> anyhow::Result<()> +where + T: serde::Serialize + tfhe::Versionize + tfhe::named::Named, +{ let expected_address = identity.verf_key().address(); if let Some(hash) = eip712_hash { verify_eip712_metadata_signature( @@ -551,11 +552,8 @@ fn verify_metadata_signatures( // The set is derived from the stored entries rather than requested from // outside: at boot there is no request to measure against. let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); - let signed_bytes = crate::cryptography::signing::composite::scheme_bound_preimage( - &stored_schemes, - crate::cryptography::signing::composite::CompositeRole::Result, - payload_bytes, - )?; + let signed_bytes = + crate::cryptography::signing::composite::scheme_bound_preimage(&stored_schemes, payload)?; for stored in signatures { match stored.scheme { diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 25d2cf001b..a501224895 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -9,7 +9,7 @@ use crate::{ signatures::PublicSigKey, signing::{SchemeVerfKeys, SigningSchemeType}, }, - engine::base::{compute_public_decryption_message, public_dec_payload_bytes}, + engine::base::{compute_public_decryption_message, public_dec_payload}, engine::validation_wasm::{ ExpectedSigner, ResponseSignatures, SignedPayloads, verify_response_signatures, }, @@ -483,7 +483,7 @@ fn check_public_decrypt_signatures( // NOTE that we cannot use `BaseKmsStruct::verify_sig` // because `BaseKmsStruct` cannot be compiled for wasm (it has an async mutex). let response_bytes = bc2wrap::serialize(&response)?; - let payload_bytes = public_dec_payload_bytes(&response_bytes, response_extra_data)?; + let payload = public_dec_payload(&response_bytes, response_extra_data); // Built only when a domain is available: without one no ECDSA signature of this // response can be checked, and the message would be of no use. @@ -508,7 +508,7 @@ fn check_public_decrypt_signatures( &SignedPayloads { dsep: &DSEP_PUBLIC_DECRYPTION, internal_bytes: &response_bytes, - payload_bytes: &payload_bytes, + payload: &payload, eip712_hash, }, &requested, diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 7b0276358d..2f2617b619 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -7,7 +7,7 @@ use crate::{ signatures::{PublicSigKey, Signature, internal_verify_sig}, signing::{SchemeVerfKeys, SigningSchemeType, unified_verify, verf_key_for}, }, - engine::signed_payload::user_dec_payload_bytes, + engine::signed_payload::user_dec_payload, }; use alloy_dyn_abi::Eip712Domain; use alloy_primitives::{Address, B256}; @@ -188,7 +188,7 @@ pub(crate) fn user_decrypt_eip712_hash( /// scheme without producing a valid signature for it. /// /// ECDSA is not handled here — it signs the EIP-712 hash rather than -/// `payload_bytes`, and what that hash covers differs per result kind. +/// `signed_bytes`, and what that hash covers differs per result kind. /// /// The error is returned **unlogged**: whether a failure here is a fault or an /// expected Byzantine rejection is the caller's to know, and so is the level it @@ -268,14 +268,14 @@ pub(crate) struct ResponseSignatures<'a> { } /// What each signature of a [`ResponseSignatures`] covers. -pub(crate) struct SignedPayloads<'a> { +pub(crate) struct SignedPayloads<'a, T> { /// Domain separator of the raw and the per-scheme signatures. pub dsep: &'a DomainSep, /// The serialized response payload, which the deprecated internal signature covers. /// Unused when [`ResponseSignatures::internal`] is empty. pub internal_bytes: &'a [u8], - /// The versioned payload every non-ECDSA scheme covers. - pub payload_bytes: &'a [u8], + /// The payload every non-ECDSA scheme covers. + pub payload: &'a T, /// The EIP-712 signing hash both ECDSA signatures recover from, or `None` when no /// domain is available and neither can therefore be checked. pub eip712_hash: Option, @@ -353,22 +353,14 @@ fn push_once(verified: &mut Vec, scheme: SigningSchemeType) { fn attribute_scheme_entry( signature: &[u8], scheme: SigningSchemeType, - payloads: &SignedPayloads, + dsep: &DomainSep, signed_bytes: &[u8], expected: &ExpectedSigner, keys: &SchemeVerfKeys, signer: Option<(u32, Address)>, ) -> anyhow::Result<(u32, Address)> { - let check = |party_id: u32| { - verify_scheme_entry( - keys, - party_id, - scheme, - signature, - payloads.dsep, - signed_bytes, - ) - }; + let check = + |party_id: u32| verify_scheme_entry(keys, party_id, scheme, signature, dsep, signed_bytes); match expected { ExpectedSigner::Known { party_id, address, .. @@ -388,7 +380,7 @@ fn attribute_scheme_entry( keys.iter() .find_map(|(party_id, party_keys)| { let verf_key = party_keys.get(scheme)?; - unified_verify(payloads.dsep, signed_bytes, &parsed, verf_key).ok()?; + unified_verify(dsep, signed_bytes, &parsed, verf_key).ok()?; addresses.get(party_id).map(|address| (*party_id, *address)) }) .ok_or_else(|| { @@ -429,13 +421,16 @@ fn attribute_scheme_entry( /// /// The error is returned **unlogged**: whether a failure here is a fault or an expected /// Byzantine rejection is the caller's to know, and so is the level it deserves. -pub(crate) fn verify_response_signatures( +pub(crate) fn verify_response_signatures( sigs: &ResponseSignatures, - payloads: &SignedPayloads, + payloads: &SignedPayloads, requested: &[SigningSchemeType], expected: &ExpectedSigner, keys: &SchemeVerfKeys, -) -> anyhow::Result<(u32, Address)> { +) -> anyhow::Result<(u32, Address)> +where + T: serde::Serialize + tfhe::Versionize + tfhe::named::Named, +{ if requested.is_empty() { return Err(anyhow_tracked( "the response was measured against no signing scheme at all, which any signature would \ @@ -446,12 +441,9 @@ pub(crate) fn verify_response_signatures( // Bound to the set this verifier requested, so an entry lifted from a // response signed under a larger set does not verify here. The ECDSA entry is // the exception; see `sign_result_entries`. - let signed_bytes = crate::cryptography::signing::composite::scheme_bound_preimage( - requested, - crate::cryptography::signing::composite::CompositeRole::Result, - payloads.payload_bytes, - ) - .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))?; + let signed_bytes = + crate::cryptography::signing::composite::scheme_bound_preimage(requested, payloads.payload) + .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))?; let mut verified: Vec = Vec::with_capacity(sigs.list.len() + 1); let mut signer: Option<(u32, Address)> = None; @@ -540,7 +532,7 @@ pub(crate) fn verify_response_signatures( attribute_scheme_entry( &typed.signature, scheme, - payloads, + payloads.dsep, &signed_bytes, expected, keys, @@ -616,10 +608,7 @@ fn authenticate_user_decrypt_and_check_meta_data( &SignedPayloads { dsep: &DSEP_USER_DECRYPTION, internal_bytes: &response_bytes, - payload_bytes: &user_dec_payload_bytes( - &response_bytes, - eip712_params.response_extra_data, - )?, + payload: &user_dec_payload(&response_bytes, eip712_params.response_extra_data), eip712_hash: Some(user_decrypt_eip712_hash( response, trusted_ctx.client_request, From 512aa94c31c4fe2b77649da0d23fb1053b288b8e Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 11:00:30 +0200 Subject: [PATCH 054/114] fix: imports --- core-client/src/crsgen.rs | 6 +- core-client/src/decrypt.rs | 8 +- core-client/src/keygen.rs | 6 +- core/service/src/client/client_non_wasm.rs | 104 +++++++++++------- .../client/tests/centralized/crs_gen_tests.rs | 7 +- .../src/cryptography/signing/composite.rs | 75 +++++++------ core/service/src/engine/base.rs | 41 ++++--- .../service/src/engine/validation_non_wasm.rs | 7 +- core/service/src/engine/validation_wasm.rs | 6 +- 9 files changed, 146 insertions(+), 114 deletions(-) diff --git a/core-client/src/crsgen.rs b/core-client/src/crsgen.rs index fc45d3b784..13b8a10e44 100644 --- a/core-client/src/crsgen.rs +++ b/core-client/src/crsgen.rs @@ -12,7 +12,7 @@ use kms_grpc::rpc_types::{PubDataType, first_signature_with_scheme}; use kms_grpc::{ContextId, EpochId, RequestId}; use kms_lib::client::client_wasm::Client; use kms_lib::client::local_crypto::load_material_from_pub_storage; -use kms_lib::engine::base::{DSEP_PUBDATA_CRS, crs_payload_bytes, crs_sol_type}; +use kms_lib::engine::base::{DSEP_PUBDATA_CRS, crs_payload, crs_sol_type}; use std::collections::HashMap; use std::path::Path; use tfhe::zk::CompactPkeCrs; @@ -365,7 +365,7 @@ pub(crate) fn check_crsgen_signatures( let max_num_bits = max_num_bits_from_crs(crs) as u32; let sol_type = crs_sol_type(crs_id, &crs_digest, max_num_bits, &extra_data); - let payload_bytes = crs_payload_bytes(crs_id, max_num_bits, &crs_digest, &extra_data)?; + let signed_payload = crs_payload(crs_id, max_num_bits, &crs_digest, &extra_data); internal_client .verify_result_signatures( signatures, @@ -373,7 +373,7 @@ pub(crate) fn check_crsgen_signatures( &sol_type, domain, &DSEP_PUBDATA_CRS, - &payload_bytes, + &signed_payload, ) .map(|(party_id, _address)| { tracing::info!("CRS gen result verified as produced by party {party_id}"); diff --git a/core-client/src/decrypt.rs b/core-client/src/decrypt.rs index 8a810a97d9..8876a6794c 100644 --- a/core-client/src/decrypt.rs +++ b/core-client/src/decrypt.rs @@ -16,9 +16,7 @@ use kms_lib::{ user_decryption_wasm::ParsedUserDecryptionRequest, }, cryptography::encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}, - engine::base::{ - DSEP_PUBLIC_DECRYPTION, compute_public_decryption_message, public_dec_payload_bytes, - }, + engine::base::{DSEP_PUBLIC_DECRYPTION, compute_public_decryption_message, public_dec_payload}, }; use prost::Message as _; use rand::{CryptoRng, Rng}; @@ -242,7 +240,7 @@ fn check_pt_signatures( ); let sol_type = compute_public_decryption_message(external_handles, &payload.plaintexts, extra_data)?; - let payload_bytes = public_dec_payload_bytes(&bc2wrap::serialize(payload)?, extra_data)?; + let signed_payload = public_dec_payload(&bc2wrap::serialize(payload)?, extra_data); let (party_id, address) = internal_client.verify_result_signatures( &response.signatures, @@ -250,7 +248,7 @@ fn check_pt_signatures( &sol_type, domain, &DSEP_PUBLIC_DECRYPTION, - &payload_bytes, + &signed_payload, )?; // check that the address is in the list of known KMS addresses diff --git a/core-client/src/keygen.rs b/core-client/src/keygen.rs index b4ede8d0d4..807c26be37 100644 --- a/core-client/src/keygen.rs +++ b/core-client/src/keygen.rs @@ -16,7 +16,7 @@ use kms_grpc::{ContextId, RequestId}; use kms_lib::client::client_wasm::Client; use kms_lib::client::local_crypto::{load_material_from_pub_storage, load_pk_from_pub_storage}; use kms_lib::engine::base::{ - CurrentPublicMaterialLayout, DSEP_PUBDATA_KEY, keygen_payload_bytes, keygen_sol_type, + CurrentPublicMaterialLayout, DSEP_PUBDATA_KEY, keygen_payload, keygen_sol_type, }; use std::collections::{BTreeMap, HashMap}; use std::path::Path; @@ -574,7 +574,7 @@ fn check_keyset_signatures( extra_data: Vec, ) -> anyhow::Result<()> { let sol_type = keygen_sol_type(layout, prep_id, key_id, &key_digests, &extra_data)?; - let payload_bytes = keygen_payload_bytes(prep_id, key_id, &key_digests, &extra_data)?; + let payload = keygen_payload(prep_id, key_id, &key_digests, &extra_data); internal_client .verify_result_signatures( signatures, @@ -582,7 +582,7 @@ fn check_keyset_signatures( &sol_type, domain, &DSEP_PUBDATA_KEY, - &payload_bytes, + &payload, ) .map(|(party_id, _address)| { tracing::info!("Keygen result verified as produced by party {party_id}"); diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index 4b4a7aca08..3419ad4fb7 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -201,6 +201,7 @@ mod tests { use crate::cryptography::signing::SigningError; use crate::cryptography::signing::composite::sign_result_entries; use crate::dummy_domain; + use crate::engine::base::CrsSignedPayload; use aes_prng::AesRng; use kms_grpc::RequestId; use kms_grpc::solidity_types::CrsgenVerification; @@ -208,7 +209,16 @@ mod tests { const DSEP: &DomainSep = b"CLNTTEST"; const PARTY: u32 = 1; - const PAYLOAD: &[u8] = b"the serialized result payload a non-ECDSA scheme signs"; + /// The result payload a non-ECDSA scheme signs, standing in for the + /// per-result-kind types the real callers pass. + fn payload() -> CrsSignedPayload { + CrsSignedPayload { + crs_id: RequestId::zeros(), + max_num_bits: 64, + crs_digest: vec![7u8; 32], + extra_data: vec![], + } + } fn seeded_identity(seed: u64) -> NodeSigningIdentity { let mut rng = AesRng::seed_from_u64(seed); @@ -261,7 +271,7 @@ mod tests { fn signatures_for( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], - payload: &[u8], + payload: &CrsSignedPayload, ) -> Vec { let eip712_hash = sol_type().eip712_signing_hash(&dummy_domain()); sign_result_entries(identity, schemes, DSEP, eip712_hash.as_slice(), payload) @@ -274,7 +284,7 @@ mod tests { fn verify( client: &Client, signatures: &[TypedSignature], - payload: &[u8], + payload: &CrsSignedPayload, ) -> anyhow::Result<(u32, alloy_primitives::Address)> { // No legacy signature is offered: these cases are about the list itself. verify_with_legacy(client, signatures, &[], payload) @@ -284,7 +294,7 @@ mod tests { client: &Client, signatures: &[TypedSignature], external_signature: &[u8], - payload: &[u8], + payload: &CrsSignedPayload, ) -> anyhow::Result<(u32, alloy_primitives::Address)> { client.verify_result_signatures( signatures, @@ -309,7 +319,7 @@ mod tests { let identity = seeded_identity(2); let client = client_for(&identity, &[]); - let err = verify(&client, &[], PAYLOAD).unwrap_err().to_string(); + let err = verify(&client, &[], &payload()).unwrap_err().to_string(); assert!( err.contains("carries no signatures"), "the error does not name the cause: {err}" @@ -324,9 +334,13 @@ mod tests { let identity = seeded_identity(12); let client = client_for(&identity, &[]); - let (party_id, address) = - verify_with_legacy(&client, &[], &legacy_external_signature(&identity), PAYLOAD) - .unwrap(); + let (party_id, address) = verify_with_legacy( + &client, + &[], + &legacy_external_signature(&identity), + &payload(), + ) + .unwrap(); assert_eq!(party_id, PARTY); assert_eq!(address, identity.verf_key().address()); } @@ -343,9 +357,14 @@ mod tests { &[SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa65], ); - let err = verify_with_legacy(&client, &[], &legacy_external_signature(&identity), PAYLOAD) - .unwrap_err() - .to_string(); + let err = verify_with_legacy( + &client, + &[], + &legacy_external_signature(&identity), + &payload(), + ) + .unwrap_err() + .to_string(); assert!( err.contains("MlDsa65") && err.contains("was requested"), "the error does not name the unanswered scheme: {err}" @@ -359,8 +378,8 @@ mod tests { let client = client_for(&identity, &[]); let stranger = legacy_external_signature(&seeded_identity(15)); - assert!(verify_with_legacy(&client, &[], &stranger, PAYLOAD).is_err()); - assert!(verify_with_legacy(&client, &[], &[0u8; 65], PAYLOAD).is_err()); + assert!(verify_with_legacy(&client, &[], &stranger, &payload()).is_err()); + assert!(verify_with_legacy(&client, &[], &[0u8; 65], &payload()).is_err()); } /// The legacy signature is checked *alongside* the list, not instead of it. A result @@ -370,26 +389,26 @@ mod tests { fn a_bad_legacy_signature_is_rejected_even_when_the_list_verifies() { let identity = seeded_identity(16); let client = client_for(&identity, &[]); - let signatures = signatures_for(&identity, &[SigningSchemeType::Ecdsa256k1], PAYLOAD); + let signatures = signatures_for(&identity, &[SigningSchemeType::Ecdsa256k1], &payload()); // Both copies present and agreeing is the honest case. let (party_id, _address) = verify_with_legacy( &client, &signatures, &legacy_external_signature(&identity), - PAYLOAD, + &payload(), ) .unwrap(); assert_eq!(party_id, PARTY); // A garbage legacy signature is a rejection, and so is one of another party. - assert!(verify_with_legacy(&client, &signatures, &[0xAA; 65], PAYLOAD).is_err()); + assert!(verify_with_legacy(&client, &signatures, &[0xAA; 65], &payload()).is_err()); assert!( verify_with_legacy( &client, &signatures, &legacy_external_signature(&seeded_identity(17)), - PAYLOAD, + &payload(), ) .is_err() ); @@ -401,9 +420,9 @@ mod tests { fn a_result_without_an_ecdsa_entry_is_attributed() { let identity = seeded_identity(3); let client = client_requesting(&identity, true, &[SigningSchemeType::MlDsa65]); - let signatures = signatures_for(&identity, &[SigningSchemeType::MlDsa65], PAYLOAD); + let signatures = signatures_for(&identity, &[SigningSchemeType::MlDsa65], &payload()); - let (party_id, _address) = verify(&client, &signatures, PAYLOAD).unwrap(); + let (party_id, _address) = verify(&client, &signatures, &payload()).unwrap(); assert_eq!(party_id, PARTY); } @@ -416,9 +435,17 @@ mod tests { // Ask for exactly the scheme under test, so the rejection can only come // from the signature check and not from a scheme left unverified. let client = client_requesting(&identity, true, &[scheme]); - let signatures = signatures_for(&identity, &[scheme], PAYLOAD); + let signatures = signatures_for(&identity, &[scheme], &payload()); assert!( - verify(&client, &signatures, b"a different payload").is_err(), + verify( + &client, + &signatures, + &CrsSignedPayload { + crs_digest: vec![8u8; 32], + ..payload() + } + ) + .is_err(), "the {scheme} entry verified a payload it does not cover" ); } @@ -457,18 +484,19 @@ mod tests { fn an_entry_of_an_unknown_scheme_is_skipped() { let identity = seeded_identity(20); let client = client_for(&identity, &[]); - let mut signatures = signatures_for(&identity, &[SigningSchemeType::Ecdsa256k1], PAYLOAD); + let mut signatures = + signatures_for(&identity, &[SigningSchemeType::Ecdsa256k1], &payload()); signatures.push(TypedSignature { scheme: i32::MAX, signature: vec![0xEE; 64], }); assert_eq!( - verify(&client, &signatures, PAYLOAD).unwrap(), + verify(&client, &signatures, &payload()).unwrap(), (PARTY, identity.verf_key().address()) ); // On its own the unknown entry authenticates nothing. - assert!(verify(&client, &signatures[1..], PAYLOAD).is_err()); + assert!(verify(&client, &signatures[1..], &payload()).is_err()); } /// Another party's signatures are not accepted as this party's. @@ -479,10 +507,10 @@ mod tests { let signatures = signatures_for( &seeded_identity(6), &[SigningSchemeType::Ecdsa256k1], - PAYLOAD, + &payload(), ); - let err = verify(&client, &signatures, PAYLOAD) + let err = verify(&client, &signatures, &payload()) .unwrap_err() .to_string(); assert!(err.contains("belongs to no known party"), "{err}"); @@ -496,15 +524,15 @@ mod tests { let requested = [SigningSchemeType::Ecdsa256k1, SigningSchemeType::Ed25519]; // ECDSA identifies the party before Ed25519 encounters its missing key. let client = client_requesting(&identity, false, &requested); - let signatures = signatures_for(&identity, &requested, PAYLOAD); + let signatures = signatures_for(&identity, &requested, &payload()); assert_eq!( - verify(&client_for(&identity, &[]), &signatures[..1], PAYLOAD) + verify(&client_for(&identity, &[]), &signatures[..1], &payload()) .unwrap() .0, PARTY ); - let err = verify(&client, &signatures, PAYLOAD) + let err = verify(&client, &signatures, &payload()) .unwrap_err() .to_string(); assert!( @@ -524,11 +552,11 @@ mod tests { // Both lists are signed under the whole requested set, so what is left // for the verifier to object to is the two parties - let mine = signatures_for(&identity, &requested, PAYLOAD); - let theirs = signatures_for(&other, &requested, PAYLOAD); + let mine = signatures_for(&identity, &requested, &payload()); + let theirs = signatures_for(&other, &requested, &payload()); let signatures = vec![mine[0].clone(), theirs[1].clone()]; - assert!(verify(&client, &signatures, PAYLOAD).is_err()); + assert!(verify(&client, &signatures, &payload()).is_err()); } /// Every requested scheme must verify, even when other entries verify. @@ -552,9 +580,9 @@ mod tests { client.set_signing_schemes(&requested).unwrap(); } // Signed under exactly the set offered, not a larger list - let offered = signatures_for(&identity, &offered, PAYLOAD); + let offered = signatures_for(&identity, &offered, &payload()); assert_eq!( - verify(&client, &offered, PAYLOAD).unwrap(), + verify(&client, &offered, &payload()).unwrap(), (PARTY, identity.verf_key().address()), "{case}" ); @@ -565,7 +593,9 @@ mod tests { .filter(|typed| typed.scheme != dropped.as_wire()) .cloned() .collect(); - let err = verify(&client, &stripped, PAYLOAD).unwrap_err().to_string(); + let err = verify(&client, &stripped, &payload()) + .unwrap_err() + .to_string(); assert!( err.contains(&dropped.to_string()) && err.contains("was requested"), "{case}: the error does not name the missing {dropped} scheme: {err}" @@ -596,7 +626,7 @@ mod tests { for legacy in [false, true] { let check = |signature: Vec| { if legacy { - verify_with_legacy(&client, &[], &signature, PAYLOAD) + verify_with_legacy(&client, &[], &signature, &payload()) } else { verify( &client, @@ -604,7 +634,7 @@ mod tests { scheme: SigningSchemeType::Ecdsa256k1.as_wire(), signature, }], - PAYLOAD, + &payload(), ) } }; diff --git a/core/service/src/client/tests/centralized/crs_gen_tests.rs b/core/service/src/client/tests/centralized/crs_gen_tests.rs index c257ef1a16..fb78516a17 100644 --- a/core/service/src/client/tests/centralized/crs_gen_tests.rs +++ b/core/service/src/client/tests/centralized/crs_gen_tests.rs @@ -140,13 +140,12 @@ async fn crs_gen_centralized_manual( crsDigest: actual_digest.to_vec().into(), extraData: ceremony_req.extra_data.clone().into(), }; - let payload_bytes = crate::engine::base::crs_payload_bytes( + let signed_payload = crate::engine::base::crs_payload( request_id, max_num_bits as u32, &actual_digest, &ceremony_req.extra_data, - ) - .unwrap(); + ); internal_client .verify_result_signatures( &resp.signatures, @@ -154,7 +153,7 @@ async fn crs_gen_centralized_manual( &sol_type, &domain, &DSEP_PUBDATA_CRS, - &payload_bytes, + &signed_payload, ) .unwrap(); diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index a7363cc639..b40f333572 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -287,11 +287,16 @@ mod tests { use super::*; use crate::cryptography::signatures::{SigningSchemeType, gen_sig_keys}; use crate::cryptography::signing::test_support::seeded_identity; + use crate::vault::storage::tests::TestType; use aes_prng::AesRng; use rand::SeedableRng; const DSEP: &DomainSep = b"COMPSIGT"; - const MSG: &[u8] = b"a message signed under several schemes at once"; + + /// A stand-in for the signed payload types the real callers pass. + fn msg() -> TestType { + TestType { i: 4711 } + } fn pair() -> Vec { vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87] @@ -310,12 +315,12 @@ mod tests { #[test] fn round_trip_sunshine() { let (identity, keys, schemes) = setup(1); - let sig = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + let sig = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); assert_eq!(entry_schemes(&sig), schemes); - verify_uniform(&sig, &keys, DSEP, MSG).unwrap(); + verify_uniform(&sig, &keys, DSEP, &msg()).unwrap(); let (_, other_keys, _) = setup(7); - assert!(verify_uniform(&sig, &other_keys, DSEP, MSG).is_err()); + assert!(verify_uniform(&sig, &other_keys, DSEP, &msg()).is_err()); } /// Removing a signature must not leave something that verifies under the @@ -324,14 +329,14 @@ mod tests { #[test] fn a_stripped_signature_is_rejected() { let (identity, keys, schemes) = setup(2); - let sig = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + let sig = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); // Drop the ML-DSA half and relabel the set as ECDSA-only let stripped = vec![sig[0].clone()]; // Against the original policy it is the wrong scheme set... assert!(matches!( - verify_uniform(&stripped, &keys, DSEP, MSG), + verify_uniform(&stripped, &keys, DSEP, &msg()), Err(SigningError::UnexpectedSchemeSet { .. }) )); @@ -341,13 +346,13 @@ mod tests { // *pair*, which does not match the single-scheme preimage. let ecdsa_only = VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); - assert!(verify_uniform(&stripped, &ecdsa_only, DSEP, MSG).is_err()); + assert!(verify_uniform(&stripped, &ecdsa_only, DSEP, &msg()).is_err()); // The same mismatch from the other side: the *whole* pair signature // against that ECDSA-only key set is refused for its shape rather than // verified on the one entry the set holds a key for. assert!(matches!( - verify_uniform(&sig, &ecdsa_only, DSEP, MSG), + verify_uniform(&sig, &ecdsa_only, DSEP, &msg()), Err(SigningError::UnexpectedSchemeSet { .. }) )); } @@ -356,7 +361,7 @@ mod tests { #[test] fn a_non_canonical_entry_list_is_rejected() { let (identity, keys, schemes) = setup(3); - let sig = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + let sig = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); let mut reversed = sig.clone(); reversed.reverse(); @@ -369,7 +374,7 @@ mod tests { ] { assert!( matches!( - verify_uniform(&entries, &keys, DSEP, MSG), + verify_uniform(&entries, &keys, DSEP, &msg()), Err(SigningError::UnexpectedSchemeSet { .. }) ), "a {case} entry list was not rejected" @@ -381,13 +386,13 @@ mod tests { #[test] fn one_tampered_signature_fails_the_composite() { let (identity, keys, schemes) = setup(4); - let base = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); + let base = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); for index in 0..base.len() { let mut tampered = base.clone(); tampered[index].signature[0] ^= 0x01; assert!( - verify_uniform(&tampered, &keys, DSEP, MSG).is_err(), + verify_uniform(&tampered, &keys, DSEP, &msg()).is_err(), "tampering with signature {index} was not detected" ); } @@ -396,9 +401,9 @@ mod tests { #[test] fn a_tampered_message_or_dsep_fails() { let (identity, keys, schemes) = setup(5); - let sig = sign_uniform(&identity, &schemes, DSEP, MSG).unwrap(); - assert!(verify_uniform(&sig, &keys, DSEP, b"a different message").is_err()); - assert!(verify_uniform(&sig, &keys, b"OTHERDSP", MSG).is_err()); + let sig = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); + assert!(verify_uniform(&sig, &keys, DSEP, &TestType { i: 4712 }).is_err()); + assert!(verify_uniform(&sig, &keys, b"OTHERDSP", &msg()).is_err()); } /// An identity with no root seed can only do ECDSA, so asking it for the @@ -408,7 +413,7 @@ mod tests { let mut rng = AesRng::seed_from_u64(9); let identity = NodeSigningIdentity::ecdsa_only(gen_sig_keys(&mut rng).1); assert!(matches!( - sign_uniform(&identity, &pair(), DSEP, MSG), + sign_uniform(&identity, &pair(), DSEP, &msg()), Err(SigningError::MissingRootSeed(_)) )); } @@ -425,11 +430,11 @@ mod tests { SigningSchemeType::MlDsa65, ]; let eip712_hash = [0x11u8; 32]; - let payload = b"the serialized result payload"; - let bound = scheme_bound_preimage(&schemes, payload).unwrap(); + let payload = msg(); + let bound = scheme_bound_preimage(&schemes, &payload).unwrap(); let entries = - sign_result_entries(&identity, &schemes, DSEP, &eip712_hash, payload).unwrap(); + sign_result_entries(&identity, &schemes, DSEP, &eip712_hash, &payload).unwrap(); assert_eq!(entries.len(), 3); for entry in &entries { @@ -444,8 +449,11 @@ mod tests { unified_verify(DSEP, &bound, &sig, &vk).unwrap_or_else(|e| { panic!("{:?} should sign the bound payload: {e}", entry.scheme) }); - // ...and neither the bare payload nor the EIP-712 hash. - assert!(unified_verify(DSEP, payload, &sig, &vk).is_err()); + // ...and neither the serialized payload alone nor the EIP-712 hash. + let mut bare = Vec::new(); + tfhe::safe_serialization::safe_serialize(&payload, &mut bare, SAFE_SER_SIZE_LIMIT) + .unwrap(); + assert!(unified_verify(DSEP, &bare, &sig, &vk).is_err()); assert!(unified_verify(DSEP, &eip712_hash, &sig, &vk).is_err()); } } @@ -465,7 +473,7 @@ mod tests { let canonical = canonical_schemes(&requested).unwrap(); let entries = - sign_result_entries(&identity, &requested, DSEP, &[0x11u8; 32], b"payload").unwrap(); + sign_result_entries(&identity, &requested, DSEP, &[0x11u8; 32], &msg()).unwrap(); assert_eq!( entries.iter().map(|e| e.scheme).collect::>(), @@ -480,7 +488,7 @@ mod tests { let mut rng = AesRng::seed_from_u64(21); let identity = seeded_identity(&mut rng); assert!( - sign_result_entries(&identity, &[], DSEP, &[0u8; 32], b"payload") + sign_result_entries(&identity, &[], DSEP, &[0u8; 32], &msg()) .unwrap() .is_empty() ); @@ -492,16 +500,19 @@ mod tests { fn preimages_separate_scheme_sets() { let single = vec![SigningSchemeType::Ecdsa256k1]; assert_ne!( - scheme_bound_preimage(&single, MSG).unwrap(), - scheme_bound_preimage(&pair(), MSG).unwrap() + scheme_bound_preimage(&single, &msg()).unwrap(), + scheme_bound_preimage(&pair(), &msg()).unwrap() ); - // The length prefix is what separates a set from a longer one starting - // with it. + // The count in front of the tags is what separates a set from a longer + // one starting with it, so the shorter set's encoding appears nowhere in + // the longer set's preimage. + let pair_preimage = scheme_bound_preimage(&pair(), &msg()).unwrap(); + let single_scheme_bytes = canonical_scheme_bytes(&single); assert!( - !scheme_bound_preimage(&pair(), MSG) - .unwrap() - .starts_with(&canonical_scheme_bytes(&single)) + !pair_preimage + .windows(single_scheme_bytes.len()) + .any(|window| window == single_scheme_bytes) ); } @@ -518,8 +529,8 @@ mod tests { // So callers may pass any order and still agree on the signed bytes. assert_eq!( - scheme_bound_preimage(&reordered, MSG).unwrap(), - scheme_bound_preimage(&pair(), MSG).unwrap() + scheme_bound_preimage(&reordered, &msg()).unwrap(), + scheme_bound_preimage(&pair(), &msg()).unwrap() ); assert!(matches!( diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index c12b7da367..76eba44f05 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -2000,20 +2000,21 @@ pub(crate) mod tests { assert_ne!(scheme_sig.signature, sigs.signature); } else { // Every other scheme signs the versioned payload — the response - // bytes together with the extra data — prefixed by the scheme - // set, so the entry commits to the set it was produced under. - let payload_signed = - super::public_dec_payload_bytes(&payload_bytes, extra_data).unwrap(); + // bytes together with the extra data — inside a preimage naming + // the scheme set, so the entry commits to the set it was + // produced under. + let payload_signed = super::public_dec_payload(&payload_bytes, extra_data); let signed = scheme_bound_preimage(&ordered, &payload_signed).unwrap(); let vk = sk.unified_verifying_key(*scheme).unwrap(); let sig = Signature::new(*scheme, scheme_sig.signature.clone()); unified_verify(&DSEP_PUBLIC_DECRYPTION, &signed, &sig, &vk) .unwrap_or_else(|e| panic!("{scheme:?} signature should verify: {e}")); - // The unprefixed payload is specifically not what was signed. + // The payload on its own is specifically not what was signed. + let mut bare = Vec::new(); + safe_serialize(&payload_signed, &mut bare, SAFE_SER_SIZE_LIMIT).unwrap(); assert!( - unified_verify(&DSEP_PUBLIC_DECRYPTION, &payload_signed, &sig, &vk) - .is_err(), + unified_verify(&DSEP_PUBLIC_DECRYPTION, &bare, &sig, &vk).is_err(), "{scheme:?} signature must be bound to the scheme set" ); @@ -2034,8 +2035,7 @@ pub(crate) mod tests { } // The extra data is part of what that entry covers. - let other_extra = - super::public_dec_payload_bytes(&payload_bytes, b"other extra").unwrap(); + let other_extra = super::public_dec_payload(&payload_bytes, b"other extra"); let other = scheme_bound_preimage(&ordered, &other_extra).unwrap(); assert!( unified_verify(&DSEP_PUBLIC_DECRYPTION, &other, &sig, &vk).is_err(), @@ -2074,13 +2074,12 @@ pub(crate) mod tests { ); let expected_external = compute_eip712_signature(sk.ecdsa(), &sol_type, &domain).unwrap(); let eip712_hash = sol_type.eip712_signing_hash(&domain); - let payload_bytes = super::signed_payload_bytes(&super::CrsSignedPayload { + let payload = super::CrsSignedPayload { crs_id, max_num_bits: max_num_bits as u32, crs_digest: crs_digest.clone(), extra_data: extra_data.clone(), - }) - .unwrap(); + }; // Go through the production entry point, so the payload the signer builds // is the one asserted against here. @@ -2128,10 +2127,10 @@ pub(crate) mod tests { // Every other scheme signs the serialized CRS payload, prefixed // by the scheme set the response was produced under. scheme => { - let signed = scheme_bound_preimage(&schemes, &payload_bytes).unwrap(); + let signed_payload = scheme_bound_preimage(&schemes, &payload).unwrap(); let vk = sk.unified_verifying_key(scheme).unwrap(); let sig = Signature::new(scheme, stored.signature.clone()); - unified_verify(&DSEP_PUBDATA_CRS, &signed, &sig, &vk) + unified_verify(&DSEP_PUBDATA_CRS, &signed_payload, &sig, &vk) .unwrap_or_else(|e| panic!("{scheme:?} CRS signature should verify: {e}")); // Specifically not the EIP-712 hash any more... assert!( @@ -2141,7 +2140,7 @@ pub(crate) mod tests { ); // ...and not the unprefixed payload either. assert!( - unified_verify(&DSEP_PUBDATA_CRS, &payload_bytes, &sig, &vk).is_err(), + unified_verify(&DSEP_PUBDATA_CRS, &signed_payload, &sig, &vk).is_err(), "{scheme:?} CRS signature must be bound to the scheme set" ); assert!( @@ -2186,7 +2185,7 @@ pub(crate) mod tests { KeyGenMetadata::LegacyV0(_) => panic!("expected current metadata"), }; - let expected_payload = super::keygen_payload_bytes( + let expected_payload = super::keygen_payload( &prep_id, &key_id, &BTreeMap::from([ @@ -2194,8 +2193,7 @@ pub(crate) mod tests { (PubDataType::PublicKey, public_key_digest), ]), &extra_data, - ) - .unwrap(); + ); assert_eq!(inner.signatures.len(), schemes.len()); for stored in &inner.signatures { @@ -2210,10 +2208,11 @@ pub(crate) mod tests { unified_verify(&DSEP_PUBDATA_KEY, &signed, &sig, &vk).unwrap_or_else(|e| { panic!("{scheme:?} keygen signature should verify: {e}") }); - // The scheme-set prefix is load-bearing: without it the entry - // could be replayed as a complete response under one scheme. + // The scheme set in the preimage is load-bearing + let mut bare = Vec::new(); + safe_serialize(&expected_payload, &mut bare, SAFE_SER_SIZE_LIMIT).unwrap(); assert!( - unified_verify(&DSEP_PUBDATA_KEY, &expected_payload, &sig, &vk).is_err(), + unified_verify(&DSEP_PUBDATA_KEY, &bare, &sig, &vk).is_err(), "{scheme:?} keygen signature must be bound to the scheme set" ); } diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index a501224895..d2b5409987 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -2373,17 +2373,16 @@ mod tests { }; // The post-quantum entry signs the versioned payload — the response bytes - // together with the extra data — prefixed by the scheme set. + // together with the extra data — inside a preimage naming the scheme set. let response_bytes = bc2wrap::serialize(&payload).unwrap(); - let payload_bytes = - crate::engine::base::public_dec_payload_bytes(&response_bytes, &extra_data).unwrap(); + let signed = crate::engine::base::public_dec_payload(&response_bytes, &extra_data); let scheme = SigningSchemeType::MlDsa65; let signatures: Vec = sign_result_entries( &identity, &[scheme], &DSEP_PUBLIC_DECRYPTION, &[0u8; 32], - &payload_bytes, + &signed, ) .unwrap() .iter() diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 2f2617b619..948eb8a93c 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -1094,11 +1094,7 @@ mod tests { &SignedPayloads { dsep: &DSEP_USER_DECRYPTION, internal_bytes: &response_bytes, - payload_bytes: &super::user_dec_payload_bytes( - &response_bytes, - request.extra_data(), - ) - .unwrap(), + payload: &super::user_dec_payload(&response_bytes, request.extra_data()), eip712_hash: Some( user_decrypt_eip712_hash(response, &request, eip712_domain).unwrap(), ), From 2b959eda4ba1e9ff9fc304e2343444b54b29b383 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 11:07:53 +0200 Subject: [PATCH 055/114] refactor: simplified verfkeyset serialization --- .../src/cryptography/signing/verf_key_set.rs | 90 +++++-------------- 1 file changed, 22 insertions(+), 68 deletions(-) diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index cfbca686cc..46971a58b6 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -6,10 +6,7 @@ use hashing::{DomainSep, hash_element}; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; use tfhe::named::Named; -use tfhe_versionable::{ - Unversionize, UnversionizeError, Version, Versionize, VersionizeOwned, VersionsDispatch, - derived_traits::VersionsDispatch as VersionsDispatchTrait, -}; +use tfhe_versionable::{Versionize, VersionsDispatch}; /// Domain separator for the digest that identifies a whole verification-key set. const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; @@ -29,8 +26,9 @@ const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; /// /// The set is non-empty, and every key is filed under the scheme it actually /// belongs to. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Versionize)] #[serde(transparent)] +#[versionize(try_convert = "VerfKeySetRepr")] pub struct VerfKeySet { keys: BTreeMap, } @@ -39,77 +37,32 @@ impl Named for VerfKeySet { const NAME: &'static str = "VerfKeySet"; } -/// The borrowed versioned form of [`VerfKeySet`]. -#[derive(Serialize)] -pub struct VerfKeySetRef<'vers>( - as Versionize>::Versioned<'vers>, -); - -impl<'vers> From<&'vers VerfKeySet> for VerfKeySetRef<'vers> { - fn from(value: &'vers VerfKeySet) -> Self { - Self(value.keys.versionize()) - } -} - -/// The owned versioned form of [`VerfKeySet`]. -#[derive(Serialize, Deserialize)] -pub struct VerfKeySetOwned( - as VersionizeOwned>::VersionedOwned, -); - -impl From for VerfKeySetOwned { - fn from(value: VerfKeySet) -> Self { - Self(value.keys.versionize_owned()) - } -} - -/// Reading a set back re-establishes the invariants. +/// The unvalidated mirror of [`VerfKeySet`] that carries the version dispatch. +/// way back from a versioned artifact, so both paths check the same invariants. /// -/// This is the whole reason the versionable traits are written out rather than -/// derived: the derive converts the mirror type straight back into the struct, -/// so the validating [`Deserialize`] impl below would not run on the versioned -/// path. -impl TryFrom for VerfKeySet { - type Error = UnversionizeError; - - fn try_from(versioned: VerfKeySetOwned) -> Result { - let keys = - as Unversionize>::unversionize( - versioned.0, - )?; - Self::new(keys).map_err(|error| UnversionizeError::conversion("VerfKeySet", error)) - } -} - -impl Version for VerfKeySet { - type Ref<'vers> = VerfKeySetRef<'vers>; - type Owned = VerfKeySetOwned; -} +/// The mirror is a newtype over the map, so the stored bytes hold the versioned +/// map and no extra field. +#[derive(Versionize)] +#[versionize(VerfKeySetVersions)] +pub struct VerfKeySetRepr(BTreeMap); #[derive(VersionsDispatch)] pub enum VerfKeySetVersions { - V0(VerfKeySet), + V0(VerfKeySetRepr), } -impl Versionize for VerfKeySet { - type Versioned<'vers> = >::Ref<'vers>; - - fn versionize(&self) -> Self::Versioned<'_> { - self.into() +impl From for VerfKeySetRepr { + fn from(value: VerfKeySet) -> Self { + Self(value.keys) } } -impl VersionizeOwned for VerfKeySet { - type VersionedOwned = >::Owned; - - fn versionize_owned(self) -> Self::VersionedOwned { - self.into() - } -} +/// Reading a set back re-establishes the invariants. +impl TryFrom for VerfKeySet { + type Error = SigningError; -impl Unversionize for VerfKeySet { - fn unversionize(versioned: Self::VersionedOwned) -> Result { - versioned.try_into() + fn try_from(versioned: VerfKeySetRepr) -> Result { + Self::new(versioned.0) } } @@ -204,6 +157,7 @@ mod tests { use aes_prng::AesRng; use rand::SeedableRng; use strum::IntoEnumIterator; + use tfhe_versionable::{Unversionize, UnversionizeError, VersionizeOwned}; #[test] fn from_identity_covers_every_requested_scheme() { @@ -284,9 +238,9 @@ mod tests { good ); for bad in [misfiled, empty] { - let smuggled = VerfKeySetOwned(bad.versionize_owned()); + let smuggled = VerfKeySetRepr(bad).versionize_owned(); assert!(matches!( - VerfKeySet::try_from(smuggled), + VerfKeySet::unversionize(smuggled), Err(UnversionizeError::Conversion { .. }) )); } From e1f79f9a6b435b70c07085f54bc270d38443f2be Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 11:38:39 +0200 Subject: [PATCH 056/114] chore: consistent naming --- .../src/cryptography/signcryption/ecdsa_v0.rs | 18 ++++++++++++++---- .../src/cryptography/signcryption/mod.rs | 6 +++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 4de30f7658..00bc9dc0f8 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -35,7 +35,7 @@ fn sender_verf_key_digest(verf_key: &PublicSigKey) -> Result, Cryptograp // Implements the actual signcryption but without serialization // // This is the FROZEN layout. -pub(super) fn inner_signcryption( +pub(super) fn seal( signcrypt_key: &UnifiedSigncryptionKey, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, @@ -72,7 +72,7 @@ pub(super) fn inner_signcryption( /// Implements the actual unsigncryption process, but without any deserialization /// /// This is the FROZEN layout. -pub(super) fn inner_unsigncrypt( +pub(super) fn open( unsign_key: &UnifiedUnsigncryptionKey, sender_verf_key: &PublicSigKey, dsep: &DomainSep, @@ -161,8 +161,17 @@ fn check_format_and_signature( fn receiver_binding( receiver_id: &[u8], enc_key: &UnifiedPublicEncKey, -) -> Result, CryptographyError> { - Ok([receiver_id, receiver_enc_key_digest(enc_key)?.as_slice()].concat()) +) -> Result<[u8; DIGEST_BYTES + 20], CryptographyError> { + if receiver_id.len() != 20 { + return Err(CryptographyError::LengthError(format!( + "Receiver ID must be 20 bytes (the Ethereum address), got {} bytes", + receiver_id.len() + ))); + } + let mut out = [0u8; DIGEST_BYTES + 20]; + out[..20].copy_from_slice(receiver_id); + out[20..].copy_from_slice(receiver_enc_key_digest(enc_key)?.as_slice()); + Ok(out) } /// Decrypt a signcrypted message and ignore the signature @@ -314,6 +323,7 @@ mod tests { let other = signcryption_fixture(PkeSchemeType::MlKem512, 501); let base = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); + println!("base: {:?}", base.len()); assert_eq!(base, receiver_binding(&f.receiver_id, &f.enc_key).unwrap()); assert_ne!( base, diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 830c0ea6cb..0fffb38536 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -240,7 +240,7 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { ) -> Result>, CryptographyError> { match &self.sender { SenderAuth::Ecdsa(sender_verf_key) => { - ecdsa_v0::inner_unsigncrypt(self, sender_verf_key, dsep, cipher) + ecdsa_v0::open(self, sender_verf_key, dsep, cipher) } SenderAuth::Multi(keys) => composite_v1::open( self.decryption_key, @@ -462,7 +462,7 @@ impl<'a> Signcrypt for UnifiedSigncryptionKey<'a> { // This key holds one ECDSA signing key, so the frozen layout is the only // one it can produce. A multi-signature envelope needs a // `NodeSigningIdentity` and goes through `composite_v1::seal`. - ecdsa_v0::inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) + ecdsa_v0::seal(self, rng, dsep, serialized_msg.as_slice()) } } @@ -504,7 +504,7 @@ impl<'a> SigncryptFHEPlaintext for UnifiedSigncryptionKey<'a> { // `TypedSigncryptedCiphertext.signcrypted_ciphertext`, is a bare `bytes` // field, and the deployed browser-side verifier parses exactly one // layout. The frozen one is not a default here, it is the only option. - ecdsa_v0::inner_signcryption(self, rng, dsep, serialized_msg.as_slice()) + ecdsa_v0::seal(self, rng, dsep, serialized_msg.as_slice()) } } From d4bdd4fbce7ce7756006df23bae0afa413d75e29 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 12:00:05 +0200 Subject: [PATCH 057/114] refactor: use unsigncrypt key --- .../cryptography/signcryption/composite_v1.rs | 32 ++++++++++++------- .../src/cryptography/signcryption/mod.rs | 9 +----- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 46d49eddd7..c3ef10bdd4 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -5,12 +5,13 @@ use super::UnifiedSigncryption; use super::common::hybrid_encrypt; use super::common::{hybrid_decrypt, receiver_enc_key_digest}; use crate::consts::SAFE_SER_SIZE_LIMIT; -use crate::cryptography::encryption::{HasPkeScheme, UnifiedPrivateEncKey, UnifiedPublicEncKey}; +use crate::cryptography::encryption::{HasPkeScheme, UnifiedPublicEncKey}; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; +use crate::cryptography::signatures::StoredTypedSignature; #[cfg(feature = "non-wasm")] use crate::cryptography::signatures::{NodeSigningIdentity, SigningSchemeType}; -use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; +use crate::cryptography::signcryption::UnifiedUnsigncryptionKey; #[cfg(feature = "non-wasm")] use crate::cryptography::signing::composite::sign_uniform; use crate::cryptography::signing::composite::verify_uniform; @@ -139,29 +140,36 @@ pub fn seal( /// /// `sender_keys` is the verifier's policy the signatures will be validated against. pub(super) fn open( - decryption_key: &UnifiedPrivateEncKey, - encryption_key: &UnifiedPublicEncKey, - sender_keys: &VerfKeySet, - receiver_id: &[u8], + unsign_key: &UnifiedUnsigncryptionKey, dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { - if cipher.pke_type != encryption_key.encryption_scheme_type() { + if cipher.pke_type != unsign_key.encryption_key.encryption_scheme_type() { return Err(CryptographyError::VerificationError( "encryption type of cipher does not match the decryption key type".to_string(), )); } + let sender_keys = match unsign_key.sender { + super::SenderAuth::Ecdsa(_public_sig_key) => { + return Err(CryptographyError::VerificationError( + "Unsigncryption key for legacy scheme supplied for composite signcryption" + .to_string(), + )); + } + super::SenderAuth::Multi(verf_key_set) => verf_key_set, + }; let kem_ct: HybridKemCt = safe_deserialize(std::io::Cursor::new(&cipher.payload), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError)?; - let plaintext = hybrid_decrypt(kem_ct, decryption_key)?; + let plaintext = hybrid_decrypt(kem_ct, unsign_key.decryption_key)?; let mut envelope: CompositeEnvelope = safe_deserialize(std::io::Cursor::new(&*plaintext), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError)?; let msg = Zeroizing::new(std::mem::take(&mut envelope.msg)); - let mut signed = SigncryptionSignedPayload::new(&msg, receiver_id, encryption_key)?; + let mut signed = + SigncryptionSignedPayload::new(&msg, unsign_key.receiver_id, unsign_key.encryption_key)?; let verified = verify_uniform(&envelope.signature, sender_keys, dsep, &signed); signed.zeroize(); verified.map_err(|e| CryptographyError::VerificationError(e.to_string()))?; @@ -174,8 +182,8 @@ mod tests { use super::super::common::signcryption_fixture; use super::super::{Signcrypt, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt}; use super::*; - use crate::cryptography::encryption::PkeSchemeType; - use crate::cryptography::signatures::{PublicSigKey, UnifiedPublicSigKey}; + use crate::cryptography::encryption::{PkeSchemeType, UnifiedPrivateEncKey}; + use crate::cryptography::signatures::{PublicSigKey, UnifiedPublicSigKey, VerfKeySet}; use crate::cryptography::signing::test_support::seeded_identity; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; @@ -381,7 +389,7 @@ mod tests { let other_id = b"a different receiver".to_vec(); assert!( - open(&f.dec_key, &f.enc_key, &f.keys, &other_id, DSEP, &cipher).is_err(), + open_as(&f, &f.keys, &other_id, DSEP, &cipher).is_err(), "a different receiver id opened the envelope" ); diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 0fffb38536..69b45388ac 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -242,14 +242,7 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { SenderAuth::Ecdsa(sender_verf_key) => { ecdsa_v0::open(self, sender_verf_key, dsep, cipher) } - SenderAuth::Multi(keys) => composite_v1::open( - self.decryption_key, - self.encryption_key, - keys, - self.receiver_id, - dsep, - cipher, - ), + SenderAuth::Multi(_keys) => composite_v1::open(&self, dsep, cipher), } } } From 7d04efc522df3c7b151448be4ffffc84a4d1fb37 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 12:49:42 +0200 Subject: [PATCH 058/114] refactor: upgrade to unified signcryption format --- core/service/src/backup/custodian.rs | 3 +- core/service/src/backup/operator.rs | 3 +- .../cryptography/signcryption/composite_v1.rs | 200 +++++++++++------- .../src/cryptography/signcryption/ecdsa_v0.rs | 9 +- .../src/cryptography/signcryption/mod.rs | 89 ++++++-- .../tests/backward_compatibility_kms.rs | 2 +- 6 files changed, 211 insertions(+), 95 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 10af0ea676..de57748dda 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -468,8 +468,9 @@ impl Custodian { operator_ephem_enc_key, &operator_verf_id, ); + // No scheme set: this is the frozen, ECDSA-only layout. let signcryption = - signcrypt_key.signcrypt(rng, &DSEP_BACKUP_MATERIAL, &*backup_material)?; + signcrypt_key.signcrypt(rng, &DSEP_BACKUP_MATERIAL, &[], &*backup_material)?; tracing::debug!( "Signed re-encrypted share for operator: {}", operator_verification_key.address() diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index c206cee361..0b0e84450d 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -574,8 +574,9 @@ impl Operator { }; let custodian_verf_id = custodian_verf_key.verf_key_id(); let signcryption_key = UnifiedSigncryptionKey::new(sk, cus_enc_key, &custodian_verf_id); + // No scheme set: this is the frozen, ECDSA-only layout. let signcryption = signcryption_key - .signcrypt(rng, &DSEP_BACKUP_CUSTODIAN, &backup_material) + .signcrypt(rng, &DSEP_BACKUP_CUSTODIAN, &[], &backup_material) .map_err(BackupError::InternalCryptographyError)?; // Commitment by the operator, which is a hash of [BackupMaterial]. // diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index c3ef10bdd4..ddd80605c9 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -10,7 +10,9 @@ use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; use crate::cryptography::signatures::StoredTypedSignature; #[cfg(feature = "non-wasm")] -use crate::cryptography::signatures::{NodeSigningIdentity, SigningSchemeType}; +use crate::cryptography::signatures::SigningSchemeType; +#[cfg(feature = "non-wasm")] +use crate::cryptography::signcryption::{SignerAuth, UnifiedSigncryptionKey}; use crate::cryptography::signcryption::UnifiedUnsigncryptionKey; #[cfg(feature = "non-wasm")] use crate::cryptography::signing::composite::sign_uniform; @@ -100,16 +102,25 @@ impl SigncryptionSignedPayload { /// [`SigncryptionSignedPayload`]. The whole envelope is then encrypted to the /// receiver. #[cfg(feature = "non-wasm")] -pub fn seal( - identity: &NodeSigningIdentity, - schemes: &[SigningSchemeType], - receiver_enc_key: &UnifiedPublicEncKey, - receiver_id: &[u8], +pub(super) fn seal( + signcrypt_key: &UnifiedSigncryptionKey, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, + schemes: &[SigningSchemeType], msg: &[u8], ) -> Result { - let mut signed = SigncryptionSignedPayload::new(msg, receiver_id, receiver_enc_key)?; + let identity = match signcrypt_key.signer { + SignerAuth::Multi(identity) => identity, + SignerAuth::Ecdsa(_) => { + return Err(CryptographyError::SigningError( + "signcryption key for the legacy scheme supplied for composite signcryption" + .to_string(), + )); + } + }; + let receiver_enc_key = signcrypt_key.receiver_enc_key; + let mut signed = + SigncryptionSignedPayload::new(msg, signcrypt_key.receiver_id, receiver_enc_key)?; let signature = sign_uniform(identity, schemes, dsep, &signed); signed.zeroize(); let signature = signature?; @@ -183,7 +194,9 @@ mod tests { use super::super::{Signcrypt, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt}; use super::*; use crate::cryptography::encryption::{PkeSchemeType, UnifiedPrivateEncKey}; - use crate::cryptography::signatures::{PublicSigKey, UnifiedPublicSigKey, VerfKeySet}; + use crate::cryptography::signatures::{ + NodeSigningIdentity, PublicSigKey, UnifiedPublicSigKey, VerfKeySet, + }; use crate::cryptography::signing::test_support::seeded_identity; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; @@ -195,6 +208,7 @@ mod tests { vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87] } + /// The material one composite round trip needs. struct CompositeFixture { rng: AesRng, identity: NodeSigningIdentity, @@ -228,25 +242,56 @@ mod tests { } } - fn seal_msg(f: &mut CompositeFixture, msg: &[u8]) -> UnifiedSigncryption { - seal( - &f.identity, - &f.schemes, - &f.enc_key, - &f.receiver_id, - &mut f.rng, - DSEP, - msg, - ) - .unwrap() - } + impl CompositeFixture { + /// Seal `msg` under the fixture's own scheme set: the set its reader + /// demands, so the result round trips. + fn seal_msg(&mut self, msg: &[u8]) -> UnifiedSigncryption { + let schemes = self.schemes.clone(); + self.seal_under(&schemes, msg).unwrap() + } + + /// Seal `msg` under `schemes`, which need not be what the fixture's + /// reader demands. + fn seal_under( + &mut self, + schemes: &[SigningSchemeType], + msg: &[u8], + ) -> Result { + // Destructured so the sealer borrows the identity and the receiver + // material while `rng` is borrowed mutably. + let Self { + rng, + identity, + enc_key, + receiver_id, + .. + } = self; + let key = UnifiedSigncryptionKey::new_multi(identity, enc_key, receiver_id.as_slice()); + seal(&key, rng, DSEP, schemes, msg) + } + + /// The reader this fixture's envelopes are made for. + fn reader(&self) -> UnifiedUnsigncryptionKey<'_> { + self.reader_with(&self.keys, &self.receiver_id) + } + + /// A reader deviating from the fixture in its key set or its receiver + /// id, so a test can vary one of them at a time. + fn reader_with<'a>( + &'a self, + keys: &'a VerfKeySet, + receiver_id: &'a [u8], + ) -> UnifiedUnsigncryptionKey<'a> { + UnifiedUnsigncryptionKey::new_multi(&self.dec_key, &self.enc_key, keys, receiver_id) + } - fn open_with( - f: &CompositeFixture, - cipher: &UnifiedSigncryption, - ) -> Result>, CryptographyError> { - UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id) - .open(DSEP, cipher) + /// Open `cipher` with the fixture's own reader. + fn open( + &self, + cipher: &UnifiedSigncryption, + ) -> Result>, CryptographyError> { + self.reader().open(DSEP, cipher) + } } /// The ECDSA member of the fixture's key set, in the form the frozen reader @@ -263,10 +308,10 @@ mod tests { fn round_trip() { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { let mut f = fixture(scheme, 100); - let cipher = seal_msg(&mut f, b"a composite message"); + let cipher = f.seal_msg(b"a composite message"); assert_eq!(cipher.pke_type, scheme); - let opened = open_with(&f, &cipher).unwrap(); + let opened = f.open(&cipher).unwrap(); assert_eq!(&*opened, b"a composite message", "{scheme}"); } } @@ -279,18 +324,9 @@ mod tests { // The sender signs under a weaker pair; using MlDsa44 instead of MlDsa87. let weaker = vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa44]; - let cipher = seal( - &f.identity, - &weaker, - &f.enc_key, - &f.receiver_id, - &mut f.rng, - DSEP, - b"downgrade me", - ) - .unwrap(); - - let err = open_with(&f, &cipher).unwrap_err(); + let cipher = f.seal_under(&weaker, b"downgrade me").unwrap(); + + let err = f.open(&cipher).unwrap_err(); assert!( matches!(err, CryptographyError::VerificationError(_)), "{err}" @@ -300,15 +336,10 @@ mod tests { // exactly what was signed, confirming the rejection above is the policy // check and not an unrelated failure. let weaker_keys = VerfKeySet::from_identity(&f.identity, &weaker).unwrap(); - let opened = open( - &f.dec_key, - &f.enc_key, - &weaker_keys, - &f.receiver_id, - DSEP, - &cipher, - ) - .unwrap(); + let opened = f + .reader_with(&weaker_keys, &f.receiver_id) + .open(DSEP, &cipher) + .unwrap(); assert_eq!(&*opened, b"downgrade me"); } @@ -323,11 +354,11 @@ mod tests { (400_u64, vec![SigningSchemeType::Ecdsa256k1]), ] { let mut f = fixture_under(PkeSchemeType::MlKem512, seed, schemes); - let composite = seal_msg(&mut f, b"composite payload"); + let composite = f.seal_msg(b"composite payload"); // The envelope does open for the reader it was made for, so each // rejection below is the format mismatch and not a broken fixture. - assert_eq!(&*open_with(&f, &composite).unwrap(), b"composite payload"); + assert_eq!(&*f.open(&composite).unwrap(), b"composite payload"); // Frozen reader, handed a composite envelope. let legacy_ecdsa = ecdsa_member(&f); @@ -352,9 +383,9 @@ mod tests { let mut rng = base.rng; let frozen = UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id) - .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) + .signcrypt(&mut rng, DSEP, &[], &TestType { i: 7 }) .unwrap(); - let err = open_with(&f, &frozen).unwrap_err(); + let err = f.open(&frozen).unwrap_err(); assert!( matches!(err, CryptographyError::SerializationError(_)), "the composite reader must reject a frozen envelope on deserialization, got: {err}" @@ -362,55 +393,72 @@ mod tests { } } - /// Opening fails on every deviation from what was sealed. + /// Opening fails on every deviation from what was sealed. #[test] fn open_rejects_any_deviation_from_what_was_sealed() { let mut f = fixture(PkeSchemeType::MlKem512, 500); - let cipher = seal_msg(&mut f, b"bound to one sender and one receiver"); + let cipher = f.seal_msg(b"bound to one sender and one receiver"); // The untouched envelope opens, so each rejection below is the // deviation and not an unrelated failure. - open_with(&f, &cipher).unwrap(); + f.open(&cipher).unwrap(); let mut rng = AesRng::seed_from_u64(999); let other_keys = VerfKeySet::from_identity(&seeded_identity(&mut rng), &f.schemes).unwrap(); assert!( - open( - &f.dec_key, - &f.enc_key, - &other_keys, - &f.receiver_id, - DSEP, - &cipher - ) - .is_err(), + f.reader_with(&other_keys, &f.receiver_id) + .open(DSEP, &cipher) + .is_err(), "another party's key set opened the envelope" ); let other_id = b"a different receiver".to_vec(); assert!( - open_as(&f, &f.keys, &other_id, DSEP, &cipher).is_err(), + f.reader_with(&f.keys, &other_id) + .open(DSEP, &cipher) + .is_err(), "a different receiver id opened the envelope" ); assert!( - open( - &f.dec_key, - &f.enc_key, - &f.keys, - &f.receiver_id, - b"OTHERDSP", - &cipher - ) - .is_err(), + f.reader().open(b"OTHERDSP", &cipher).is_err(), "a different domain separator opened the envelope" ); let mut flipped = cipher.clone(); flipped.payload[0] ^= 0x01; assert!( - open_with(&f, &flipped).is_err(), + f.open(&flipped).is_err(), "a tampered ciphertext opened the envelope" ); } + + /// `SignerAuth` names the layout, so the composite sealer refuses a frozen + /// signer rather than falling back to signing with its ECDSA key alone. + #[test] + fn a_frozen_signer_cannot_seal_composite() { + let mut base = signcryption_fixture(PkeSchemeType::MlKem512, 600); + let key = UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id); + + let err = seal(&key, &mut base.rng, DSEP, &pair(), b"no identity here").unwrap_err(); + assert!(matches!(err, CryptographyError::SigningError(_)), "{err}"); + } + + /// The frozen layout is ECDSA by definition and has no scheme set, so asking + /// it for one is an error rather than an ECDSA-only envelope the caller + /// believes is composite. + #[test] + fn the_frozen_layout_refuses_a_scheme_set() { + let mut base = signcryption_fixture(PkeSchemeType::MlKem512, 700); + let key = UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id); + + let err = key + .signcrypt(&mut base.rng, DSEP, &pair(), &TestType { i: 1 }) + .unwrap_err(); + assert!(matches!(err, CryptographyError::SigningError(_)), "{err}"); + + // ...and with no set requested it seals as it always did. + key.signcrypt(&mut base.rng, DSEP, &[], &TestType { i: 1 }) + .unwrap(); + } } diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 00bc9dc0f8..2712ca5ca4 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -46,16 +46,17 @@ pub(super) fn seal( // Note that H(client_verf_key) = client_address // Only serialize the inner structure to ensure backwards compatibility!!! let binding = receiver_binding(signcrypt_key.receiver_id, signcrypt_key.receiver_enc_key)?; + let signing_key = signcrypt_key.signer.ecdsa(); // Wipe the temporary signed message after signing. let to_sign = Zeroizing::new([msg, binding.as_slice()].concat()); - let sig = internal_sign(dsep, &to_sign, signcrypt_key.signing_key) + let sig = internal_sign(dsep, &to_sign, signing_key) .map_err(|e| CryptographyError::SigningError(e.to_string()))?; // Encrypt msg || sig || H(server_verification_key) // OBSERVE: serialization is simply r concatenated with s. That is NOT an Ethereum compatible // signature since we preclude the v value. // The verification key is serialized based on the SEC1 standard. - let verf_key_hash = sender_verf_key_digest(&PublicSigKey::from_sk(signcrypt_key.signing_key))?; + let verf_key_hash = sender_verf_key_digest(&PublicSigKey::from_sk(signing_key))?; // Wipe the temporary encrypted message after encryption. let to_encrypt = Zeroizing::new([msg, sig.to_bytes().as_ref(), verf_key_hash.as_ref()].concat()); @@ -256,7 +257,9 @@ mod tests { let mut expected_msg = Vec::new(); safe_serialize(&payload, &mut expected_msg, SAFE_SER_SIZE_LIMIT).unwrap(); - let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); + let cipher = signcrypt_key + .signcrypt(&mut f.rng, DSEP, &[], &payload) + .unwrap(); assert_eq!(cipher.pke_type, scheme); let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 69b45388ac..2994c3de94 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -29,7 +29,9 @@ use crate::cryptography::encryption::{ HasPkeScheme, PkeSchemeType, UnifiedPrivateEncKey, UnifiedPublicEncKey, }; use crate::cryptography::error::CryptographyError; -use crate::cryptography::signatures::{PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet}; +use crate::cryptography::signatures::{ + NodeSigningIdentity, PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet, +}; use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; use kms_grpc::kms::v1::TypedPlaintext; @@ -43,10 +45,14 @@ use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; pub trait Signcrypt { /// Signcrypt a message of type T with a specified domain separator. + /// + /// `schemes` is the set the signature is made under, and is meaningful only + /// for the new composite signer format. For the legacy ECDSA format use an empty list. fn signcrypt( &self, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, + schemes: &[SigningSchemeType], msg: &T, ) -> Result; } @@ -132,7 +138,7 @@ impl UnifiedSigncryptionKeyOwned { pub fn reference<'a>(&'a self) -> UnifiedSigncryptionKey<'a> { UnifiedSigncryptionKey { - signing_key: &self.signing_key, + signer: SignerAuth::Ecdsa(&self.signing_key), receiver_enc_key: &self.receiver_enc_key, receiver_id: &self.receiver_id, } @@ -145,23 +151,61 @@ impl HasPkeScheme for UnifiedSigncryptionKeyOwned { } } +/// What a sealer signs with — and therefore which envelope layout it writes. +/// +/// The dual of [`SenderAuth`]. As there, the key material and the layout are the +/// same choice, so they are the same value. +#[derive(Clone, Debug)] +pub enum SignerAuth<'a> { + /// A single ECDSA signing key: the frozen layout, and only that. + Ecdsa(&'a PrivateSigKey), + /// A node identity, which signs under whichever schemes a call requests: + /// the multi-signature layout. + Multi(&'a NodeSigningIdentity), +} + +impl<'a> SignerAuth<'a> { + /// The ECDSA key behind this signer. + pub fn ecdsa(&self) -> &'a PrivateSigKey { + match self { + SignerAuth::Ecdsa(signing_key) => signing_key, + SignerAuth::Multi(identity) => identity.ecdsa(), + } + } +} + /// Internal type for signcryption keys, storing only references to the real internal keys. /// Thus this type should not be serialized instead `UnifiedSigncryptionKeyOwned` should be used. #[derive(Clone, Debug)] pub struct UnifiedSigncryptionKey<'a> { - pub signing_key: &'a PrivateSigKey, + pub signer: SignerAuth<'a>, pub receiver_enc_key: &'a UnifiedPublicEncKey, pub receiver_id: &'a [u8], // Identifier for the receiver's encryption key, e.g. blockchain address } impl<'a> UnifiedSigncryptionKey<'a> { + /// A sealer of the frozen, single-ECDSA layout. pub fn new( signing_key: &'a PrivateSigKey, receiver_enc_key: &'a UnifiedPublicEncKey, receiver_id: &'a [u8], ) -> Self { Self { - signing_key, + signer: SignerAuth::Ecdsa(signing_key), + receiver_enc_key, + receiver_id, + } + } + + /// A sealer of the multi-signature layout, signing under whichever schemes + /// the sealing call names. + pub fn new_multi( + identity: &'a NodeSigningIdentity, + receiver_enc_key: &'a UnifiedPublicEncKey, + receiver_id: &'a [u8], + ) -> Self { + Self { + signer: SignerAuth::Multi(identity), receiver_enc_key, receiver_id, } @@ -441,6 +485,7 @@ impl<'a> Signcrypt for UnifiedSigncryptionKey<'a> { &self, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, + schemes: &[SigningSchemeType], msg: &T, ) -> Result where @@ -452,10 +497,27 @@ impl<'a> Signcrypt for UnifiedSigncryptionKey<'a> { "Could not serialize message for signcryption: {e}", )) })?; - // This key holds one ECDSA signing key, so the frozen layout is the only - // one it can produce. A multi-signature envelope needs a - // `NodeSigningIdentity` and goes through `composite_v1::seal`. - ecdsa_v0::seal(self, rng, dsep, serialized_msg.as_slice()) + match self.signer { + // The frozen layout is ECDSA by definition. + SignerAuth::Ecdsa(_) => { + if !schemes.is_empty() { + return Err(CryptographyError::SigningError(format!( + "the frozen layout signs under ECDSA alone, but {} scheme(s) were requested; \ + use a `SignerAuth::Multi` signer for a composite envelope", + schemes.len() + ))); + } + ecdsa_v0::seal(self, rng, dsep, serialized_msg.as_slice()) + } + #[cfg(feature = "non-wasm")] + SignerAuth::Multi(_) => { + composite_v1::seal(self, rng, dsep, schemes, serialized_msg.as_slice()) + } + #[cfg(not(feature = "non-wasm"))] + SignerAuth::Multi(_) => Err(CryptographyError::SigningError( + "composite signcryption is not available in this build".to_string(), + )), + } } } @@ -464,13 +526,14 @@ impl Signcrypt for UnifiedSigncryptionKeyOwned { &self, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, + schemes: &[SigningSchemeType], msg: &T, ) -> Result where T: Serialize + tfhe::Versionize + tfhe::named::Named, { let ref_type = self.reference(); - ref_type.signcrypt(rng, dsep, msg) + ref_type.signcrypt(rng, dsep, schemes, msg) } } @@ -699,7 +762,7 @@ mod tests { let msg = TestType { i: 1333 }; let cipher = keys .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) + .signcrypt(&mut rng, b"TESTTEST", &[], &msg) .unwrap(); assert_eq!(cipher.pke_type, scheme); @@ -718,7 +781,7 @@ mod tests { let msg = TestType { i: 1333 }; let cipher = p384_keys .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) + .signcrypt(&mut rng, b"TESTTEST", &[], &msg) .unwrap(); let err = ml_kem_512_keys @@ -735,7 +798,7 @@ mod tests { let msg = TestType { i: 1333 }; let cipher = client_signcryption_keys .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) + .signcrypt(&mut rng, b"TESTTEST", &[], &msg) .unwrap(); let serialized_cipher = bc2wrap::serialize(&cipher).unwrap(); let deserialized_cipher: UnifiedSigncryption = @@ -768,7 +831,7 @@ mod tests { let msg = TestType { i: 1333 }; let correct_cipher = client_signcryption_keys .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) + .signcrypt(&mut rng, b"TESTTEST", &[], &msg) .unwrap(); // flip a bit in the payload diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index ee5e45cb4b..beabc28ef0 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -821,7 +821,7 @@ fn test_unified_signcryption( let signcrypt_key = UnifiedSigncryptionKeyOwned::new(server_sig_key, enc_key.clone(), receiver_id.clone()); let new_versionized = signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &verf_key) + .signcrypt(&mut rng, b"TESTTEST", &[], &verf_key) .unwrap(); if original_versionized != new_versionized { From 315b5c937ee463518ad3a4f13ff8e3f583943f7e Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 14:29:56 +0200 Subject: [PATCH 059/114] test: improved composite --- .../cryptography/signcryption/composite_v1.rs | 189 ++++++++---------- .../src/cryptography/signcryption/ecdsa_v0.rs | 14 +- 2 files changed, 83 insertions(+), 120 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index ddd80605c9..f5a0d73dbb 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -194,9 +194,7 @@ mod tests { use super::super::{Signcrypt, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt}; use super::*; use crate::cryptography::encryption::{PkeSchemeType, UnifiedPrivateEncKey}; - use crate::cryptography::signatures::{ - NodeSigningIdentity, PublicSigKey, UnifiedPublicSigKey, VerfKeySet, - }; + use crate::cryptography::signatures::{NodeSigningIdentity, UnifiedPublicSigKey, VerfKeySet}; use crate::cryptography::signing::test_support::seeded_identity; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; @@ -219,11 +217,7 @@ mod tests { receiver_id: Vec, } - fn fixture(scheme: PkeSchemeType, seed: u64) -> CompositeFixture { - fixture_under(scheme, seed, pair()) - } - - fn fixture_under( + fn fixture( scheme: PkeSchemeType, seed: u64, schemes: Vec, @@ -242,76 +236,26 @@ mod tests { } } - impl CompositeFixture { - /// Seal `msg` under the fixture's own scheme set: the set its reader - /// demands, so the result round trips. - fn seal_msg(&mut self, msg: &[u8]) -> UnifiedSigncryption { - let schemes = self.schemes.clone(); - self.seal_under(&schemes, msg).unwrap() - } - - /// Seal `msg` under `schemes`, which need not be what the fixture's - /// reader demands. - fn seal_under( - &mut self, - schemes: &[SigningSchemeType], - msg: &[u8], - ) -> Result { - // Destructured so the sealer borrows the identity and the receiver - // material while `rng` is borrowed mutably. - let Self { - rng, - identity, - enc_key, - receiver_id, - .. - } = self; - let key = UnifiedSigncryptionKey::new_multi(identity, enc_key, receiver_id.as_slice()); - seal(&key, rng, DSEP, schemes, msg) - } - - /// The reader this fixture's envelopes are made for. - fn reader(&self) -> UnifiedUnsigncryptionKey<'_> { - self.reader_with(&self.keys, &self.receiver_id) - } - - /// A reader deviating from the fixture in its key set or its receiver - /// id, so a test can vary one of them at a time. - fn reader_with<'a>( - &'a self, - keys: &'a VerfKeySet, - receiver_id: &'a [u8], - ) -> UnifiedUnsigncryptionKey<'a> { - UnifiedUnsigncryptionKey::new_multi(&self.dec_key, &self.enc_key, keys, receiver_id) - } - - /// Open `cipher` with the fixture's own reader. - fn open( - &self, - cipher: &UnifiedSigncryption, - ) -> Result>, CryptographyError> { - self.reader().open(DSEP, cipher) - } - } - - /// The ECDSA member of the fixture's key set, in the form the frozen reader - /// takes. - fn ecdsa_member(f: &CompositeFixture) -> PublicSigKey { - match f.keys.require(SigningSchemeType::Ecdsa256k1).unwrap() { - UnifiedPublicSigKey::Ecdsa256k1(key) => key.clone(), - _ => unreachable!("the ECDSA member of the set is an ECDSA key"), - } - } - /// Round-trips for both PKE schemes the backup and user-decryption paths use. #[test] fn round_trip() { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { - let mut f = fixture(scheme, 100); - let cipher = f.seal_msg(b"a composite message"); + let mut f = fixture(scheme, 100, pair()); + let sealer = UnifiedSigncryptionKey::new_multi(&f.identity, &f.enc_key, &f.receiver_id); + let cipher = seal( + &sealer, + &mut f.rng, + DSEP, + &f.schemes, + b"a composite message", + ) + .unwrap(); assert_eq!(cipher.pke_type, scheme); - let opened = f.open(&cipher).unwrap(); + let opened = + UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id) + .open(DSEP, &cipher) + .unwrap(); assert_eq!(&*opened, b"a composite message", "{scheme}"); } } @@ -320,13 +264,17 @@ mod tests { /// pair rejects a signcryption made under a weaker one. #[test] fn a_signature_under_another_scheme_set_is_rejected() { - let mut f = fixture(PkeSchemeType::MlKem512, 200); + let mut f = fixture(PkeSchemeType::MlKem512, 200, pair()); // The sender signs under a weaker pair; using MlDsa44 instead of MlDsa87. let weaker = vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa44]; - let cipher = f.seal_under(&weaker, b"downgrade me").unwrap(); + let sealer = UnifiedSigncryptionKey::new_multi(&f.identity, &f.enc_key, &f.receiver_id); + let cipher = seal(&sealer, &mut f.rng, DSEP, &weaker, b"downgrade me").unwrap(); - let err = f.open(&cipher).unwrap_err(); + let err = + UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id) + .open(DSEP, &cipher) + .unwrap_err(); assert!( matches!(err, CryptographyError::VerificationError(_)), "{err}" @@ -336,10 +284,14 @@ mod tests { // exactly what was signed, confirming the rejection above is the policy // check and not an unrelated failure. let weaker_keys = VerfKeySet::from_identity(&f.identity, &weaker).unwrap(); - let opened = f - .reader_with(&weaker_keys, &f.receiver_id) - .open(DSEP, &cipher) - .unwrap(); + let opened = UnifiedUnsigncryptionKey::new_multi( + &f.dec_key, + &f.enc_key, + &weaker_keys, + &f.receiver_id, + ) + .open(DSEP, &cipher) + .unwrap(); assert_eq!(&*opened, b"downgrade me"); } @@ -353,24 +305,30 @@ mod tests { (300_u64, pair()), (400_u64, vec![SigningSchemeType::Ecdsa256k1]), ] { - let mut f = fixture_under(PkeSchemeType::MlKem512, seed, schemes); - let composite = f.seal_msg(b"composite payload"); + let mut f = fixture(PkeSchemeType::MlKem512, seed, schemes); + let sealer = UnifiedSigncryptionKey::new_multi(&f.identity, &f.enc_key, &f.receiver_id); + let composite = seal(&sealer, &mut f.rng, DSEP, &f.schemes, b"composite payload") + .unwrap(); // The envelope does open for the reader it was made for, so each // rejection below is the format mismatch and not a broken fixture. - assert_eq!(&*f.open(&composite).unwrap(), b"composite payload"); + let reader = + UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id); + assert_eq!(&*reader.open(DSEP, &composite).unwrap(), b"composite payload"); // Frozen reader, handed a composite envelope. - let legacy_ecdsa = ecdsa_member(&f); - let legacy_key = UnifiedUnsigncryptionKey::new( + let legacy_ecdsa = match f.keys.require(SigningSchemeType::Ecdsa256k1).unwrap() { + UnifiedPublicSigKey::Ecdsa256k1(key) => key.clone(), + _ => unreachable!("the ECDSA member of the set is an ECDSA key"), + }; + let err = UnifiedUnsigncryptionKey::new( &f.dec_key, &f.enc_key, &legacy_ecdsa, &f.receiver_id, - ); - let err = legacy_key - .unsigncrypt::(DSEP, &composite) - .unwrap_err(); + ) + .unsigncrypt::(DSEP, &composite) + .unwrap_err(); assert!( matches!(err, CryptographyError::BincodeError(_)), "the frozen reader must reject a composite envelope on the KEM ciphertext, got: {err}" @@ -385,7 +343,7 @@ mod tests { UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id) .signcrypt(&mut rng, DSEP, &[], &TestType { i: 7 }) .unwrap(); - let err = f.open(&frozen).unwrap_err(); + let err = reader.open(DSEP, &frozen).unwrap_err(); assert!( matches!(err, CryptographyError::SerializationError(_)), "the composite reader must reject a frozen envelope on deserialization, got: {err}" @@ -396,41 +354,56 @@ mod tests { /// Opening fails on every deviation from what was sealed. #[test] fn open_rejects_any_deviation_from_what_was_sealed() { - let mut f = fixture(PkeSchemeType::MlKem512, 500); - let cipher = f.seal_msg(b"bound to one sender and one receiver"); + let mut f = fixture(PkeSchemeType::MlKem512, 500, pair()); + let sealer = UnifiedSigncryptionKey::new_multi(&f.identity, &f.enc_key, &f.receiver_id); + let cipher = seal( + &sealer, + &mut f.rng, + DSEP, + &f.schemes, + b"bound to one sender and one receiver", + ) + .unwrap(); // The untouched envelope opens, so each rejection below is the // deviation and not an unrelated failure. - f.open(&cipher).unwrap(); + let reader = + UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id); + reader.open(DSEP, &cipher).unwrap(); + + assert!( + reader.open(b"OTHERDSP", &cipher).is_err(), + "a different domain separator opened the envelope" + ); + + let mut flipped = cipher.clone(); + flipped.payload[0] ^= 0x01; + assert!( + reader.open(DSEP, &flipped).is_err(), + "a tampered ciphertext opened the envelope" + ); let mut rng = AesRng::seed_from_u64(999); let other_keys = VerfKeySet::from_identity(&seeded_identity(&mut rng), &f.schemes).unwrap(); assert!( - f.reader_with(&other_keys, &f.receiver_id) - .open(DSEP, &cipher) - .is_err(), + UnifiedUnsigncryptionKey::new_multi( + &f.dec_key, + &f.enc_key, + &other_keys, + &f.receiver_id + ) + .open(DSEP, &cipher) + .is_err(), "another party's key set opened the envelope" ); let other_id = b"a different receiver".to_vec(); assert!( - f.reader_with(&f.keys, &other_id) + UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &other_id) .open(DSEP, &cipher) .is_err(), "a different receiver id opened the envelope" ); - - assert!( - f.reader().open(b"OTHERDSP", &cipher).is_err(), - "a different domain separator opened the envelope" - ); - - let mut flipped = cipher.clone(); - flipped.payload[0] ^= 0x01; - assert!( - f.open(&flipped).is_err(), - "a tampered ciphertext opened the envelope" - ); } /// `SignerAuth` names the layout, so the composite sealer refuses a frozen diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 2712ca5ca4..1eb3677e35 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -162,17 +162,8 @@ fn check_format_and_signature( fn receiver_binding( receiver_id: &[u8], enc_key: &UnifiedPublicEncKey, -) -> Result<[u8; DIGEST_BYTES + 20], CryptographyError> { - if receiver_id.len() != 20 { - return Err(CryptographyError::LengthError(format!( - "Receiver ID must be 20 bytes (the Ethereum address), got {} bytes", - receiver_id.len() - ))); - } - let mut out = [0u8; DIGEST_BYTES + 20]; - out[..20].copy_from_slice(receiver_id); - out[20..].copy_from_slice(receiver_enc_key_digest(enc_key)?.as_slice()); - Ok(out) +) -> Result, CryptographyError> { + Ok([receiver_id, receiver_enc_key_digest(enc_key)?.as_slice()].concat()) } /// Decrypt a signcrypted message and ignore the signature @@ -326,7 +317,6 @@ mod tests { let other = signcryption_fixture(PkeSchemeType::MlKem512, 501); let base = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); - println!("base: {:?}", base.len()); assert_eq!(base, receiver_binding(&f.receiver_id, &f.enc_key).unwrap()); assert_ne!( base, From c1f87dd8435fb4df025816cc824b06f04bd44583 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 14:34:40 +0200 Subject: [PATCH 060/114] chore: removed versioning from unified signcryption keys since they are never actually persisted and block steamlining --- .../data/0_13_0/kms/designcryption_key.bcode | 3 - .../data/0_13_0/kms/signcryption_key.bcode | 3 - .../data/0_13_10/kms/designcryption_key.bcode | 3 - .../data/0_13_10/kms/signcryption_key.bcode | 3 - .../data/0_13_20/kms/designcryption_key.bcode | 3 - .../data/0_13_20/kms/signcryption_key.bcode | 3 - .../data/0_14_0/kms/designcryption_key.bcode | 3 - .../data/0_14_0/kms/signcryption_key.bcode | 3 - .../data/0_15_0/kms/designcryption_key.bcode | 3 - .../data/0_15_0/kms/signcryption_key.bcode | 3 - backward-compatibility/data/kms.ron | 80 ------------------- .../generate-v0.13.0/src/data_0_13.rs | 49 +----------- .../generate-v0.13.10/src/data_0_13.rs | 48 +---------- .../generate-v0.13.20/src/data_0_13.rs | 48 +---------- .../generate-v0.14.0/src/data_0_14.rs | 48 +---------- .../generate-v0.15.0/src/data_0_15.rs | 49 +----------- backward-compatibility/src/lib.rs | 44 ---------- .../src/cryptography/signcryption/mod.rs | 29 +++---- .../tests/backward_compatibility_kms.rs | 74 +---------------- 19 files changed, 23 insertions(+), 476 deletions(-) delete mode 100644 backward-compatibility/data/0_13_0/kms/designcryption_key.bcode delete mode 100644 backward-compatibility/data/0_13_0/kms/signcryption_key.bcode delete mode 100644 backward-compatibility/data/0_13_10/kms/designcryption_key.bcode delete mode 100644 backward-compatibility/data/0_13_10/kms/signcryption_key.bcode delete mode 100644 backward-compatibility/data/0_13_20/kms/designcryption_key.bcode delete mode 100644 backward-compatibility/data/0_13_20/kms/signcryption_key.bcode delete mode 100644 backward-compatibility/data/0_14_0/kms/designcryption_key.bcode delete mode 100644 backward-compatibility/data/0_14_0/kms/signcryption_key.bcode delete mode 100644 backward-compatibility/data/0_15_0/kms/designcryption_key.bcode delete mode 100644 backward-compatibility/data/0_15_0/kms/signcryption_key.bcode diff --git a/backward-compatibility/data/0_13_0/kms/designcryption_key.bcode b/backward-compatibility/data/0_13_0/kms/designcryption_key.bcode deleted file mode 100644 index ccb5d6080a..0000000000 --- a/backward-compatibility/data/0_13_0/kms/designcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b10c5bf4b215b2204709f5c7ee3f567a7072b321b514f2364ebaf5d3f1ba15ac -size 2541 diff --git a/backward-compatibility/data/0_13_0/kms/signcryption_key.bcode b/backward-compatibility/data/0_13_0/kms/signcryption_key.bcode deleted file mode 100644 index 1bc463d85d..0000000000 --- a/backward-compatibility/data/0_13_0/kms/signcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef6450d800773acdaed4163734882a117889436fa0738b63609fa36bdc5fcf19 -size 892 diff --git a/backward-compatibility/data/0_13_10/kms/designcryption_key.bcode b/backward-compatibility/data/0_13_10/kms/designcryption_key.bcode deleted file mode 100644 index ccb5d6080a..0000000000 --- a/backward-compatibility/data/0_13_10/kms/designcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b10c5bf4b215b2204709f5c7ee3f567a7072b321b514f2364ebaf5d3f1ba15ac -size 2541 diff --git a/backward-compatibility/data/0_13_10/kms/signcryption_key.bcode b/backward-compatibility/data/0_13_10/kms/signcryption_key.bcode deleted file mode 100644 index 1bc463d85d..0000000000 --- a/backward-compatibility/data/0_13_10/kms/signcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef6450d800773acdaed4163734882a117889436fa0738b63609fa36bdc5fcf19 -size 892 diff --git a/backward-compatibility/data/0_13_20/kms/designcryption_key.bcode b/backward-compatibility/data/0_13_20/kms/designcryption_key.bcode deleted file mode 100644 index ccb5d6080a..0000000000 --- a/backward-compatibility/data/0_13_20/kms/designcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b10c5bf4b215b2204709f5c7ee3f567a7072b321b514f2364ebaf5d3f1ba15ac -size 2541 diff --git a/backward-compatibility/data/0_13_20/kms/signcryption_key.bcode b/backward-compatibility/data/0_13_20/kms/signcryption_key.bcode deleted file mode 100644 index 1bc463d85d..0000000000 --- a/backward-compatibility/data/0_13_20/kms/signcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef6450d800773acdaed4163734882a117889436fa0738b63609fa36bdc5fcf19 -size 892 diff --git a/backward-compatibility/data/0_14_0/kms/designcryption_key.bcode b/backward-compatibility/data/0_14_0/kms/designcryption_key.bcode deleted file mode 100644 index ccb5d6080a..0000000000 --- a/backward-compatibility/data/0_14_0/kms/designcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b10c5bf4b215b2204709f5c7ee3f567a7072b321b514f2364ebaf5d3f1ba15ac -size 2541 diff --git a/backward-compatibility/data/0_14_0/kms/signcryption_key.bcode b/backward-compatibility/data/0_14_0/kms/signcryption_key.bcode deleted file mode 100644 index 1bc463d85d..0000000000 --- a/backward-compatibility/data/0_14_0/kms/signcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef6450d800773acdaed4163734882a117889436fa0738b63609fa36bdc5fcf19 -size 892 diff --git a/backward-compatibility/data/0_15_0/kms/designcryption_key.bcode b/backward-compatibility/data/0_15_0/kms/designcryption_key.bcode deleted file mode 100644 index ccb5d6080a..0000000000 --- a/backward-compatibility/data/0_15_0/kms/designcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b10c5bf4b215b2204709f5c7ee3f567a7072b321b514f2364ebaf5d3f1ba15ac -size 2541 diff --git a/backward-compatibility/data/0_15_0/kms/signcryption_key.bcode b/backward-compatibility/data/0_15_0/kms/signcryption_key.bcode deleted file mode 100644 index 1bc463d85d..0000000000 --- a/backward-compatibility/data/0_15_0/kms/signcryption_key.bcode +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ef6450d800773acdaed4163734882a117889436fa0738b63609fa36bdc5fcf19 -size 892 diff --git a/backward-compatibility/data/kms.ron b/backward-compatibility/data/kms.ron index 0bf7539ccb..3cec0d3d38 100644 --- a/backward-compatibility/data/kms.ron +++ b/backward-compatibility/data/kms.ron @@ -438,22 +438,6 @@ ], )), ), - ( - kms_core_version_min: "0.13.0", - kms_core_module: "kms", - metadata: UnifiedSigncryptionKeyOwned(( - test_filename: "signcryption_key", - state: 100, - )), - ), - ( - kms_core_version_min: "0.13.0", - kms_core_module: "kms", - metadata: UnifiedUnsigncryptionKeyOwned(( - test_filename: "designcryption_key", - state: 200, - )), - ), ( kms_core_version_min: "0.13.0", kms_core_module: "kms", @@ -736,22 +720,6 @@ ], )), ), - ( - kms_core_version_min: "0.13.10", - kms_core_module: "kms", - metadata: UnifiedSigncryptionKeyOwned(( - test_filename: "signcryption_key", - state: 100, - )), - ), - ( - kms_core_version_min: "0.13.10", - kms_core_module: "kms", - metadata: UnifiedUnsigncryptionKeyOwned(( - test_filename: "designcryption_key", - state: 200, - )), - ), ( kms_core_version_min: "0.13.10", kms_core_module: "kms", @@ -1064,22 +1032,6 @@ ], )), ), - ( - kms_core_version_min: "0.13.20", - kms_core_module: "kms", - metadata: UnifiedSigncryptionKeyOwned(( - test_filename: "signcryption_key", - state: 100, - )), - ), - ( - kms_core_version_min: "0.13.20", - kms_core_module: "kms", - metadata: UnifiedUnsigncryptionKeyOwned(( - test_filename: "designcryption_key", - state: 200, - )), - ), ( kms_core_version_min: "0.13.20", kms_core_module: "kms", @@ -1392,22 +1344,6 @@ ], )), ), - ( - kms_core_version_min: "0.14.0", - kms_core_module: "kms", - metadata: UnifiedSigncryptionKeyOwned(( - test_filename: "signcryption_key", - state: 100, - )), - ), - ( - kms_core_version_min: "0.14.0", - kms_core_module: "kms", - metadata: UnifiedUnsigncryptionKeyOwned(( - test_filename: "designcryption_key", - state: 200, - )), - ), ( kms_core_version_min: "0.14.0", kms_core_module: "kms", @@ -1763,22 +1699,6 @@ ], )), ), - ( - kms_core_version_min: "0.15.0", - kms_core_module: "kms", - metadata: UnifiedSigncryptionKeyOwned(( - test_filename: "signcryption_key", - state: 100, - )), - ), - ( - kms_core_version_min: "0.15.0", - kms_core_module: "kms", - metadata: UnifiedUnsigncryptionKeyOwned(( - test_filename: "designcryption_key", - state: 200, - )), - ), ( kms_core_version_min: "0.15.0", kms_core_module: "kms", diff --git a/backward-compatibility/generate-v0.13.0/src/data_0_13.rs b/backward-compatibility/generate-v0.13.0/src/data_0_13.rs index 985b424ece..8b4127580f 100644 --- a/backward-compatibility/generate-v0.13.0/src/data_0_13.rs +++ b/backward-compatibility/generate-v0.13.0/src/data_0_13.rs @@ -7,7 +7,7 @@ use kms_0_13_0::cryptography::{ encryption::{Encryption, PkeScheme, PkeSchemeType}, hybrid_ml_kem::HybridKemCt, signatures::{compute_eip712_signature, gen_sig_keys}, - signcryption::{Signcrypt, UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned}, + signcryption::{Signcrypt, UnifiedSigncryptionKeyOwned}, }; use kms_0_13_0::engine::base::{ safe_serialize_hash_element_versioned, CrsGenMetadata, KeyGenMetadataInner, KmsFheKeyHandles, @@ -76,8 +76,8 @@ use backward_compatibility::{ PrivateKeySetTest, PrivateSigKeyTest, PrssSetTest, PrssSetupCombinedTest, PubDataTypeTest, PublicSigKeyTest, ReleasePCRValuesTest, ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SoftwareVersionTest, TestMetadataDD, TestMetadataKMS, - TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedSigncryptionKeyTest, - UnifiedSigncryptionTest, UnifiedUnsigncryptionKeyTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, + TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, + UnifiedSigncryptionTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, KMS_MODULE_NAME, }; @@ -370,17 +370,7 @@ fn signcryption_payload_test() -> SigncryptionPayloadTest { } // KMS test -const SIGNCRYPTION_KEY_TEST: UnifiedSigncryptionKeyTest = UnifiedSigncryptionKeyTest { - test_filename: Cow::Borrowed("signcryption_key"), - state: 100, -}; - // KMS test -const UNSIGNCRYPTION_KEY_TEST: UnifiedUnsigncryptionKeyTest = UnifiedUnsigncryptionKeyTest { - test_filename: Cow::Borrowed("designcryption_key"), - state: 200, -}; - // KMS test const UNIFIED_SIGNCRYPTION_TEST: UnifiedSigncryptionTest = UnifiedSigncryptionTest { test_filename: Cow::Borrowed("unified_signcryption"), @@ -616,37 +606,6 @@ impl KmsV0_13 { TestMetadataKMS::SigncryptionPayload(test) } - fn gen_signcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(SIGNCRYPTION_KEY_TEST.state); - let (_verf_key, server_sig_key) = gen_sig_keys(&mut rng); - let (client_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (_dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedSigncryptionKeyOwned::new( - server_sig_key, - enc_key, - client_verf_key.verf_key_id(), - ); - store_versioned_test!(&signcrypt_key, dir, &SIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedSigncryptionKeyOwned(SIGNCRYPTION_KEY_TEST) - } - - fn gen_designcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(UNSIGNCRYPTION_KEY_TEST.state); - let (sender_verf_key, _sender_sig_key) = gen_sig_keys(&mut rng); - let (receiver_verf_key, _receiver_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedUnsigncryptionKeyOwned::new( - dec_key, - enc_key, - sender_verf_key, - receiver_verf_key.verf_key_id().to_vec(), - ); - store_versioned_test!(&signcrypt_key, dir, &UNSIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedUnsigncryptionKeyOwned(UNSIGNCRYPTION_KEY_TEST) - } - fn gen_unified_signcryption(dir: &PathBuf) -> TestMetadataKMS { let mut rng = AesRng::seed_from_u64(UNIFIED_SIGNCRYPTION_TEST.state); let (verf_key, server_sig_key) = gen_sig_keys(&mut rng); @@ -1198,8 +1157,6 @@ impl KMSCoreVersion for V0_13 { KmsV0_13::gen_crs_metadata(&dir), KmsV0_13::gen_typed_plaintext(&dir), KmsV0_13::gen_signcryption_payload(&dir), - KmsV0_13::gen_signcryption_key(&dir), - KmsV0_13::gen_designcryption_key(&dir), KmsV0_13::gen_unified_signcryption(&dir), KmsV0_13::gen_hybrid_kem_ct(&dir), KmsV0_13::gen_prss_setup_combined(&dir), diff --git a/backward-compatibility/generate-v0.13.10/src/data_0_13.rs b/backward-compatibility/generate-v0.13.10/src/data_0_13.rs index 8f1aca9faa..995b7cd237 100644 --- a/backward-compatibility/generate-v0.13.10/src/data_0_13.rs +++ b/backward-compatibility/generate-v0.13.10/src/data_0_13.rs @@ -7,7 +7,7 @@ use kms_0_13_10::cryptography::{ encryption::{Encryption, PkeScheme, PkeSchemeType}, hybrid_ml_kem::HybridKemCt, signatures::{compute_eip712_signature, gen_sig_keys}, - signcryption::{Signcrypt, UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned}, + signcryption::{Signcrypt, UnifiedSigncryptionKeyOwned}, }; use kms_0_13_10::engine::base::{ safe_serialize_hash_element_versioned, CrsGenMetadata, KeyGenMetadataInner, KmsFheKeyHandles, @@ -76,8 +76,7 @@ use backward_compatibility::{ PrivateSigKeyTest, PrssSetTest, PrssSetupCombinedTest, PubDataTypeTest, PublicSigKeyTest, ReleasePCRValuesTest, ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SoftwareVersionTest, TestMetadataDD, TestMetadataKMS, TestMetadataKmsGrpc, - ThresholdFheKeysTest, TypedPlaintextTest, UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, - UnifiedUnsigncryptionKeyTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, + ThresholdFheKeysTest, TypedPlaintextTest, UnifiedSigncryptionTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, KMS_MODULE_NAME, }; @@ -362,17 +361,7 @@ fn signcryption_payload_test() -> SigncryptionPayloadTest { } // KMS test -const SIGNCRYPTION_KEY_TEST: UnifiedSigncryptionKeyTest = UnifiedSigncryptionKeyTest { - test_filename: Cow::Borrowed("signcryption_key"), - state: 100, -}; - // KMS test -const UNSIGNCRYPTION_KEY_TEST: UnifiedUnsigncryptionKeyTest = UnifiedUnsigncryptionKeyTest { - test_filename: Cow::Borrowed("designcryption_key"), - state: 200, -}; - // KMS test const UNIFIED_SIGNCRYPTION_TEST: UnifiedSigncryptionTest = UnifiedSigncryptionTest { test_filename: Cow::Borrowed("unified_signcryption"), @@ -608,37 +597,6 @@ impl KmsV0_13_10 { TestMetadataKMS::SigncryptionPayload(test) } - fn gen_signcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(SIGNCRYPTION_KEY_TEST.state); - let (_verf_key, server_sig_key) = gen_sig_keys(&mut rng); - let (client_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (_dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedSigncryptionKeyOwned::new( - server_sig_key, - enc_key, - client_verf_key.verf_key_id(), - ); - store_versioned_test!(&signcrypt_key, dir, &SIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedSigncryptionKeyOwned(SIGNCRYPTION_KEY_TEST) - } - - fn gen_designcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(UNSIGNCRYPTION_KEY_TEST.state); - let (sender_verf_key, _sender_sig_key) = gen_sig_keys(&mut rng); - let (receiver_verf_key, _receiver_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedUnsigncryptionKeyOwned::new( - dec_key, - enc_key, - sender_verf_key, - receiver_verf_key.verf_key_id().to_vec(), - ); - store_versioned_test!(&signcrypt_key, dir, &UNSIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedUnsigncryptionKeyOwned(UNSIGNCRYPTION_KEY_TEST) - } - fn gen_unified_signcryption(dir: &PathBuf) -> TestMetadataKMS { let mut rng = AesRng::seed_from_u64(UNIFIED_SIGNCRYPTION_TEST.state); let (verf_key, server_sig_key) = gen_sig_keys(&mut rng); @@ -1171,8 +1129,6 @@ impl KMSCoreVersion for V0_13_10 { KmsV0_13_10::gen_crs_metadata(&dir), KmsV0_13_10::gen_typed_plaintext(&dir), KmsV0_13_10::gen_signcryption_payload(&dir), - KmsV0_13_10::gen_signcryption_key(&dir), - KmsV0_13_10::gen_designcryption_key(&dir), KmsV0_13_10::gen_unified_signcryption(&dir), KmsV0_13_10::gen_hybrid_kem_ct(&dir), KmsV0_13_10::gen_prss_setup_combined(&dir), diff --git a/backward-compatibility/generate-v0.13.20/src/data_0_13.rs b/backward-compatibility/generate-v0.13.20/src/data_0_13.rs index 0918391c9f..e06f43fd40 100644 --- a/backward-compatibility/generate-v0.13.20/src/data_0_13.rs +++ b/backward-compatibility/generate-v0.13.20/src/data_0_13.rs @@ -11,7 +11,7 @@ use kms_0_13_20::cryptography::{ encryption::{Encryption, PkeScheme, PkeSchemeType}, hybrid_ml_kem::HybridKemCt, signatures::{compute_eip712_signature, gen_sig_keys}, - signcryption::{Signcrypt, UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned}, + signcryption::{Signcrypt, UnifiedSigncryptionKeyOwned}, }; use kms_0_13_20::engine::base::{ safe_serialize_hash_element_versioned, CrsGenMetadata, KeyGenMetadataInner, KmsFheKeyHandles, @@ -79,8 +79,7 @@ use backward_compatibility::{ NodeInfoTest, PRSSSetupTest, PrfKeyTest, PrivDataTypeTest, PrivateSigKeyTest, PrssSetTest, PrssSetupCombinedTest, PubDataTypeTest, PublicSigKeyTest, ReleasePCRValuesTest, ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SoftwareVersionTest, TestMetadataDD, - TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, - UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, UnifiedUnsigncryptionKeyTest, + TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedSigncryptionTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, KMS_MODULE_NAME, }; @@ -382,17 +381,7 @@ fn signcryption_payload_test() -> SigncryptionPayloadTest { } // KMS test -const SIGNCRYPTION_KEY_TEST: UnifiedSigncryptionKeyTest = UnifiedSigncryptionKeyTest { - test_filename: Cow::Borrowed("signcryption_key"), - state: 100, -}; - // KMS test -const UNSIGNCRYPTION_KEY_TEST: UnifiedUnsigncryptionKeyTest = UnifiedUnsigncryptionKeyTest { - test_filename: Cow::Borrowed("designcryption_key"), - state: 200, -}; - // KMS test const UNIFIED_SIGNCRYPTION_TEST: UnifiedSigncryptionTest = UnifiedSigncryptionTest { test_filename: Cow::Borrowed("unified_signcryption"), @@ -709,37 +698,6 @@ impl KmsV0_13_20 { TestMetadataKMS::SigncryptionPayload(test) } - fn gen_signcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(SIGNCRYPTION_KEY_TEST.state); - let (_verf_key, server_sig_key) = gen_sig_keys(&mut rng); - let (client_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (_dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedSigncryptionKeyOwned::new( - server_sig_key, - enc_key, - client_verf_key.verf_key_id(), - ); - store_versioned_test!(&signcrypt_key, dir, &SIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedSigncryptionKeyOwned(SIGNCRYPTION_KEY_TEST) - } - - fn gen_designcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(UNSIGNCRYPTION_KEY_TEST.state); - let (sender_verf_key, _sender_sig_key) = gen_sig_keys(&mut rng); - let (receiver_verf_key, _receiver_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedUnsigncryptionKeyOwned::new( - dec_key, - enc_key, - sender_verf_key, - receiver_verf_key.verf_key_id().to_vec(), - ); - store_versioned_test!(&signcrypt_key, dir, &UNSIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedUnsigncryptionKeyOwned(UNSIGNCRYPTION_KEY_TEST) - } - fn gen_unified_signcryption(dir: &PathBuf) -> TestMetadataKMS { let mut rng = AesRng::seed_from_u64(UNIFIED_SIGNCRYPTION_TEST.state); let (verf_key, server_sig_key) = gen_sig_keys(&mut rng); @@ -1275,8 +1233,6 @@ impl KMSCoreVersion for V0_13_20 { KmsV0_13_20::gen_crs_metadata_with_extra_data(&dir), KmsV0_13_20::gen_typed_plaintext(&dir), KmsV0_13_20::gen_signcryption_payload(&dir), - KmsV0_13_20::gen_signcryption_key(&dir), - KmsV0_13_20::gen_designcryption_key(&dir), KmsV0_13_20::gen_unified_signcryption(&dir), KmsV0_13_20::gen_hybrid_kem_ct(&dir), KmsV0_13_20::gen_prss_setup_combined(&dir), diff --git a/backward-compatibility/generate-v0.14.0/src/data_0_14.rs b/backward-compatibility/generate-v0.14.0/src/data_0_14.rs index 5e0f72f24d..0b281e51de 100644 --- a/backward-compatibility/generate-v0.14.0/src/data_0_14.rs +++ b/backward-compatibility/generate-v0.14.0/src/data_0_14.rs @@ -12,7 +12,7 @@ use kms_0_14_0::cryptography::{ hybrid_ml_kem::HybridKemCt, signatures::{compute_eip712_signature, gen_sig_keys}, signcryption::{ - Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned, + Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, }, }; use kms_0_14_0::engine::base::{CrsGenMetadata, KeyGenMetadataInner, KmsFheKeyHandles}; @@ -82,8 +82,7 @@ use backward_compatibility::{ NodeInfoTest, PRSSSetupTest, PrfKeyTest, PrivDataTypeTest, PrivateSigKeyTest, PrssSetTest, PrssSetupCombinedTest, PubDataTypeTest, PublicSigKeyTest, ReleasePCRValuesTest, ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SoftwareVersionTest, TestMetadataDD, - TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, - UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, UnifiedUnsigncryptionKeyTest, + TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedSigncryptionTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, KMS_MODULE_NAME, }; use hashing_0_14_0::hash_versioned; @@ -392,17 +391,7 @@ fn signcryption_payload_test() -> SigncryptionPayloadTest { } // KMS test -const SIGNCRYPTION_KEY_TEST: UnifiedSigncryptionKeyTest = UnifiedSigncryptionKeyTest { - test_filename: Cow::Borrowed("signcryption_key"), - state: 100, -}; - // KMS test -const UNSIGNCRYPTION_KEY_TEST: UnifiedUnsigncryptionKeyTest = UnifiedUnsigncryptionKeyTest { - test_filename: Cow::Borrowed("designcryption_key"), - state: 200, -}; - // KMS test const UNIFIED_SIGNCRYPTION_TEST: UnifiedSigncryptionTest = UnifiedSigncryptionTest { test_filename: Cow::Borrowed("unified_signcryption"), @@ -719,37 +708,6 @@ impl KmsV0_14_0 { TestMetadataKMS::SigncryptionPayload(test) } - fn gen_signcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(SIGNCRYPTION_KEY_TEST.state); - let (_verf_key, server_sig_key) = gen_sig_keys(&mut rng); - let (client_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (_dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedSigncryptionKeyOwned::new( - server_sig_key, - enc_key, - client_verf_key.verf_key_id(), - ); - store_versioned_test!(&signcrypt_key, dir, &SIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedSigncryptionKeyOwned(SIGNCRYPTION_KEY_TEST) - } - - fn gen_designcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(UNSIGNCRYPTION_KEY_TEST.state); - let (sender_verf_key, _sender_sig_key) = gen_sig_keys(&mut rng); - let (receiver_verf_key, _receiver_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedUnsigncryptionKeyOwned::new( - dec_key, - enc_key, - sender_verf_key, - receiver_verf_key.verf_key_id().to_vec(), - ); - store_versioned_test!(&signcrypt_key, dir, &UNSIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedUnsigncryptionKeyOwned(UNSIGNCRYPTION_KEY_TEST) - } - fn gen_unified_signcryption(dir: &PathBuf) -> TestMetadataKMS { let mut rng = AesRng::seed_from_u64(UNIFIED_SIGNCRYPTION_TEST.state); let (verf_key, server_sig_key) = gen_sig_keys(&mut rng); @@ -1283,8 +1241,6 @@ impl KMSCoreVersion for V0_14_0 { KmsV0_14_0::gen_crs_metadata_with_extra_data(&dir), KmsV0_14_0::gen_typed_plaintext(&dir), KmsV0_14_0::gen_signcryption_payload(&dir), - KmsV0_14_0::gen_signcryption_key(&dir), - KmsV0_14_0::gen_designcryption_key(&dir), KmsV0_14_0::gen_unified_signcryption(&dir), KmsV0_14_0::gen_hybrid_kem_ct(&dir), KmsV0_14_0::gen_prss_setup_combined(&dir), diff --git a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs index 692b956e06..82b0e20351 100644 --- a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs +++ b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs @@ -30,7 +30,7 @@ use kms_0_15_0::cryptography::{ SigningSchemeType, StoredTypedSignature, UnifiedPublicSigKey, }, signcryption::{ - Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, UnifiedUnsigncryptionKeyOwned, + Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, }, }; use kms_0_15_0::engine::base::{ @@ -116,8 +116,8 @@ use backward_compatibility::{ ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SoftwareVersionTest, StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataDD, TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, - UnifiedCipherTest, UnifiedPublicSigKeyTest, UnifiedSigncryptionKeyTest, - UnifiedSigncryptionTest, UnifiedUnsigncryptionKeyTest, UserDecSignedPayloadTest, + UnifiedCipherTest, UnifiedPublicSigKeyTest, + UnifiedSigncryptionTest, UserDecSignedPayloadTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, KMS_MODULE_NAME, }; use hashing_0_15_0::hash_versioned; @@ -479,17 +479,7 @@ fn signcryption_payload_test() -> SigncryptionPayloadTest { } // KMS test -const SIGNCRYPTION_KEY_TEST: UnifiedSigncryptionKeyTest = UnifiedSigncryptionKeyTest { - test_filename: Cow::Borrowed("signcryption_key"), - state: 100, -}; - // KMS test -const UNSIGNCRYPTION_KEY_TEST: UnifiedUnsigncryptionKeyTest = UnifiedUnsigncryptionKeyTest { - test_filename: Cow::Borrowed("designcryption_key"), - state: 200, -}; - const MLKEM1024_P384_PUBLIC_KEY_TEST: MlKem1024P384PublicKeyTest = MlKem1024P384PublicKeyTest { test_filename: Cow::Borrowed("mlkem1024_p384_public_key"), state: 384, @@ -1068,37 +1058,6 @@ impl KmsV0_15_0 { TestMetadataKMS::SigncryptionPayload(test) } - fn gen_signcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(SIGNCRYPTION_KEY_TEST.state); - let (_verf_key, server_sig_key) = gen_sig_keys(&mut rng); - let (client_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (_dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedSigncryptionKeyOwned::new( - server_sig_key, - enc_key, - client_verf_key.verf_key_id(), - ); - store_versioned_test!(&signcrypt_key, dir, &SIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedSigncryptionKeyOwned(SIGNCRYPTION_KEY_TEST) - } - - fn gen_designcryption_key(dir: &PathBuf) -> TestMetadataKMS { - let mut rng = AesRng::seed_from_u64(UNSIGNCRYPTION_KEY_TEST.state); - let (sender_verf_key, _sender_sig_key) = gen_sig_keys(&mut rng); - let (receiver_verf_key, _receiver_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedUnsigncryptionKeyOwned::new( - dec_key, - enc_key, - sender_verf_key, - receiver_verf_key.verf_key_id().to_vec(), - ); - store_versioned_test!(&signcrypt_key, dir, &UNSIGNCRYPTION_KEY_TEST.test_filename); - TestMetadataKMS::UnifiedUnsigncryptionKeyOwned(UNSIGNCRYPTION_KEY_TEST) - } - fn gen_mlkem1024_p384_public_key(dir: &PathBuf) -> TestMetadataKMS { let mut rng = AesRng::seed_from_u64(MLKEM1024_P384_PUBLIC_KEY_TEST.state); let mut encryption = Encryption::new(PkeSchemeType::MlKem1024P384, &mut rng); @@ -2170,8 +2129,6 @@ impl KMSCoreVersion for V0_15_0 { KmsV0_15_0::gen_stored_eip712_domain(&dir), KmsV0_15_0::gen_typed_plaintext(&dir), KmsV0_15_0::gen_signcryption_payload(&dir), - KmsV0_15_0::gen_signcryption_key(&dir), - KmsV0_15_0::gen_designcryption_key(&dir), KmsV0_15_0::gen_mlkem1024_p384_public_key(&dir), KmsV0_15_0::gen_mlkem1024_p384_private_key(&dir), KmsV0_15_0::gen_unified_signcryption(&dir), diff --git a/backward-compatibility/src/lib.rs b/backward-compatibility/src/lib.rs index c23c529068..010441aa57 100644 --- a/backward-compatibility/src/lib.rs +++ b/backward-compatibility/src/lib.rs @@ -702,48 +702,6 @@ impl TestType for SigncryptionPayloadTest { } } -// KMS test -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct UnifiedSigncryptionKeyTest { - pub test_filename: Cow<'static, str>, - pub state: u64, -} - -impl TestType for UnifiedSigncryptionKeyTest { - fn module(&self) -> String { - KMS_MODULE_NAME.to_string() - } - - fn target_type(&self) -> String { - "UnifiedSigncryptionKeyOwned".to_string() - } - - fn test_filename(&self) -> String { - self.test_filename.to_string() - } -} - -// KMS test -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct UnifiedUnsigncryptionKeyTest { - pub test_filename: Cow<'static, str>, - pub state: u64, -} - -impl TestType for UnifiedUnsigncryptionKeyTest { - fn module(&self) -> String { - KMS_MODULE_NAME.to_string() - } - - fn target_type(&self) -> String { - "UnifiedUnsigncryptionKeyOwned".to_string() - } - - fn test_filename(&self) -> String { - self.test_filename.to_string() - } -} - /// Test metadata for MLKEM1024-P384 public key backward compatibility. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct MlKem1024P384PublicKeyTest { @@ -1310,8 +1268,6 @@ pub enum TestMetadataKMS { SigncryptionPayload(SigncryptionPayloadTest), PrssSetupCombined(PrssSetupCombinedTest), EpochData(EpochDataTest), - UnifiedSigncryptionKeyOwned(UnifiedSigncryptionKeyTest), - UnifiedUnsigncryptionKeyOwned(UnifiedUnsigncryptionKeyTest), MlKem1024P384PublicKey(MlKem1024P384PublicKeyTest), MlKem1024P384PrivateKey(MlKem1024P384PrivateKeyTest), UnifiedSigncryption(UnifiedSigncryptionTest), diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 2994c3de94..b5c1227bcb 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -109,15 +109,12 @@ pub trait UnsigncryptFHEPlaintext: Unsigncrypt { ) -> Result; } -#[derive( - Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop, VersionsDispatch, -)] -pub enum UnifiedSigncryptionKeyOwnedVersions { - V0(UnifiedSigncryptionKeyOwned), -} - -#[derive(Clone, Eq, PartialEq, Serialize, Deserialize, Debug, Zeroize, Versionize)] -#[versionize(UnifiedSigncryptionKeyOwnedVersions)] +/// An owning sealer, for a caller that cannot hold borrows across a task +/// boundary. +/// +/// Deliberately not serializable: no `PrivDataType` variant is a signcryption +/// key, so nothing persists this. It needs no version dispatch. +#[derive(Clone, Eq, PartialEq, Debug, Zeroize)] pub struct UnifiedSigncryptionKeyOwned { pub signing_key: PrivateSigKey, pub receiver_enc_key: UnifiedPublicEncKey, @@ -297,15 +294,9 @@ impl HasPkeScheme for UnifiedUnsigncryptionKey<'_> { } } -#[derive( - Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop, VersionsDispatch, -)] -pub enum UnifiedUnsigncryptionKeyOwnedVersions { - V0(UnifiedUnsigncryptionKeyOwned), -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Versionize)] -#[versionize(UnifiedUnsigncryptionKeyOwnedVersions)] +/// An owning reader, the counterpart of [`UnifiedSigncryptionKeyOwned`], and +/// not serializable for the same reason. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct UnifiedUnsigncryptionKeyOwned { pub decryption_key: UnifiedPrivateEncKey, pub encryption_key: UnifiedPublicEncKey, // Needed for validation of the signcrypted payload @@ -702,7 +693,7 @@ pub fn ephemeral_signcryption_key_generation( /// Helper struct that contains both signcryption and unsigncryption keys for a client /// For now only used for testing #[cfg(test)] -#[derive(Clone, Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +#[derive(Clone, Debug, Zeroize, ZeroizeOnDrop)] pub struct UnifiedSigncryptionKeyPairOwned { pub signcrypt_key: UnifiedSigncryptionKeyOwned, pub unsigncryption_key: UnifiedUnsigncryptionKeyOwned, diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index beabc28ef0..1991bd838b 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -20,8 +20,7 @@ use backward_compatibility::{ RootSigningSeedTest, SchemeDigestsTest, SigncryptionPayloadTest, SoftwareVersionTest, StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataKMS, TestType, Testcase, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedCipherTest, UnifiedPublicSigKeyTest, - UnifiedSigncryptionKeyTest, UnifiedSigncryptionTest, UnifiedUnsigncryptionKeyTest, - UserDecSignedPayloadTest, data_dir, + UnifiedSigncryptionTest, UserDecSignedPayloadTest, data_dir, load::{DataFormat, TestFailure, TestResult, TestSuccess}, tests::{TestedModule, run_all_tests}, }; @@ -703,71 +702,6 @@ fn test_unified_public_sig_key( Ok(test.success(format)) } -fn test_signcryption_keys( - dir: &Path, - test: &UnifiedSigncryptionKeyTest, - format: DataFormat, -) -> Result { - let original_versionized: UnifiedSigncryptionKeyOwned = - load_and_unversionize(dir, test, format)?; - let mut rng = AesRng::seed_from_u64(test.state); - let (_, server_sig_key) = gen_sig_keys(&mut rng); - let (client_verf_key, _) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (_, enc_key) = encryption.keygen().unwrap(); - let new_versionized = UnifiedSigncryptionKeyOwned::new( - server_sig_key.clone(), - enc_key, - client_verf_key.verf_key_id().to_vec(), - ); - - if original_versionized != new_versionized { - Err(test.failure( - format!( - "Invalid UnifiedSigncryptionKeyOwned:\n Expected :\n{original_versionized:?}\nGot:\n{new_versionized:?}" - ), - format, - )) - } else { - Ok(test.success(format)) - } -} - -/// Observe that this test also indirectly tests UnifiedPublicEncKey and UnifiedPrivateEncKey -/// Also note that while these keys are currently not stored on disc, they are generated from a seedphrase -/// for the custodians, so we still need to ensure that they do not change format unexpectedly! -/// Hence we keep them versioned -fn test_unsigncryption_keys( - dir: &Path, - test: &UnifiedUnsigncryptionKeyTest, - format: DataFormat, -) -> Result { - let original_versionized: UnifiedUnsigncryptionKeyOwned = - load_and_unversionize(dir, test, format)?; - let mut rng = AesRng::seed_from_u64(test.state); - let (server_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); - let (client_verf_key, _client_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (dec_key, enc_key) = encryption.keygen().unwrap(); - let new_versionized = UnifiedUnsigncryptionKeyOwned::new( - dec_key, - enc_key, - server_verf_key, - client_verf_key.verf_key_id(), - ); - - if original_versionized != new_versionized { - Err(test.failure( - format!( - "Invalid UnifiedUnsigncryptionKeyOwned:\n Expected :\n{original_versionized:?}\nGot:\n{new_versionized:?}" - ), - format, - )) - } else { - Ok(test.success(format)) - } -} - fn test_mlkem1024_p384_public_key( dir: &Path, test: &MlKem1024P384PublicKeyTest, @@ -1828,12 +1762,6 @@ impl TestedModule for KMS { Self::Metadata::SigncryptionPayload(test) => { test_signcryption_payload(test_dir.as_ref(), test, format).into() } - Self::Metadata::UnifiedSigncryptionKeyOwned(test) => { - test_signcryption_keys(test_dir.as_ref(), test, format).into() - } - Self::Metadata::UnifiedUnsigncryptionKeyOwned(test) => { - test_unsigncryption_keys(test_dir.as_ref(), test, format).into() - } Self::Metadata::MlKem1024P384PublicKey(test) => { test_mlkem1024_p384_public_key(test_dir.as_ref(), test, format).into() } From 100be1a9f13ca476774208983fc5ed10e2f84c27 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 14:42:56 +0200 Subject: [PATCH 061/114] chore: missing update --- core/service/tests/versioned_enum_coverage.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index 43d8ee2e33..610a7cffc1 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -44,11 +44,12 @@ fn dispatch_underlying_type_name(enum_name: &str) -> String { /// TODO(zama-ai/kms-internal#3028): this explicit list should go away after /// we have a proper way to identify which structs need to be tested. const ALLOW_UNCOVERED: &[&str] = &[ - // Field of UnifiedSigncryptionKeyOwned. - // Covered via UnifiedSigncryptionKeyTest. + // Field of InternalCustodianContext, reached through a + // RecoveryValidationMaterial payload. + // Covered via RecoveryValidationMaterialTest. "UnifiedPublicEncKey", - // Field of UnifiedUnsigncryptionKeyOwned. - // Covered via UnifiedUnsigncryptionKeyTest. + // No fixture carries this wrapper itself; only the keys it wraps. + // Covered via MlKem1024P384PrivateKeyTest. "UnifiedPrivateEncKey", // Field of UnifiedCipher. // Covered via UnifiedCipherTest. From 41fbd67568cbf3a15ae010e2cefaaa7d98db126f Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 14:49:43 +0200 Subject: [PATCH 062/114] chore: streamlining of unified singccryption keys --- core/service/src/backup/custodian.rs | 11 +- core/service/src/backup/operator.rs | 15 +- .../cryptography/signcryption/composite_v1.rs | 107 +++++---- .../src/cryptography/signcryption/ecdsa_v0.rs | 17 +- .../src/cryptography/signcryption/mod.rs | 214 ++++++------------ .../src/engine/centralized/central_kms.rs | 6 +- .../threshold/service/user_decryptor.rs | 12 +- .../tests/backward_compatibility_kms.rs | 6 +- 8 files changed, 170 insertions(+), 218 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index de57748dda..7df45518ff 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -463,14 +463,13 @@ impl Custodian { // re-encrypted share and sign it let operator_verf_id = operator_verification_key.verf_key_id(); - let signcrypt_key = UnifiedSigncryptionKey::new( - &self.signing_key, - operator_ephem_enc_key, - &operator_verf_id, + let signcrypt_key = UnifiedSigncryptionKey::from_signing_key( + self.signing_key.clone(), + operator_ephem_enc_key.clone(), + operator_verf_id, ); - // No scheme set: this is the frozen, ECDSA-only layout. let signcryption = - signcrypt_key.signcrypt(rng, &DSEP_BACKUP_MATERIAL, &[], &*backup_material)?; + signcrypt_key.signcrypt(rng, &DSEP_BACKUP_MATERIAL, &*backup_material)?; tracing::debug!( "Signed re-encrypted share for operator: {}", operator_verification_key.address() diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index 0b0e84450d..6daf426165 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -18,7 +18,7 @@ use crate::{ }; use crate::{ backup::custodian::DSEP_BACKUP_CUSTODIAN, - cryptography::signatures::{internal_sign, internal_verify_sig}, + cryptography::signatures::{NodeSigningIdentity, internal_sign, internal_verify_sig}, }; use crate::{ backup::{ @@ -42,6 +42,7 @@ use std::{ collections::{BTreeMap, HashMap}, fmt::Display, ops::{Add, Sub}, + sync::Arc, time::{Duration, SystemTime}, }; use tfhe::{named::Named, safe_serialization::safe_deserialize}; @@ -499,6 +500,9 @@ impl Operator { } Some(sk) => sk, }; + // Built once and shared by every per-custodian sealer below, so the + // signing key is not copied per custodian. + let identity = Arc::new(NodeSigningIdentity::from(sk.clone())); let n = self.custodian_keys.len(); let t = self.threshold; @@ -573,10 +577,13 @@ impl Operator { shares, }; let custodian_verf_id = custodian_verf_key.verf_key_id(); - let signcryption_key = UnifiedSigncryptionKey::new(sk, cus_enc_key, &custodian_verf_id); - // No scheme set: this is the frozen, ECDSA-only layout. + let signcryption_key = UnifiedSigncryptionKey::new( + identity.clone(), + cus_enc_key.clone(), + custodian_verf_id, + ); let signcryption = signcryption_key - .signcrypt(rng, &DSEP_BACKUP_CUSTODIAN, &[], &backup_material) + .signcrypt(rng, &DSEP_BACKUP_CUSTODIAN, &backup_material) .map_err(BackupError::InternalCryptographyError)?; // Commitment by the operator, which is a hash of [BackupMaterial]. // diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index f5a0d73dbb..a23231209b 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -12,7 +12,7 @@ use crate::cryptography::signatures::StoredTypedSignature; #[cfg(feature = "non-wasm")] use crate::cryptography::signatures::SigningSchemeType; #[cfg(feature = "non-wasm")] -use crate::cryptography::signcryption::{SignerAuth, UnifiedSigncryptionKey}; +use crate::cryptography::signcryption::UnifiedSigncryptionKey; use crate::cryptography::signcryption::UnifiedUnsigncryptionKey; #[cfg(feature = "non-wasm")] use crate::cryptography::signing::composite::sign_uniform; @@ -109,19 +109,10 @@ pub(super) fn seal( schemes: &[SigningSchemeType], msg: &[u8], ) -> Result { - let identity = match signcrypt_key.signer { - SignerAuth::Multi(identity) => identity, - SignerAuth::Ecdsa(_) => { - return Err(CryptographyError::SigningError( - "signcryption key for the legacy scheme supplied for composite signcryption" - .to_string(), - )); - } - }; - let receiver_enc_key = signcrypt_key.receiver_enc_key; + let receiver_enc_key = &signcrypt_key.receiver_enc_key; let mut signed = - SigncryptionSignedPayload::new(msg, signcrypt_key.receiver_id, receiver_enc_key)?; - let signature = sign_uniform(identity, schemes, dsep, &signed); + SigncryptionSignedPayload::new(msg, &signcrypt_key.receiver_id, receiver_enc_key)?; + let signature = sign_uniform(&signcrypt_key.identity, schemes, dsep, &signed); signed.zeroize(); let signature = signature?; @@ -199,6 +190,7 @@ mod tests { use crate::vault::storage::tests::TestType; use aes_prng::AesRng; use rand::SeedableRng; + use std::sync::Arc; const DSEP: &DomainSep = b"COMPV1TT"; @@ -207,9 +199,14 @@ mod tests { } /// The material one composite round trip needs. + /// + /// The sealer is held here rather than rebuilt per test: now that it owns + /// its fields it has no lifetime, so it can sit in the fixture beside the + /// `rng` it is used with. struct CompositeFixture { rng: AesRng, - identity: NodeSigningIdentity, + sealer: UnifiedSigncryptionKey, + identity: Arc, keys: VerfKeySet, schemes: Vec, dec_key: UnifiedPrivateEncKey, @@ -223,10 +220,16 @@ mod tests { schemes: Vec, ) -> CompositeFixture { let mut base = signcryption_fixture(scheme, seed); - let identity = seeded_identity(&mut base.rng); + let identity = Arc::new(seeded_identity(&mut base.rng)); let keys = VerfKeySet::from_identity(&identity, &schemes).unwrap(); + let sealer = UnifiedSigncryptionKey::new( + identity.clone(), + base.enc_key.clone(), + base.receiver_id.clone(), + ); CompositeFixture { rng: base.rng, + sealer, identity, keys, schemes, @@ -241,9 +244,8 @@ mod tests { fn round_trip() { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { let mut f = fixture(scheme, 100, pair()); - let sealer = UnifiedSigncryptionKey::new_multi(&f.identity, &f.enc_key, &f.receiver_id); let cipher = seal( - &sealer, + &f.sealer, &mut f.rng, DSEP, &f.schemes, @@ -268,8 +270,7 @@ mod tests { // The sender signs under a weaker pair; using MlDsa44 instead of MlDsa87. let weaker = vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa44]; - let sealer = UnifiedSigncryptionKey::new_multi(&f.identity, &f.enc_key, &f.receiver_id); - let cipher = seal(&sealer, &mut f.rng, DSEP, &weaker, b"downgrade me").unwrap(); + let cipher = seal(&f.sealer, &mut f.rng, DSEP, &weaker, b"downgrade me").unwrap(); let err = UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id) @@ -306,9 +307,8 @@ mod tests { (400_u64, vec![SigningSchemeType::Ecdsa256k1]), ] { let mut f = fixture(PkeSchemeType::MlKem512, seed, schemes); - let sealer = UnifiedSigncryptionKey::new_multi(&f.identity, &f.enc_key, &f.receiver_id); - let composite = seal(&sealer, &mut f.rng, DSEP, &f.schemes, b"composite payload") - .unwrap(); + let composite = + seal(&f.sealer, &mut f.rng, DSEP, &f.schemes, b"composite payload").unwrap(); // The envelope does open for the reader it was made for, so each // rejection below is the format mismatch and not a broken fixture. @@ -339,10 +339,13 @@ mod tests { // `safe_deserialize` refuses it — again before any decryption. let base = signcryption_fixture(PkeSchemeType::MlKem512, seed); let mut rng = base.rng; - let frozen = - UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id) - .signcrypt(&mut rng, DSEP, &[], &TestType { i: 7 }) - .unwrap(); + let frozen = UnifiedSigncryptionKey::from_signing_key( + base.signing_key.clone(), + base.enc_key.clone(), + base.receiver_id.clone(), + ) + .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) + .unwrap(); let err = reader.open(DSEP, &frozen).unwrap_err(); assert!( matches!(err, CryptographyError::SerializationError(_)), @@ -355,9 +358,8 @@ mod tests { #[test] fn open_rejects_any_deviation_from_what_was_sealed() { let mut f = fixture(PkeSchemeType::MlKem512, 500, pair()); - let sealer = UnifiedSigncryptionKey::new_multi(&f.identity, &f.enc_key, &f.receiver_id); let cipher = seal( - &sealer, + &f.sealer, &mut f.rng, DSEP, &f.schemes, @@ -406,32 +408,47 @@ mod tests { ); } - /// `SignerAuth` names the layout, so the composite sealer refuses a frozen - /// signer rather than falling back to signing with its ECDSA key alone. + /// A node with no root seed has no post-quantum key to sign with, so the + /// composite layout is refused for the schemes that need one — rather than + /// quietly dropping them and sealing under what is left. #[test] - fn a_frozen_signer_cannot_seal_composite() { + fn a_seedless_identity_cannot_seal_a_post_quantum_composite() { let mut base = signcryption_fixture(PkeSchemeType::MlKem512, 600); - let key = UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id); + let key = UnifiedSigncryptionKey::from_signing_key( + base.signing_key.clone(), + base.enc_key.clone(), + base.receiver_id.clone(), + ); - let err = seal(&key, &mut base.rng, DSEP, &pair(), b"no identity here").unwrap_err(); + let err = seal(&key, &mut base.rng, DSEP, &pair(), b"no seed here").unwrap_err(); assert!(matches!(err, CryptographyError::SigningError(_)), "{err}"); } - /// The frozen layout is ECDSA by definition and has no scheme set, so asking - /// it for one is an error rather than an ECDSA-only envelope the caller - /// believes is composite. + /// ...but ECDSA alone needs no seed, so a seedless node can still write a + /// one-signature composite envelope. That envelope is a composite, not the + /// frozen layout: it is only readable by a reader demanding exactly + /// `{Ecdsa256k1}`, which is what keeps the two apart. #[test] - fn the_frozen_layout_refuses_a_scheme_set() { + fn a_seedless_identity_can_seal_an_ecdsa_only_composite() { let mut base = signcryption_fixture(PkeSchemeType::MlKem512, 700); - let key = UnifiedSigncryptionKey::new(&base.signing_key, &base.enc_key, &base.receiver_id); + let key = UnifiedSigncryptionKey::from_signing_key( + base.signing_key.clone(), + base.enc_key.clone(), + base.receiver_id.clone(), + ); + let ecdsa_only = [SigningSchemeType::Ecdsa256k1]; - let err = key - .signcrypt(&mut base.rng, DSEP, &pair(), &TestType { i: 1 }) - .unwrap_err(); - assert!(matches!(err, CryptographyError::SigningError(_)), "{err}"); + let cipher = seal(&key, &mut base.rng, DSEP, &ecdsa_only, b"one signature").unwrap(); - // ...and with no set requested it seals as it always did. - key.signcrypt(&mut base.rng, DSEP, &[], &TestType { i: 1 }) - .unwrap(); + let keys = VerfKeySet::from_identity(&key.identity, &ecdsa_only).unwrap(); + let opened = UnifiedUnsigncryptionKey::new_multi( + &base.dec_key, + &base.enc_key, + &keys, + &base.receiver_id, + ) + .open(DSEP, &cipher) + .unwrap(); + assert_eq!(&*opened, b"one signature"); } } diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 1eb3677e35..4ac1ffab9b 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -45,8 +45,8 @@ pub(super) fn seal( // Sign msg || H(client_verf_key) || H(client_pub_key) // Note that H(client_verf_key) = client_address // Only serialize the inner structure to ensure backwards compatibility!!! - let binding = receiver_binding(signcrypt_key.receiver_id, signcrypt_key.receiver_enc_key)?; - let signing_key = signcrypt_key.signer.ecdsa(); + let binding = receiver_binding(&signcrypt_key.receiver_id, &signcrypt_key.receiver_enc_key)?; + let signing_key = signcrypt_key.signing_key(); // Wipe the temporary signed message after signing. let to_sign = Zeroizing::new([msg, binding.as_slice()].concat()); let sig = internal_sign(dsep, &to_sign, signing_key) @@ -61,7 +61,7 @@ pub(super) fn seal( let to_encrypt = Zeroizing::new([msg, sig.to_bytes().as_ref(), verf_key_hash.as_ref()].concat()); - let ciphertext = hybrid_encrypt(rng, &to_encrypt, signcrypt_key.receiver_enc_key)?; + let ciphertext = hybrid_encrypt(rng, &to_encrypt, &signcrypt_key.receiver_enc_key)?; // LEGACY: approach to serialization Ok(UnifiedSigncryption::new( bc2wrap::serialize(&ciphertext) @@ -242,15 +242,16 @@ mod tests { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { let mut f = signcryption_fixture(scheme, 200); let payload = TestType { i: 4711 }; - let signcrypt_key = - UnifiedSigncryptionKey::new(&f.signing_key, &f.enc_key, &f.receiver_id); + let signcrypt_key = UnifiedSigncryptionKey::from_signing_key( + f.signing_key.clone(), + f.enc_key.clone(), + f.receiver_id.clone(), + ); let mut expected_msg = Vec::new(); safe_serialize(&payload, &mut expected_msg, SAFE_SER_SIZE_LIMIT).unwrap(); - let cipher = signcrypt_key - .signcrypt(&mut f.rng, DSEP, &[], &payload) - .unwrap(); + let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); assert_eq!(cipher.pke_type, scheme); let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index b5c1227bcb..8418092796 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -36,23 +36,24 @@ use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; use kms_grpc::kms::v1::TypedPlaintext; use rand::{CryptoRng, RngCore}; +use std::sync::Arc; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use tfhe::FheTypes; use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; use tfhe_versionable::{Upgrade, Version, Versionize, VersionsDispatch}; -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use zeroize::{Zeroize, Zeroizing}; pub trait Signcrypt { - /// Signcrypt a message of type T with a specified domain separator. + /// Signcrypt a message of type T with a specified domain separator, in the + /// frozen layout: one ECDSA signature, so there is no scheme set to name. /// - /// `schemes` is the set the signature is made under, and is meaningful only - /// for the new composite signer format. For the legacy ECDSA format use an empty list. + /// For the multi-signature layout see + /// [`UnifiedSigncryptionKey::signcrypt_composite`]. fn signcrypt( &self, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, - schemes: &[SigningSchemeType], msg: &T, ) -> Result; } @@ -100,7 +101,7 @@ pub trait UnsigncryptFHEPlaintext: Unsigncrypt { /// The method is exclusively used to decrypt partially decrypted FHE ciphertexts for user decryption. /// /// The returned payload contains cleartext and implements [`Zeroize`], but not - /// [`ZeroizeOnDrop`], because callers may move out `plaintext`. + /// [`zeroize::ZeroizeOnDrop`], because callers may move out `plaintext`. fn unsigncrypt_plaintext( &self, dsep: &DomainSep, @@ -109,107 +110,51 @@ pub trait UnsigncryptFHEPlaintext: Unsigncrypt { ) -> Result; } -/// An owning sealer, for a caller that cannot hold borrows across a task -/// boundary. -/// -/// Deliberately not serializable: no `PrivDataType` variant is a signcryption -/// key, so nothing persists this. It needs no version dispatch. -#[derive(Clone, Eq, PartialEq, Debug, Zeroize)] -pub struct UnifiedSigncryptionKeyOwned { - pub signing_key: PrivateSigKey, +/// Who is sealing, and to whom. +#[derive(Clone, Debug)] +pub struct UnifiedSigncryptionKey { + pub identity: Arc, pub receiver_enc_key: UnifiedPublicEncKey, pub receiver_id: Vec, // Identifier for the receiver's encryption key, e.g. blockchain address } -impl UnifiedSigncryptionKeyOwned { + +impl UnifiedSigncryptionKey { pub fn new( - signing_key: PrivateSigKey, + identity: Arc, receiver_enc_key: UnifiedPublicEncKey, receiver_id: Vec, ) -> Self { Self { - signing_key, + identity, receiver_enc_key, receiver_id, } } - pub fn reference<'a>(&'a self) -> UnifiedSigncryptionKey<'a> { - UnifiedSigncryptionKey { - signer: SignerAuth::Ecdsa(&self.signing_key), - receiver_enc_key: &self.receiver_enc_key, - receiver_id: &self.receiver_id, - } - } -} - -impl HasPkeScheme for UnifiedSigncryptionKeyOwned { - fn encryption_scheme_type(&self) -> PkeSchemeType { - self.receiver_enc_key.encryption_scheme_type() - } -} - -/// What a sealer signs with — and therefore which envelope layout it writes. -/// -/// The dual of [`SenderAuth`]. As there, the key material and the layout are the -/// same choice, so they are the same value. -#[derive(Clone, Debug)] -pub enum SignerAuth<'a> { - /// A single ECDSA signing key: the frozen layout, and only that. - Ecdsa(&'a PrivateSigKey), - /// A node identity, which signs under whichever schemes a call requests: - /// the multi-signature layout. - Multi(&'a NodeSigningIdentity), -} - -impl<'a> SignerAuth<'a> { - /// The ECDSA key behind this signer. - pub fn ecdsa(&self) -> &'a PrivateSigKey { - match self { - SignerAuth::Ecdsa(signing_key) => signing_key, - SignerAuth::Multi(identity) => identity.ecdsa(), - } - } -} - -/// Internal type for signcryption keys, storing only references to the real internal keys. -/// Thus this type should not be serialized instead `UnifiedSigncryptionKeyOwned` should be used. -#[derive(Clone, Debug)] -pub struct UnifiedSigncryptionKey<'a> { - pub signer: SignerAuth<'a>, - pub receiver_enc_key: &'a UnifiedPublicEncKey, - pub receiver_id: &'a [u8], // Identifier for the receiver's encryption key, e.g. blockchain address -} - -impl<'a> UnifiedSigncryptionKey<'a> { - /// A sealer of the frozen, single-ECDSA layout. - pub fn new( - signing_key: &'a PrivateSigKey, - receiver_enc_key: &'a UnifiedPublicEncKey, - receiver_id: &'a [u8], + /// A sealer for a caller that holds only an ECDSA key. + /// + /// The key becomes a seedless identity: it writes the frozen envelope, and a + /// composite one under `Ecdsa256k1` alone. Asking it for any other scheme + /// fails with `MissingRootSeed`. + pub fn from_signing_key( + signing_key: PrivateSigKey, + receiver_enc_key: UnifiedPublicEncKey, + receiver_id: Vec, ) -> Self { - Self { - signer: SignerAuth::Ecdsa(signing_key), + Self::new( + Arc::new(NodeSigningIdentity::from(signing_key)), receiver_enc_key, receiver_id, - } + ) } - /// A sealer of the multi-signature layout, signing under whichever schemes - /// the sealing call names. - pub fn new_multi( - identity: &'a NodeSigningIdentity, - receiver_enc_key: &'a UnifiedPublicEncKey, - receiver_id: &'a [u8], - ) -> Self { - Self { - signer: SignerAuth::Multi(identity), - receiver_enc_key, - receiver_id, - } + /// The ECDSA key the frozen layout signs with. + pub fn signing_key(&self) -> &PrivateSigKey { + self.identity.ecdsa() } } -impl HasPkeScheme for UnifiedSigncryptionKey<'_> { +impl HasPkeScheme for UnifiedSigncryptionKey { fn encryption_scheme_type(&self) -> PkeSchemeType { self.receiver_enc_key.encryption_scheme_type() } @@ -294,8 +239,8 @@ impl HasPkeScheme for UnifiedUnsigncryptionKey<'_> { } } -/// An owning reader, the counterpart of [`UnifiedSigncryptionKeyOwned`], and -/// not serializable for the same reason. +/// An owning reader, the counterpart of [`UnifiedSigncryptionKey`], and not +/// serializable for the same reason. #[derive(Clone, Debug, Eq, PartialEq)] pub struct UnifiedUnsigncryptionKeyOwned { pub decryption_key: UnifiedPrivateEncKey, @@ -471,49 +416,26 @@ impl Zeroize for SigncryptionPayload { /// WARNING: It is assumed that the client's public key HAS been validated to come from a valid /// `ClientRequest` and validated to be consistent with the blockchain identity of the client BEFORE /// calling this method. IF THIS HAS NOT BEEN DONE THEN ANYONE CAN IMPERSONATE ANY CLIENT!!! -impl<'a> Signcrypt for UnifiedSigncryptionKey<'a> { +impl Signcrypt for UnifiedSigncryptionKey { fn signcrypt( &self, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, - schemes: &[SigningSchemeType], msg: &T, ) -> Result where T: Serialize + tfhe::Versionize + tfhe::named::Named, { - let mut serialized_msg = ZeroizingWriter::new(); - safe_serialize(msg, &mut serialized_msg, SAFE_SER_SIZE_LIMIT).map_err(|e| { - CryptographyError::SerializationError(format!( - "Could not serialize message for signcryption: {e}", - )) - })?; - match self.signer { - // The frozen layout is ECDSA by definition. - SignerAuth::Ecdsa(_) => { - if !schemes.is_empty() { - return Err(CryptographyError::SigningError(format!( - "the frozen layout signs under ECDSA alone, but {} scheme(s) were requested; \ - use a `SignerAuth::Multi` signer for a composite envelope", - schemes.len() - ))); - } - ecdsa_v0::seal(self, rng, dsep, serialized_msg.as_slice()) - } - #[cfg(feature = "non-wasm")] - SignerAuth::Multi(_) => { - composite_v1::seal(self, rng, dsep, schemes, serialized_msg.as_slice()) - } - #[cfg(not(feature = "non-wasm"))] - SignerAuth::Multi(_) => Err(CryptographyError::SigningError( - "composite signcryption is not available in this build".to_string(), - )), - } + let serialized_msg = serialize_for_signcryption(msg)?; + ecdsa_v0::seal(self, rng, dsep, serialized_msg.as_slice()) } } -impl Signcrypt for UnifiedSigncryptionKeyOwned { - fn signcrypt( +impl UnifiedSigncryptionKey { + /// Signcrypt `msg` in the multi-signature layout, signed under exactly + /// `schemes`. + #[cfg(feature = "non-wasm")] + pub fn signcrypt_composite( &self, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, @@ -523,12 +445,26 @@ impl Signcrypt for UnifiedSigncryptionKeyOwned { where T: Serialize + tfhe::Versionize + tfhe::named::Named, { - let ref_type = self.reference(); - ref_type.signcrypt(rng, dsep, schemes, msg) + let serialized_msg = serialize_for_signcryption(msg)?; + composite_v1::seal(self, rng, dsep, schemes, serialized_msg.as_slice()) } } -impl<'a> SigncryptFHEPlaintext for UnifiedSigncryptionKey<'a> { +/// The message bytes both layouts sign and encrypt, wiped after use. +fn serialize_for_signcryption(msg: &T) -> Result +where + T: Serialize + tfhe::Versionize + tfhe::named::Named, +{ + let mut serialized_msg = ZeroizingWriter::new(); + safe_serialize(msg, &mut serialized_msg, SAFE_SER_SIZE_LIMIT).map_err(|e| { + CryptographyError::SerializationError(format!( + "Could not serialize message for signcryption: {e}", + )) + })?; + Ok(serialized_msg) +} + +impl SigncryptFHEPlaintext for UnifiedSigncryptionKey { fn signcrypt_plaintext( &self, rng: &mut (impl CryptoRng + RngCore), @@ -550,25 +486,13 @@ impl<'a> SigncryptFHEPlaintext for UnifiedSigncryptionKey<'a> { // The wire type this produces, // `TypedSigncryptedCiphertext.signcrypted_ciphertext`, is a bare `bytes` // field, and the deployed browser-side verifier parses exactly one - // layout. The frozen one is not a default here, it is the only option. + // layout. The frozen one is not a default here, it is the only option, + // which is why this calls the frozen sealer rather than offering a + // choice. It takes no scheme set for the same reason. ecdsa_v0::seal(self, rng, dsep, serialized_msg.as_slice()) } } -impl SigncryptFHEPlaintext for UnifiedSigncryptionKeyOwned { - fn signcrypt_plaintext( - &self, - rng: &mut (impl CryptoRng + RngCore), - dsep: &DomainSep, - plaintext: &[u8], - fhe_type: FheTypes, - link: &[u8], - ) -> Result { - let ref_type = self.reference(); - ref_type.signcrypt_plaintext(rng, dsep, plaintext, fhe_type, link) - } -} - impl<'a> Unsigncrypt for UnifiedUnsigncryptionKey<'a> { fn unsigncrypt( &self, @@ -676,7 +600,7 @@ pub fn ephemeral_signcryption_key_generation( let mut encryption = Encryption::new(PkeSchemeType::MlKem512, rng); let (dec_key, enc_key) = encryption.keygen().unwrap(); UnifiedSigncryptionKeyPairOwned { - signcrypt_key: UnifiedSigncryptionKeyOwned::new( + signcrypt_key: UnifiedSigncryptionKey::from_signing_key( server_sig_key.clone(), enc_key.clone(), client_verf_key_id.to_vec(), @@ -693,9 +617,9 @@ pub fn ephemeral_signcryption_key_generation( /// Helper struct that contains both signcryption and unsigncryption keys for a client /// For now only used for testing #[cfg(test)] -#[derive(Clone, Debug, Zeroize, ZeroizeOnDrop)] +#[derive(Clone, Debug)] pub struct UnifiedSigncryptionKeyPairOwned { - pub signcrypt_key: UnifiedSigncryptionKeyOwned, + pub signcrypt_key: UnifiedSigncryptionKey, pub unsigncryption_key: UnifiedUnsigncryptionKeyOwned, } @@ -730,7 +654,7 @@ mod tests { let (dec_key, enc_key) = encryption.keygen().unwrap(); let receiver_id = client_verf_key.verf_key_id(); let keys = UnifiedSigncryptionKeyPairOwned { - signcrypt_key: UnifiedSigncryptionKeyOwned::new( + signcrypt_key: UnifiedSigncryptionKey::from_signing_key( server_sig_key, enc_key.clone(), receiver_id.clone(), @@ -753,7 +677,7 @@ mod tests { let msg = TestType { i: 1333 }; let cipher = keys .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &[], &msg) + .signcrypt(&mut rng, b"TESTTEST", &msg) .unwrap(); assert_eq!(cipher.pke_type, scheme); @@ -772,7 +696,7 @@ mod tests { let msg = TestType { i: 1333 }; let cipher = p384_keys .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &[], &msg) + .signcrypt(&mut rng, b"TESTTEST", &msg) .unwrap(); let err = ml_kem_512_keys @@ -789,7 +713,7 @@ mod tests { let msg = TestType { i: 1333 }; let cipher = client_signcryption_keys .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &[], &msg) + .signcrypt(&mut rng, b"TESTTEST", &msg) .unwrap(); let serialized_cipher = bc2wrap::serialize(&cipher).unwrap(); let deserialized_cipher: UnifiedSigncryption = @@ -822,7 +746,7 @@ mod tests { let msg = TestType { i: 1333 }; let correct_cipher = client_signcryption_keys .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &[], &msg) + .signcrypt(&mut rng, b"TESTTEST", &msg) .unwrap(); // flip a bit in the payload diff --git a/core/service/src/engine/centralized/central_kms.rs b/core/service/src/engine/centralized/central_kms.rs index 4cb80a1a4d..60476bdd88 100644 --- a/core/service/src/engine/centralized/central_kms.rs +++ b/core/service/src/engine/centralized/central_kms.rs @@ -887,7 +887,11 @@ impl< client_enc_key: &UnifiedPublicEncKey, client_id: &[u8], ) -> anyhow::Result> { - let signcryption_key = UnifiedSigncryptionKey::new(sig_key, client_enc_key, client_id); + let signcryption_key = UnifiedSigncryptionKey::from_signing_key( + sig_key.clone(), + client_enc_key.clone(), + client_id.to_vec(), + ); // Observe that we encrypt the plaintext itself, this is different from the threshold case // where it is first mapped to a Vec element // Keep the cleartext behind a zeroizing guard during signcryption. diff --git a/core/service/src/engine/threshold/service/user_decryptor.rs b/core/service/src/engine/threshold/service/user_decryptor.rs index 48b052d6d7..0e01229ad4 100644 --- a/core/service/src/engine/threshold/service/user_decryptor.rs +++ b/core/service/src/engine/threshold/service/user_decryptor.rs @@ -55,7 +55,7 @@ use crate::{ encryption::UnifiedPublicEncKey, error::CryptographyError, internal_crypto_types::LegacySerialization, - signcryption::{SigncryptFHEPlaintext, UnifiedSigncryptionKeyOwned}, + signcryption::{SigncryptFHEPlaintext, UnifiedSigncryptionKey}, signing::SigningSchemeType, signing::identity::NodeSigningIdentity, zeroizing_writer::ZeroizingWriter, @@ -182,7 +182,7 @@ impl< rng: impl CryptoRng + RngCore + Send + 'static, typed_ciphertexts: Vec, link: Vec, - signcryption_key: Arc, + signcryption_key: UnifiedSigncryptionKey, identity: Arc, client_enc_key_bytes_orig: Vec, fhe_keys: OwnedRwLockReadGuard< @@ -405,7 +405,7 @@ impl< signcrypted_ciphertexts: all_signcrypted_cts, digest: link, verification_key: signcryption_key - .signing_key + .signing_key() .verf_key() .to_legacy_bytes() .map_err(|e| anyhow::anyhow!("Could not serialize verification key {}", e))?, @@ -571,11 +571,11 @@ impl< tonic::Code::Internal, ) })?; - let signcryption_key = Arc::new(UnifiedSigncryptionKeyOwned::new( - identity.ecdsa().clone(), + let signcryption_key = UnifiedSigncryptionKey::new( + identity.clone(), client_enc_key, client_address.to_vec(), - )); + ); // the result of the computation is tracked the tracker let session_maker = self.session_maker.clone(); diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index 1991bd838b..69cfb149c2 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -58,7 +58,7 @@ use kms_lib::{ StoredTypedSignature, UnifiedPublicSigKey, compute_eip712_signature, gen_sig_keys, }, signcryption::{ - Signcrypt, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, + Signcrypt, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKeyOwned, Unsigncrypt, }, }, @@ -753,9 +753,9 @@ fn test_unified_signcryption( let (dec_key, enc_key) = encryption.keygen().unwrap(); let receiver_id = client_verf_key.verf_key_id(); let signcrypt_key = - UnifiedSigncryptionKeyOwned::new(server_sig_key, enc_key.clone(), receiver_id.clone()); + UnifiedSigncryptionKey::from_signing_key(server_sig_key, enc_key.clone(), receiver_id.clone()); let new_versionized = signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &[], &verf_key) + .signcrypt(&mut rng, b"TESTTEST", &verf_key) .unwrap(); if original_versionized != new_versionized { From 1f15ee841ce76178d26c7b0267d5a42da8fc2667 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 14:53:16 +0200 Subject: [PATCH 063/114] refactor: move signcryption --- .../src/cryptography/signcryption/mod.rs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 8418092796..08f86d4c5a 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -36,9 +36,9 @@ use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; use kms_grpc::kms::v1::TypedPlaintext; use rand::{CryptoRng, RngCore}; -use std::sync::Arc; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use tfhe::FheTypes; use tfhe::safe_serialization::{safe_deserialize, safe_serialize}; use tfhe_versionable::{Upgrade, Version, Versionize, VersionsDispatch}; @@ -47,15 +47,23 @@ use zeroize::{Zeroize, Zeroizing}; pub trait Signcrypt { /// Signcrypt a message of type T with a specified domain separator, in the /// frozen layout: one ECDSA signature, so there is no scheme set to name. - /// - /// For the multi-signature layout see - /// [`UnifiedSigncryptionKey::signcrypt_composite`]. fn signcrypt( &self, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, msg: &T, ) -> Result; + + /// Signcrypt `msg` in the multi-signature layout, signed under exactly + /// `schemes`. + #[cfg(feature = "non-wasm")] + fn signcrypt_composite( + &self, + rng: &mut (impl CryptoRng + RngCore), + dsep: &DomainSep, + schemes: &[SigningSchemeType], + msg: &T, + ) -> Result; } pub trait Unsigncrypt { @@ -429,13 +437,9 @@ impl Signcrypt for UnifiedSigncryptionKey { let serialized_msg = serialize_for_signcryption(msg)?; ecdsa_v0::seal(self, rng, dsep, serialized_msg.as_slice()) } -} -impl UnifiedSigncryptionKey { - /// Signcrypt `msg` in the multi-signature layout, signed under exactly - /// `schemes`. #[cfg(feature = "non-wasm")] - pub fn signcrypt_composite( + fn signcrypt_composite( &self, rng: &mut (impl CryptoRng + RngCore), dsep: &DomainSep, From f573edf46b6be8e98bf950eab3943d9d9a405cda Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 15:03:40 +0200 Subject: [PATCH 064/114] fix: lint --- core/service/src/cryptography/signcryption/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 08f86d4c5a..97239ba7ac 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -236,7 +236,7 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { SenderAuth::Ecdsa(sender_verf_key) => { ecdsa_v0::open(self, sender_verf_key, dsep, cipher) } - SenderAuth::Multi(_keys) => composite_v1::open(&self, dsep, cipher), + SenderAuth::Multi(_keys) => composite_v1::open(self, dsep, cipher), } } } From 635c1b8dfe6400f0066c642f6a2a0377dc0b5f82 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 15:10:53 +0200 Subject: [PATCH 065/114] test: refactor --- .../src/client/user_decryption_wasm.rs | 20 +- .../src/cryptography/signcryption/common.rs | 122 ++++++++- .../cryptography/signcryption/composite_v1.rs | 245 ++++++++---------- .../src/cryptography/signcryption/ecdsa_v0.rs | 52 ++-- .../src/cryptography/signcryption/mod.rs | 133 ++-------- .../tests/backward_compatibility_kms.rs | 9 +- 6 files changed, 290 insertions(+), 291 deletions(-) diff --git a/core/service/src/client/user_decryption_wasm.rs b/core/service/src/client/user_decryption_wasm.rs index 0b003e0dea..678efd5730 100644 --- a/core/service/src/client/user_decryption_wasm.rs +++ b/core/service/src/client/user_decryption_wasm.rs @@ -31,6 +31,7 @@ use kms_grpc::kms::v1::{TypedPlaintext, UserDecryptionRequest, UserDecryptionRes use kms_grpc::rpc_types::fhe_types_to_num_blocks; use kms_grpc::solidity_types::UserDecryptionLinker; use std::num::Wrapping; +use std::sync::Arc; use tfhe::FheTypes; use tfhe::shortint::ClassicPBSParameters; use threshold_execution::endpoints::decryption::DecryptionMode; @@ -294,8 +295,12 @@ impl Client { .inspect_err(|e| tracing::warn!("signature on received response is not valid ({})", e))?; let receiver_id = self.client_address.to_vec(); - let unsign_key = - UnifiedUnsigncryptionKey::new(dec_key, enc_key, &cur_verf_key, &receiver_id); + let unsign_key = UnifiedUnsigncryptionKey::new( + Arc::new(dec_key.clone()), + enc_key.clone(), + cur_verf_key.clone(), + receiver_id, + ); payload .signcrypted_ciphertexts @@ -687,6 +692,9 @@ impl Client { let num_parties = trusted_ctx.num_parties(); let client_id = self.client_address.to_vec(); + // Shared across the loop below so the private key is not copied per + // response. + let dec_key = Arc::new(dec_key.clone()); let mut accepted = Vec::with_capacity(authenticated.len()); @@ -696,10 +704,10 @@ impl Client { let signcrypted_ciphertexts = &authenticated_resp.signcrypted_ciphertexts; let role = authenticated_resp.role; let unsign_key = UnifiedUnsigncryptionKey::new( - dec_key, - enc_key, - &authenticated_resp.verification_key, - &client_id, + dec_key.clone(), + enc_key.clone(), + authenticated_resp.verification_key.clone(), + client_id.clone(), ); let mut shares_per_slot = Vec::with_capacity(signcrypted_ciphertexts.len()); let mut recovered_ok = true; diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index 570ea0077d..d77501abf6 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -64,30 +64,128 @@ pub(super) fn hybrid_decrypt( } } -// Test scaffolding shared with `super::ecdsa_v0`, which locks the frozen layout -// against the same keys. At module level rather than inside `tests` so that -// `ecdsa_v0`'s tests can reach it without a copy. +// Test scaffolding shared by both envelope formats. At module level rather than +// inside `tests` so that `ecdsa_v0` and `composite_v1` can reach it without a +// copy. +#[cfg(test)] +use super::{UnifiedSigncryptionKey, UnifiedUnsigncryptionKey}; #[cfg(test)] use crate::cryptography::encryption::{Encryption, PkeScheme, PkeSchemeType}; #[cfg(test)] -use crate::cryptography::signatures::{PrivateSigKey, PublicSigKey, gen_sig_keys}; +use crate::cryptography::signatures::{ + PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet, gen_sig_keys, +}; #[cfg(test)] use aes_prng::AesRng; #[cfg(test)] use rand::SeedableRng; +#[cfg(test)] +use std::sync::Arc; +/// One sender, one receiver, and the rng that produced them. #[cfg(test)] pub(super) struct SigncryptionFixture { pub(super) rng: AesRng, - pub(super) dec_key: UnifiedPrivateEncKey, - pub(super) enc_key: UnifiedPublicEncKey, - pub(super) sender_verf_key: PublicSigKey, - pub(super) signing_key: PrivateSigKey, - pub(super) receiver_id: Vec, + pub(super) signcryption_key: UnifiedSigncryptionKey, + pub(super) unsigncryption_key: UnifiedUnsigncryptionKey, +} + +#[cfg(test)] +impl SigncryptionFixture { + /// The sender's ECDSA verification key, as the frozen reader names it. + pub(super) fn sender_verf_key(&self) -> PublicSigKey { + PublicSigKey::from_sk(self.signcryption_key.signing_key()) + } + + /// The schemes the reader demands, empty for a frozen reader. + pub(super) fn schemes(&self) -> Vec { + match &self.unsigncryption_key.sender { + super::SenderAuth::Multi(keys) => keys.schemes(), + super::SenderAuth::Ecdsa(_) => Vec::new(), + } + } + + /// A reader that deviates from this fixture in its sender policy or its + /// receiver id, so a test can vary one of them at a time. + pub(super) fn reader_with( + &self, + sender: super::SenderAuth, + receiver_id: Vec, + ) -> UnifiedUnsigncryptionKey { + UnifiedUnsigncryptionKey { + decryption_key: self.unsigncryption_key.decryption_key.clone(), + encryption_key: self.unsigncryption_key.encryption_key.clone(), + sender, + receiver_id, + } + } } +/// A fixture whose reader demands the frozen, single-ECDSA layout. #[cfg(test)] pub(super) fn signcryption_fixture(scheme: PkeSchemeType, seed: u64) -> SigncryptionFixture { + let p = fixture_parts(scheme, seed); + SigncryptionFixture { + signcryption_key: UnifiedSigncryptionKey::from_signing_key( + p.signing_key, + p.enc_key.clone(), + p.receiver_id.clone(), + ), + unsigncryption_key: UnifiedUnsigncryptionKey::new( + Arc::new(p.dec_key), + p.enc_key, + p.sender_verf_key, + p.receiver_id, + ), + rng: p.rng, + } +} + +/// A fixture whose reader demands a signature under every scheme in `schemes`. +#[cfg(test)] +pub(super) fn composite_fixture( + scheme: PkeSchemeType, + seed: u64, + schemes: &[SigningSchemeType], +) -> SigncryptionFixture { + use crate::cryptography::signing::test_support::seeded_identity; + + // The identity is drawn after the parts, as it was when the composite tests + // built on top of `signcryption_fixture`, so a given seed still yields the + // same keys. + let mut p = fixture_parts(scheme, seed); + let identity = Arc::new(seeded_identity(&mut p.rng)); + let keys = VerfKeySet::from_identity(&identity, schemes).unwrap(); + SigncryptionFixture { + signcryption_key: UnifiedSigncryptionKey::new( + identity, + p.enc_key.clone(), + p.receiver_id.clone(), + ), + unsigncryption_key: UnifiedUnsigncryptionKey::new_multi( + Arc::new(p.dec_key), + p.enc_key, + keys, + p.receiver_id, + ), + rng: p.rng, + } +} + +/// What both constructors draw from `seed`, in the order the fixtures have +/// always drawn it. +#[cfg(test)] +struct FixtureParts { + rng: AesRng, + sender_verf_key: PublicSigKey, + signing_key: PrivateSigKey, + dec_key: UnifiedPrivateEncKey, + enc_key: UnifiedPublicEncKey, + receiver_id: Vec, +} + +#[cfg(test)] +fn fixture_parts(scheme: PkeSchemeType, seed: u64) -> FixtureParts { let mut rng = AesRng::seed_from_u64(seed); let (sender_verf_key, signing_key) = gen_sig_keys(&mut rng); let (receiver_verf_key, _) = gen_sig_keys(&mut rng); @@ -96,12 +194,12 @@ pub(super) fn signcryption_fixture(scheme: PkeSchemeType, seed: u64) -> Signcryp let mut encryption = Encryption::new(scheme, &mut rng); encryption.keygen().unwrap() }; - SigncryptionFixture { + FixtureParts { rng, - dec_key, - enc_key, sender_verf_key, signing_key, + dec_key, + enc_key, receiver_id: receiver_verf_key.verf_key_id(), } } diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index a23231209b..d88267ef0d 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -151,7 +151,7 @@ pub(super) fn open( "encryption type of cipher does not match the decryption key type".to_string(), )); } - let sender_keys = match unsign_key.sender { + let sender_keys = match &unsign_key.sender { super::SenderAuth::Ecdsa(_public_sig_key) => { return Err(CryptographyError::VerificationError( "Unsigncryption key for legacy scheme supplied for composite signcryption" @@ -163,7 +163,7 @@ pub(super) fn open( let kem_ct: HybridKemCt = safe_deserialize(std::io::Cursor::new(&cipher.payload), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError)?; - let plaintext = hybrid_decrypt(kem_ct, unsign_key.decryption_key)?; + let plaintext = hybrid_decrypt(kem_ct, &unsign_key.decryption_key)?; let mut envelope: CompositeEnvelope = safe_deserialize(std::io::Cursor::new(&*plaintext), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError)?; @@ -171,7 +171,7 @@ pub(super) fn open( let msg = Zeroizing::new(std::mem::take(&mut envelope.msg)); let mut signed = - SigncryptionSignedPayload::new(&msg, unsign_key.receiver_id, unsign_key.encryption_key)?; + SigncryptionSignedPayload::new(&msg, &unsign_key.receiver_id, &unsign_key.encryption_key)?; let verified = verify_uniform(&envelope.signature, sender_keys, dsep, &signed); signed.zeroize(); verified.map_err(|e| CryptographyError::VerificationError(e.to_string()))?; @@ -181,16 +181,15 @@ pub(super) fn open( #[cfg(test)] mod tests { - use super::super::common::signcryption_fixture; - use super::super::{Signcrypt, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, Unsigncrypt}; + use super::super::common::{composite_fixture, signcryption_fixture}; + use super::super::{SenderAuth, Signcrypt, UnifiedSigncryptionKey, Unsigncrypt}; use super::*; - use crate::cryptography::encryption::{PkeSchemeType, UnifiedPrivateEncKey}; - use crate::cryptography::signatures::{NodeSigningIdentity, UnifiedPublicSigKey, VerfKeySet}; + use crate::cryptography::encryption::PkeSchemeType; + use crate::cryptography::signatures::{UnifiedPublicSigKey, VerfKeySet}; use crate::cryptography::signing::test_support::seeded_identity; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; use rand::SeedableRng; - use std::sync::Arc; const DSEP: &DomainSep = b"COMPV1TT"; @@ -198,66 +197,22 @@ mod tests { vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87] } - /// The material one composite round trip needs. - /// - /// The sealer is held here rather than rebuilt per test: now that it owns - /// its fields it has no lifetime, so it can sit in the fixture beside the - /// `rng` it is used with. - struct CompositeFixture { - rng: AesRng, - sealer: UnifiedSigncryptionKey, - identity: Arc, - keys: VerfKeySet, - schemes: Vec, - dec_key: UnifiedPrivateEncKey, - enc_key: UnifiedPublicEncKey, - receiver_id: Vec, - } - - fn fixture( - scheme: PkeSchemeType, - seed: u64, - schemes: Vec, - ) -> CompositeFixture { - let mut base = signcryption_fixture(scheme, seed); - let identity = Arc::new(seeded_identity(&mut base.rng)); - let keys = VerfKeySet::from_identity(&identity, &schemes).unwrap(); - let sealer = UnifiedSigncryptionKey::new( - identity.clone(), - base.enc_key.clone(), - base.receiver_id.clone(), - ); - CompositeFixture { - rng: base.rng, - sealer, - identity, - keys, - schemes, - dec_key: base.dec_key, - enc_key: base.enc_key, - receiver_id: base.receiver_id, - } - } - /// Round-trips for both PKE schemes the backup and user-decryption paths use. #[test] fn round_trip() { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { - let mut f = fixture(scheme, 100, pair()); + let mut f = composite_fixture(scheme, 100, &pair()); let cipher = seal( - &f.sealer, + &f.signcryption_key, &mut f.rng, DSEP, - &f.schemes, + &f.schemes(), b"a composite message", ) .unwrap(); assert_eq!(cipher.pke_type, scheme); - let opened = - UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id) - .open(DSEP, &cipher) - .unwrap(); + let opened = f.unsigncryption_key.open(DSEP, &cipher).unwrap(); assert_eq!(&*opened, b"a composite message", "{scheme}"); } } @@ -266,16 +221,20 @@ mod tests { /// pair rejects a signcryption made under a weaker one. #[test] fn a_signature_under_another_scheme_set_is_rejected() { - let mut f = fixture(PkeSchemeType::MlKem512, 200, pair()); + let mut f = composite_fixture(PkeSchemeType::MlKem512, 200, &pair()); // The sender signs under a weaker pair; using MlDsa44 instead of MlDsa87. let weaker = vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa44]; - let cipher = seal(&f.sealer, &mut f.rng, DSEP, &weaker, b"downgrade me").unwrap(); + let cipher = seal( + &f.signcryption_key, + &mut f.rng, + DSEP, + &weaker, + b"downgrade me", + ) + .unwrap(); - let err = - UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id) - .open(DSEP, &cipher) - .unwrap_err(); + let err = f.unsigncryption_key.open(DSEP, &cipher).unwrap_err(); assert!( matches!(err, CryptographyError::VerificationError(_)), "{err}" @@ -284,15 +243,15 @@ mod tests { // ...and the same signcryption opens for a verifier that asked for // exactly what was signed, confirming the rejection above is the policy // check and not an unrelated failure. - let weaker_keys = VerfKeySet::from_identity(&f.identity, &weaker).unwrap(); - let opened = UnifiedUnsigncryptionKey::new_multi( - &f.dec_key, - &f.enc_key, - &weaker_keys, - &f.receiver_id, - ) - .open(DSEP, &cipher) - .unwrap(); + let weaker_keys = + VerfKeySet::from_identity(&f.signcryption_key.identity, &weaker).unwrap(); + let opened = f + .reader_with( + SenderAuth::Multi(weaker_keys), + f.signcryption_key.receiver_id.clone(), + ) + .open(DSEP, &cipher) + .unwrap(); assert_eq!(&*opened, b"downgrade me"); } @@ -306,29 +265,40 @@ mod tests { (300_u64, pair()), (400_u64, vec![SigningSchemeType::Ecdsa256k1]), ] { - let mut f = fixture(PkeSchemeType::MlKem512, seed, schemes); - let composite = - seal(&f.sealer, &mut f.rng, DSEP, &f.schemes, b"composite payload").unwrap(); + let mut f = composite_fixture(PkeSchemeType::MlKem512, seed, &schemes); + let composite = seal( + &f.signcryption_key, + &mut f.rng, + DSEP, + &f.schemes(), + b"composite payload", + ) + .unwrap(); // The envelope does open for the reader it was made for, so each // rejection below is the format mismatch and not a broken fixture. - let reader = - UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id); - assert_eq!(&*reader.open(DSEP, &composite).unwrap(), b"composite payload"); + assert_eq!( + &*f.unsigncryption_key.open(DSEP, &composite).unwrap(), + b"composite payload" + ); // Frozen reader, handed a composite envelope. - let legacy_ecdsa = match f.keys.require(SigningSchemeType::Ecdsa256k1).unwrap() { - UnifiedPublicSigKey::Ecdsa256k1(key) => key.clone(), - _ => unreachable!("the ECDSA member of the set is an ECDSA key"), + let legacy_ecdsa = match &f.unsigncryption_key.sender { + SenderAuth::Multi(keys) => { + match keys.require(SigningSchemeType::Ecdsa256k1).unwrap() { + UnifiedPublicSigKey::Ecdsa256k1(key) => key.clone(), + _ => unreachable!("the ECDSA member of the set is an ECDSA key"), + } + } + SenderAuth::Ecdsa(_) => unreachable!("the fixture reader is a composite one"), }; - let err = UnifiedUnsigncryptionKey::new( - &f.dec_key, - &f.enc_key, - &legacy_ecdsa, - &f.receiver_id, - ) - .unsigncrypt::(DSEP, &composite) - .unwrap_err(); + let err = f + .reader_with( + SenderAuth::Ecdsa(legacy_ecdsa), + f.signcryption_key.receiver_id.clone(), + ) + .unsigncrypt::(DSEP, &composite) + .unwrap_err(); assert!( matches!(err, CryptographyError::BincodeError(_)), "the frozen reader must reject a composite envelope on the KEM ciphertext, got: {err}" @@ -337,16 +307,12 @@ mod tests { // Composite reader, handed a frozen envelope. The frozen layout // writes its `HybridKemCt` with bincode and no header, so // `safe_deserialize` refuses it — again before any decryption. - let base = signcryption_fixture(PkeSchemeType::MlKem512, seed); - let mut rng = base.rng; - let frozen = UnifiedSigncryptionKey::from_signing_key( - base.signing_key.clone(), - base.enc_key.clone(), - base.receiver_id.clone(), - ) - .signcrypt(&mut rng, DSEP, &TestType { i: 7 }) - .unwrap(); - let err = reader.open(DSEP, &frozen).unwrap_err(); + let mut frozen_f = signcryption_fixture(PkeSchemeType::MlKem512, seed); + let frozen = frozen_f + .signcryption_key + .signcrypt(&mut frozen_f.rng, DSEP, &TestType { i: 7 }) + .unwrap(); + let err = f.unsigncryption_key.open(DSEP, &frozen).unwrap_err(); assert!( matches!(err, CryptographyError::SerializationError(_)), "the composite reader must reject a frozen envelope on deserialization, got: {err}" @@ -357,53 +323,52 @@ mod tests { /// Opening fails on every deviation from what was sealed. #[test] fn open_rejects_any_deviation_from_what_was_sealed() { - let mut f = fixture(PkeSchemeType::MlKem512, 500, pair()); + let mut f = composite_fixture(PkeSchemeType::MlKem512, 500, &pair()); let cipher = seal( - &f.sealer, + &f.signcryption_key, &mut f.rng, DSEP, - &f.schemes, + &f.schemes(), b"bound to one sender and one receiver", ) .unwrap(); // The untouched envelope opens, so each rejection below is the // deviation and not an unrelated failure. - let reader = - UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &f.receiver_id); - reader.open(DSEP, &cipher).unwrap(); + f.unsigncryption_key.open(DSEP, &cipher).unwrap(); assert!( - reader.open(b"OTHERDSP", &cipher).is_err(), + f.unsigncryption_key.open(b"OTHERDSP", &cipher).is_err(), "a different domain separator opened the envelope" ); let mut flipped = cipher.clone(); flipped.payload[0] ^= 0x01; assert!( - reader.open(DSEP, &flipped).is_err(), + f.unsigncryption_key.open(DSEP, &flipped).is_err(), "a tampered ciphertext opened the envelope" ); let mut rng = AesRng::seed_from_u64(999); - let other_keys = VerfKeySet::from_identity(&seeded_identity(&mut rng), &f.schemes).unwrap(); + let other_keys = + VerfKeySet::from_identity(&seeded_identity(&mut rng), &f.schemes()).unwrap(); assert!( - UnifiedUnsigncryptionKey::new_multi( - &f.dec_key, - &f.enc_key, - &other_keys, - &f.receiver_id + f.reader_with( + SenderAuth::Multi(other_keys), + f.signcryption_key.receiver_id.clone() ) .open(DSEP, &cipher) .is_err(), "another party's key set opened the envelope" ); - let other_id = b"a different receiver".to_vec(); assert!( - UnifiedUnsigncryptionKey::new_multi(&f.dec_key, &f.enc_key, &f.keys, &other_id) - .open(DSEP, &cipher) - .is_err(), + f.reader_with( + f.unsigncryption_key.sender.clone(), + b"a different receiver".to_vec() + ) + .open(DSEP, &cipher) + .is_err(), "a different receiver id opened the envelope" ); } @@ -413,14 +378,16 @@ mod tests { /// quietly dropping them and sealing under what is left. #[test] fn a_seedless_identity_cannot_seal_a_post_quantum_composite() { - let mut base = signcryption_fixture(PkeSchemeType::MlKem512, 600); - let key = UnifiedSigncryptionKey::from_signing_key( - base.signing_key.clone(), - base.enc_key.clone(), - base.receiver_id.clone(), - ); + let mut f = signcryption_fixture(PkeSchemeType::MlKem512, 600); - let err = seal(&key, &mut base.rng, DSEP, &pair(), b"no seed here").unwrap_err(); + let err = seal( + &f.signcryption_key, + &mut f.rng, + DSEP, + &pair(), + b"no seed here", + ) + .unwrap_err(); assert!(matches!(err, CryptographyError::SigningError(_)), "{err}"); } @@ -430,25 +397,27 @@ mod tests { /// `{Ecdsa256k1}`, which is what keeps the two apart. #[test] fn a_seedless_identity_can_seal_an_ecdsa_only_composite() { - let mut base = signcryption_fixture(PkeSchemeType::MlKem512, 700); - let key = UnifiedSigncryptionKey::from_signing_key( - base.signing_key.clone(), - base.enc_key.clone(), - base.receiver_id.clone(), - ); + let mut f = signcryption_fixture(PkeSchemeType::MlKem512, 700); let ecdsa_only = [SigningSchemeType::Ecdsa256k1]; - let cipher = seal(&key, &mut base.rng, DSEP, &ecdsa_only, b"one signature").unwrap(); - - let keys = VerfKeySet::from_identity(&key.identity, &ecdsa_only).unwrap(); - let opened = UnifiedUnsigncryptionKey::new_multi( - &base.dec_key, - &base.enc_key, - &keys, - &base.receiver_id, + let cipher = seal( + &f.signcryption_key, + &mut f.rng, + DSEP, + &ecdsa_only, + b"one signature", ) - .open(DSEP, &cipher) .unwrap(); + + let keys = + VerfKeySet::from_identity(&f.signcryption_key.identity, &ecdsa_only).unwrap(); + let opened = f + .reader_with( + SenderAuth::Multi(keys), + f.signcryption_key.receiver_id.clone(), + ) + .open(DSEP, &cipher) + .unwrap(); assert_eq!(&*opened, b"one signature"); } } diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 4ac1ffab9b..9f6a0aa1f9 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -87,7 +87,7 @@ pub(super) fn open( // LEGACY Code: should be using safe_deserialization from tfhe-rs let deserialized_payload: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - let decrypted_plaintext = hybrid_decrypt(deserialized_payload, unsign_key.decryption_key)?; + let decrypted_plaintext = hybrid_decrypt(deserialized_payload, &unsign_key.decryption_key)?; let (msg, sig) = parse_msg(decrypted_plaintext, sender_verf_key)?; check_format_and_signature(dsep, &msg, &sig, unsign_key, sender_verf_key)?; Ok(msg) @@ -139,8 +139,8 @@ fn check_format_and_signature( ) -> Result<(), CryptographyError> { // What should be signed is dsep || msg || H(client_verification_key) || H(client_enc_key) let binding = receiver_binding( - unsigncryption_key.receiver_id, - unsigncryption_key.encryption_key, + &unsigncryption_key.receiver_id, + &unsigncryption_key.encryption_key, )?; let msg_signed = Zeroizing::new([&dsep[..], msg, binding.as_slice()].concat()); @@ -242,20 +242,19 @@ mod tests { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { let mut f = signcryption_fixture(scheme, 200); let payload = TestType { i: 4711 }; - let signcrypt_key = UnifiedSigncryptionKey::from_signing_key( - f.signing_key.clone(), - f.enc_key.clone(), - f.receiver_id.clone(), - ); + let sender_verf_key = f.sender_verf_key(); let mut expected_msg = Vec::new(); safe_serialize(&payload, &mut expected_msg, SAFE_SER_SIZE_LIMIT).unwrap(); - let cipher = signcrypt_key.signcrypt(&mut f.rng, DSEP, &payload).unwrap(); + let cipher = f + .signcryption_key + .signcrypt(&mut f.rng, DSEP, &payload) + .unwrap(); assert_eq!(cipher.pke_type, scheme); let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); - let plaintext = hybrid_decrypt(kem_ct, &f.dec_key).unwrap(); + let plaintext = hybrid_decrypt(kem_ct, &f.unsigncryption_key.decryption_key).unwrap(); // Exactly three fields, the last two of fixed size. assert_eq!( @@ -267,14 +266,18 @@ mod tests { assert_eq!(&plaintext[..msg_len], expected_msg.as_slice()); // The middle field is the ECDSA signature over the locked preimage. - let binding = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); + let binding = receiver_binding( + &f.signcryption_key.receiver_id, + &f.signcryption_key.receiver_enc_key, + ) + .unwrap(); let signed = [expected_msg.as_slice(), binding.as_slice()].concat(); let sig = Signature::from_ecdsa( k256::ecdsa::Signature::from_slice(&plaintext[msg_len..msg_len + SIG_SIZE]) .unwrap(), ); check_normalized(&sig).expect("the signature must be low-s normalized"); - f.sender_verf_key + sender_verf_key .raw_verifying_key() .verify( &[&DSEP[..], signed.as_slice()].concat(), @@ -285,9 +288,7 @@ mod tests { // The tail field is the digest of the sender's verification key. assert_eq!( &plaintext[msg_len + SIG_SIZE..], - sender_verf_key_digest(&f.sender_verf_key) - .unwrap() - .as_slice(), + sender_verf_key_digest(&sender_verf_key).unwrap().as_slice(), "{scheme}: tail is not H(sender verification key)" ); } @@ -302,7 +303,7 @@ mod tests { let short = Zeroizing::new(vec![0u8; len]); assert!( matches!( - parse_msg(short, &f.sender_verf_key), + parse_msg(short, &f.sender_verf_key()), Err(CryptographyError::LengthError(_)) ), "a {len}-byte plaintext must be rejected as too short" @@ -317,15 +318,14 @@ mod tests { let f = signcryption_fixture(PkeSchemeType::MlKem512, 500); let other = signcryption_fixture(PkeSchemeType::MlKem512, 501); - let base = receiver_binding(&f.receiver_id, &f.enc_key).unwrap(); - assert_eq!(base, receiver_binding(&f.receiver_id, &f.enc_key).unwrap()); - assert_ne!( - base, - receiver_binding(&other.receiver_id, &f.enc_key).unwrap() - ); - assert_ne!( - base, - receiver_binding(&f.receiver_id, &other.enc_key).unwrap() - ); + let id = &f.signcryption_key.receiver_id; + let enc = &f.signcryption_key.receiver_enc_key; + let other_id = &other.signcryption_key.receiver_id; + let other_enc = &other.signcryption_key.receiver_enc_key; + + let base = receiver_binding(id, enc).unwrap(); + assert_eq!(base, receiver_binding(id, enc).unwrap()); + assert_ne!(base, receiver_binding(other_id, enc).unwrap()); + assert_ne!(base, receiver_binding(id, other_enc).unwrap()); } } diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 97239ba7ac..705fc03f5d 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -175,30 +175,30 @@ impl HasPkeScheme for UnifiedSigncryptionKey { /// build a key that is ambiguous about the layout it reads, and no layout tag on /// the message for the two to disagree with. #[derive(Clone, Debug)] -pub enum SenderAuth<'a> { +pub enum SenderAuth { /// A single ECDSA verification key: the frozen layout. - Ecdsa(&'a PublicSigKey), + Ecdsa(PublicSigKey), /// One verification key per scheme: the multi-signature layout. - Multi(&'a VerfKeySet), + Multi(VerfKeySet), } -/// Internal reference type for unsigncryption keys, storing only references to the real internal keys. +/// Who is reading a signcryption, and what it will authenticate the sender with. #[derive(Clone, Debug)] -pub struct UnifiedUnsigncryptionKey<'a> { - pub decryption_key: &'a UnifiedPrivateEncKey, - pub encryption_key: &'a UnifiedPublicEncKey, // Needed for validation of the signcrypted payload - pub sender: SenderAuth<'a>, +pub struct UnifiedUnsigncryptionKey { + pub decryption_key: Arc, + pub encryption_key: UnifiedPublicEncKey, // Needed for validation of the signcrypted payload + pub sender: SenderAuth, /// The ID of the receiver of the signcryption, e.g. blockchain address - pub receiver_id: &'a [u8], + pub receiver_id: Vec, } -impl<'a> UnifiedUnsigncryptionKey<'a> { +impl UnifiedUnsigncryptionKey { /// A reader of the frozen, single-ECDSA layout. pub fn new( - decryption_key: &'a UnifiedPrivateEncKey, - encryption_key: &'a UnifiedPublicEncKey, - sender_verf_key: &'a PublicSigKey, - receiver_id: &'a [u8], + decryption_key: Arc, + encryption_key: UnifiedPublicEncKey, + sender_verf_key: PublicSigKey, + receiver_id: Vec, ) -> Self { Self { sender: SenderAuth::Ecdsa(sender_verf_key), @@ -211,10 +211,10 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { /// A reader of the multi-signature layout, requiring a signature under every /// scheme `keys` holds a key for. pub fn new_multi( - decryption_key: &'a UnifiedPrivateEncKey, - encryption_key: &'a UnifiedPublicEncKey, - keys: &'a VerfKeySet, - receiver_id: &'a [u8], + decryption_key: Arc, + encryption_key: UnifiedPublicEncKey, + keys: VerfKeySet, + receiver_id: Vec, ) -> Self { Self { sender: SenderAuth::Multi(keys), @@ -241,56 +241,7 @@ impl<'a> UnifiedUnsigncryptionKey<'a> { } } -impl HasPkeScheme for UnifiedUnsigncryptionKey<'_> { - fn encryption_scheme_type(&self) -> PkeSchemeType { - self.encryption_key.encryption_scheme_type() - } -} - -/// An owning reader, the counterpart of [`UnifiedSigncryptionKey`], and not -/// serializable for the same reason. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UnifiedUnsigncryptionKeyOwned { - pub decryption_key: UnifiedPrivateEncKey, - pub encryption_key: UnifiedPublicEncKey, // Needed for validation of the signcrypted payload - pub sender_verf_key: PublicSigKey, - /// The ID of the receiver of the signcryption, e.g. blockchain address] - pub receiver_id: Vec, -} - -impl Zeroize for UnifiedUnsigncryptionKeyOwned { - fn zeroize(&mut self) { - // We only need to zeroize the private key - self.decryption_key.zeroize(); - } -} - -impl UnifiedUnsigncryptionKeyOwned { - pub fn new( - decryption_key: UnifiedPrivateEncKey, - encryption_key: UnifiedPublicEncKey, - sender_verf_key: PublicSigKey, - receiver_id: Vec, - ) -> Self { - Self { - sender_verf_key, - decryption_key, - encryption_key, - receiver_id, - } - } - - pub fn reference<'a>(&'a self) -> UnifiedUnsigncryptionKey<'a> { - UnifiedUnsigncryptionKey { - decryption_key: &self.decryption_key, - encryption_key: &self.encryption_key, - sender: SenderAuth::Ecdsa(&self.sender_verf_key), - receiver_id: &self.receiver_id, - } - } -} - -impl HasPkeScheme for UnifiedUnsigncryptionKeyOwned { +impl HasPkeScheme for UnifiedUnsigncryptionKey { fn encryption_scheme_type(&self) -> PkeSchemeType { self.encryption_key.encryption_scheme_type() } @@ -497,7 +448,7 @@ impl SigncryptFHEPlaintext for UnifiedSigncryptionKey { } } -impl<'a> Unsigncrypt for UnifiedUnsigncryptionKey<'a> { +impl Unsigncrypt for UnifiedUnsigncryptionKey { fn unsigncrypt( &self, dsep: &DomainSep, @@ -524,27 +475,7 @@ impl<'a> Unsigncrypt for UnifiedUnsigncryptionKey<'a> { } } -impl Unsigncrypt for UnifiedUnsigncryptionKeyOwned { - fn unsigncrypt( - &self, - dsep: &DomainSep, - cipher: &UnifiedSigncryption, - ) -> Result { - let ref_type = self.reference(); - ref_type.unsigncrypt(dsep, cipher) - } - - fn validate_signcryption( - &self, - dsep: &DomainSep, - signcryption: &UnifiedSigncryption, - ) -> Result<(), CryptographyError> { - let ref_type = self.reference(); - ref_type.validate_signcryption(dsep, signcryption) - } -} - -impl<'a> UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKey<'a> { +impl UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKey { fn unsigncrypt_plaintext( &self, dsep: &DomainSep, @@ -572,18 +503,6 @@ impl<'a> UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKey<'a> { } } -impl UnsigncryptFHEPlaintext for UnifiedUnsigncryptionKeyOwned { - fn unsigncrypt_plaintext( - &self, - dsep: &DomainSep, - signcryption: &[u8], - link: &[u8], - ) -> Result { - let ref_type = self.reference(); - ref_type.unsigncrypt_plaintext(dsep, signcryption, link) - } -} - /// Helper method for what the client is supposed to do when generating ephemeral keys linked to the /// client's blockchain signing key #[cfg(test)] @@ -609,8 +528,8 @@ pub fn ephemeral_signcryption_key_generation( enc_key.clone(), client_verf_key_id.to_vec(), ), - unsigncryption_key: UnifiedUnsigncryptionKeyOwned::new( - dec_key, + unsigncryption_key: UnifiedUnsigncryptionKey::new( + Arc::new(dec_key), enc_key, server_verf_key.clone(), client_verf_key_id.to_vec(), @@ -624,7 +543,7 @@ pub fn ephemeral_signcryption_key_generation( #[derive(Clone, Debug)] pub struct UnifiedSigncryptionKeyPairOwned { pub signcrypt_key: UnifiedSigncryptionKey, - pub unsigncryption_key: UnifiedUnsigncryptionKeyOwned, + pub unsigncryption_key: UnifiedUnsigncryptionKey, } #[cfg(test)] @@ -663,8 +582,8 @@ mod tests { enc_key.clone(), receiver_id.clone(), ), - unsigncryption_key: UnifiedUnsigncryptionKeyOwned::new( - dec_key, + unsigncryption_key: UnifiedUnsigncryptionKey::new( + Arc::new(dec_key), enc_key, server_verf_key, receiver_id, diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index 69cfb149c2..54363bf9ed 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -59,7 +59,7 @@ use kms_lib::{ }, signcryption::{ Signcrypt, SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, - UnifiedUnsigncryptionKeyOwned, Unsigncrypt, + UnifiedUnsigncryptionKey, Unsigncrypt, }, }, engine::{ @@ -773,7 +773,12 @@ fn test_unified_signcryption( // frozen layout carries no version tag and is recovered by subtracting two // fixed-size tail fields, so a change there is silent until something tries. let unsign_key = - UnifiedUnsigncryptionKeyOwned::new(dec_key, enc_key, verf_key.clone(), receiver_id); + UnifiedUnsigncryptionKey::new( + std::sync::Arc::new(dec_key), + enc_key, + verf_key.clone(), + receiver_id, + ); let opened: PublicSigKey = unsign_key .unsigncrypt(b"TESTTEST", &original_versionized) .map_err(|e| { From 17c5877ffad77bb672232d8b10a77f9451fcf8b7 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 15:38:52 +0200 Subject: [PATCH 066/114] fix: compile issues --- core/service/src/backup/custodian.rs | 9 ++-- core/service/src/backup/operator.rs | 13 +++--- .../src/cryptography/signcryption/mod.rs | 43 +++++++++++++------ core/service/src/engine/backup_operator.rs | 3 +- .../src/engine/centralized/central_kms.rs | 4 +- core/service/src/engine/context_manager.rs | 8 ++-- 6 files changed, 51 insertions(+), 29 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 7df45518ff..58924a8ecb 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -19,6 +19,7 @@ use kms_grpc::kms::v1::{ use rand::{CryptoRng, Rng}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use std::sync::Arc; use std::time::SystemTime; use tfhe::safe_serialization::safe_serialize; use tfhe::{Versionize, named::Named, safe_serialization::safe_deserialize}; @@ -409,10 +410,10 @@ impl Custodian { ); let custodian_id = self.verification_key().verf_key_id(); let unsigncrypt_key = UnifiedUnsigncryptionKey::new( - &self.dec_key, - &self.enc_key, - operator_verification_key, - &custodian_id, + Arc::new(self.dec_key.clone()), + self.enc_key.clone(), + operator_verification_key.clone(), + custodian_id, ); // BackupMaterial contains secret shares which should be zeroized when dropped diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index 6daf426165..4f20311aae 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -631,7 +631,7 @@ impl Operator { &self, output: &InternalCustodianRecoveryOutput, recovery_material: &RecoveryValidationMaterial, - ephm_dec_key: &UnifiedPrivateEncKey, + ephm_dec_key: &Arc, ephm_enc_key: &UnifiedPublicEncKey, ) -> Result, RecoverySkipReason> { let (_, custodian_verf_key) = self.custodian_keys.get(&output.custodian_role).ok_or({ @@ -640,10 +640,10 @@ impl Operator { })?; let operator_id = self.verification_key.verf_key_id(); let unsign_key = UnifiedUnsigncryptionKey::new( - ephm_dec_key, - ephm_enc_key, - custodian_verf_key, - &operator_id, + ephm_dec_key.clone(), + ephm_enc_key.clone(), + custodian_verf_key.clone(), + operator_id.clone(), ); let backup_material: Zeroizing = Zeroizing::new( unsign_key @@ -738,11 +738,12 @@ impl Operator { ) -> Result>, BackupError> { let mut validated: HashMap> = HashMap::new(); let mut skip_reasons: Vec = Vec::new(); + let ephm_dec_key = Arc::new(ephm_dec_key.clone()); for output in custodian_recovery_output { match self.validate_one_recovery_output( output, recovery_material, - ephm_dec_key, + &ephm_dec_key, ephm_enc_key, ) { Ok(bm) => match validated.entry(output.custodian_role) { diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 705fc03f5d..144102ed94 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -548,6 +548,7 @@ pub struct UnifiedSigncryptionKeyPairOwned { #[cfg(test)] mod tests { + use core::panic; use super::*; use crate::cryptography::{ encryption::{Encryption, PkeScheme, PkeSchemeType}, @@ -642,9 +643,12 @@ mod tests { let deserialized_cipher: UnifiedSigncryption = bc2wrap::deserialize_slice(&serialized_cipher).unwrap(); - let serialized_server_verf_key = - bc2wrap::serialize(&client_signcryption_keys.unsigncryption_key.sender_verf_key) - .unwrap(); + let sender_verf_key = match &client_signcryption_keys.unsigncryption_key.sender { + SenderAuth::Ecdsa(verf_key) => Some(verf_key), + SenderAuth::Multi(_) => panic!("the test reader is a frozen one"), + }; + + let serialized_server_verf_key = bc2wrap::serialize(&sender_verf_key).unwrap(); let deserialized_server_verf_key: PublicSigKey = bc2wrap::deserialize_slice(&serialized_server_verf_key).unwrap(); let client_id = client_signcryption_keys @@ -652,10 +656,16 @@ mod tests { .receiver_id .clone(); let new_keys = UnifiedUnsigncryptionKey::new( - &client_signcryption_keys.unsigncryption_key.decryption_key, - &client_signcryption_keys.unsigncryption_key.encryption_key, - &deserialized_server_verf_key, - &client_id, + client_signcryption_keys + .unsigncryption_key + .decryption_key + .clone(), + client_signcryption_keys + .unsigncryption_key + .encryption_key + .clone(), + deserialized_server_verf_key, + client_id, ); let decrypted_msg = new_keys .unsigncrypt(b"TESTTEST", &deserialized_cipher) @@ -703,7 +713,7 @@ mod tests { let wrong_keys = ephemeral_signcryption_key_generation( &mut rng, &client_signcryption_keys.unsigncryption_key.receiver_id, - Some(&client_signcryption_keys.signcrypt_key.signing_key), + Some(client_signcryption_keys.signcrypt_key.signing_key()), ); assert!( wrong_keys @@ -718,10 +728,19 @@ mod tests { let mut rng = AesRng::seed_from_u64(2); let (wrong_verf_key, _) = gen_sig_keys(&mut rng); let wrong_keys = UnifiedUnsigncryptionKey::new( - &client_signcryption_keys.unsigncryption_key.decryption_key, - &client_signcryption_keys.unsigncryption_key.encryption_key, - &wrong_verf_key, - &client_signcryption_keys.unsigncryption_key.receiver_id, + client_signcryption_keys + .unsigncryption_key + .decryption_key + .clone(), + client_signcryption_keys + .unsigncryption_key + .encryption_key + .clone(), + wrong_verf_key, + client_signcryption_keys + .unsigncryption_key + .receiver_id + .clone(), ); assert!( wrong_keys diff --git a/core/service/src/engine/backup_operator.rs b/core/service/src/engine/backup_operator.rs index 8a9a66c15f..3190713729 100644 --- a/core/service/src/engine/backup_operator.rs +++ b/core/service/src/engine/backup_operator.rs @@ -762,6 +762,7 @@ async fn filter_custodian_data( let outputs_len = recovery_material.custodian_context().custodian_nodes.len(); let mut parsed_custodian_rec: HashMap> = HashMap::new(); let mut skip_reasons: Vec = Vec::new(); + let ephemeral_dec_key = Arc::new(ephemeral_dec_key.clone()); for cur_recovery_output in &custodian_recovery_outputs { if cur_recovery_output.custodian_role == 0 @@ -807,7 +808,7 @@ async fn filter_custodian_data( match operator.validate_one_recovery_output( &internal, recovery_material, - ephemeral_dec_key, + &ephemeral_dec_key, ephemeral_enc_key, ) { Ok(backup_material) => match parsed_custodian_rec.entry(role) { diff --git a/core/service/src/engine/centralized/central_kms.rs b/core/service/src/engine/centralized/central_kms.rs index 60476bdd88..1f62025d61 100644 --- a/core/service/src/engine/centralized/central_kms.rs +++ b/core/service/src/engine/centralized/central_kms.rs @@ -1182,7 +1182,7 @@ pub(crate) mod tests { use crate::cryptography::signatures::PublicSigKey; use crate::cryptography::signatures::gen_sig_keys; use crate::cryptography::signcryption::{ - UnsigncryptFHEPlaintext, ephemeral_signcryption_key_generation, + SenderAuth, UnsigncryptFHEPlaintext, ephemeral_signcryption_key_generation, }; use crate::cryptography::signing::identity::NodeSigningIdentity; use crate::dummy_domain; @@ -1858,7 +1858,7 @@ pub(crate) mod tests { if sim_type == SimulationType::BadSigKey { // Change the signing key let (server_sig_pk, _server_sig_sk) = gen_sig_keys(&mut rng); - keys.unsigncryption_key.sender_verf_key = server_sig_pk; + keys.unsigncryption_key.sender = SenderAuth::Ecdsa(server_sig_pk); } keys }; diff --git a/core/service/src/engine/context_manager.rs b/core/service/src/engine/context_manager.rs index 2177281c91..ccadfe1b12 100644 --- a/core/service/src/engine/context_manager.rs +++ b/core/service/src/engine/context_manager.rs @@ -2698,10 +2698,10 @@ mod tests { // custodian's unsigncryption key, confirming the backup material was sealed correctly. let custodian_id = custodian1.verification_key().verf_key_id(); let unsign_key = UnifiedUnsigncryptionKey::new( - custodian1.public_dec_key(), - custodian1.public_enc_key(), - &server_verf_key, - &custodian_id, + std::sync::Arc::new(custodian1.public_dec_key().clone()), + custodian1.public_enc_key().clone(), + server_verf_key.clone(), + custodian_id, ); let role_1_ct = internal_rec_req .signcryptions() From 782ba5c730a0eaff1ba192d8c0a079117053397b Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 15:44:39 +0200 Subject: [PATCH 067/114] refactor: moved hybryid en/decrypt --- core/service/src/cryptography/encryption.rs | 72 +++++++++---------- .../src/cryptography/signcryption/common.rs | 43 +---------- 2 files changed, 39 insertions(+), 76 deletions(-) diff --git a/core/service/src/cryptography/encryption.rs b/core/service/src/cryptography/encryption.rs index c5c9b5779a..0dde3f18a1 100644 --- a/core/service/src/cryptography/encryption.rs +++ b/core/service/src/cryptography/encryption.rs @@ -68,6 +68,23 @@ impl HasPkeScheme for UnifiedPublicEncKey { } impl UnifiedPublicEncKey { + /// Encrypt `msg` under this key with the hybrid KEM/DEM its scheme calls for. + pub(crate) fn hybrid_encrypt( + &self, + rng: &mut (impl CryptoRng + RngCore), + msg: &[u8], + ) -> Result { + match self { + UnifiedPublicEncKey::MlKem512(public_enc_key) => { + hybrid_ml_kem::enc::(rng, msg, &public_enc_key.0) + } + UnifiedPublicEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), + UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { + hybrid_composite_ml_kem::enc_ml_kem_1024_p384(rng, msg, public_enc_key) + } + } + } + /// Expect the inner type to be MlKem512, the user-decryption scheme, and return it, /// otherwise panic. Not for use on backup keys, which are MlKem1024P384. pub fn unwrap_ml_kem_512(self) -> PublicEncKey { @@ -225,28 +242,8 @@ impl Encrypt for UnifiedPublicEncKey { let mut serialized_msg = ZeroizingWriter::new(); tfhe::safe_serialization::safe_serialize(msg, &mut serialized_msg, SAFE_SER_SIZE_LIMIT) .map_err(|e| CryptographyError::DeserializationError(e.to_string()))?; - let (inner_ct, scheme) = match self { - UnifiedPublicEncKey::MlKem512(public_enc_key) => ( - hybrid_ml_kem::enc::( - rng, - serialized_msg.as_slice(), - &public_enc_key.0, - )?, - PkeSchemeType::MlKem512, - ), - UnifiedPublicEncKey::MlKem1024(_) => { - return Err(CryptographyError::MlKem1024Unsupported); - } - UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => ( - hybrid_composite_ml_kem::enc_ml_kem_1024_p384( - rng, - serialized_msg.as_slice(), - public_enc_key, - )?, - PkeSchemeType::MlKem1024P384, - ), - }; - Ok(UnifiedCipher::new(inner_ct, scheme)) + let inner_ct = self.hybrid_encrypt(rng, serialized_msg.as_slice())?; + Ok(UnifiedCipher::new(inner_ct, self.encryption_scheme_type())) } } @@ -312,6 +309,22 @@ impl From<&UnifiedPrivateEncKey> for PkeSchemeType { } impl UnifiedPrivateEncKey { + /// Decrypt `ct` under this key with the hybrid KEM/DEM its scheme calls for. + pub(crate) fn hybrid_decrypt( + &self, + ct: HybridKemCt, + ) -> Result>, CryptographyError> { + match self { + UnifiedPrivateEncKey::MlKem512(private_enc_key) => { + hybrid_ml_kem::dec::(ct, &private_enc_key.0) + } + UnifiedPrivateEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), + UnifiedPrivateEncKey::MlKem1024P384(private_enc_key) => { + hybrid_composite_ml_kem::dec_ml_kem_1024_p384(ct, private_enc_key) + } + } + } + /// Expect the inner type to be MlKem512, the user-decryption scheme, and return it, /// otherwise panic. Not for use on backup keys, which are MlKem1024P384. pub fn unwrap_ml_kem_512(self) -> PrivateEncKey { @@ -497,20 +510,7 @@ impl Decrypt for UnifiedPrivateEncKey { "encryption type of cipher does not match the decryption key type".to_string(), )); } - let raw_plaintext = match self { - UnifiedPrivateEncKey::MlKem512(private_enc_key) => { - hybrid_ml_kem::dec::(cipher.cipher.to_owned(), &private_enc_key.0)? - } - UnifiedPrivateEncKey::MlKem1024(_) => { - return Err(CryptographyError::MlKem1024Unsupported); - } - UnifiedPrivateEncKey::MlKem1024P384(private_enc_key) => { - hybrid_composite_ml_kem::dec_ml_kem_1024_p384( - cipher.cipher.to_owned(), - private_enc_key, - )? - } - }; + let raw_plaintext = self.hybrid_decrypt(cipher.cipher.to_owned())?; // Keep plaintext guarded through deserialization. let mut res_buf = std::io::Cursor::new(&*raw_plaintext); safe_deserialize(&mut res_buf, SAFE_SER_SIZE_LIMIT) diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index d77501abf6..59fc1f8680 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -1,15 +1,11 @@ //! Code shared by every signcryption envelope format. //! //! Signing and verifying build *identical* receiver bindings or nothing -//! verifies at all, and both formats encrypt the same way. +//! verifies at all. -use crate::cryptography::encryption::{UnifiedPrivateEncKey, UnifiedPublicEncKey}; +use crate::cryptography::encryption::UnifiedPublicEncKey; use crate::cryptography::error::CryptographyError; -use crate::cryptography::hybrid_composite_ml_kem; -use crate::cryptography::hybrid_ml_kem::{self, HybridKemCt}; use hashing::{DomainSep, serialize_hash_element}; -use rand::{CryptoRng, RngCore}; -use zeroize::Zeroizing; pub(super) const DSEP_SIGNCRYPTION: DomainSep = *b"SIGNCRYP"; @@ -31,46 +27,13 @@ pub(super) fn receiver_enc_key_digest( } } -/// Encrypt `msg` under `enc_key` with the hybrid KEM/DEM matching its scheme. -pub(super) fn hybrid_encrypt( - rng: &mut (impl CryptoRng + RngCore), - msg: &[u8], - enc_key: &UnifiedPublicEncKey, -) -> Result { - match enc_key { - UnifiedPublicEncKey::MlKem512(public_enc_key) => { - hybrid_ml_kem::enc::(rng, msg, &public_enc_key.0) - } - UnifiedPublicEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), - UnifiedPublicEncKey::MlKem1024P384(public_enc_key) => { - hybrid_composite_ml_kem::enc_ml_kem_1024_p384(rng, msg, public_enc_key) - } - } -} - -/// Decrypt `ct` under `dec_key` with the hybrid KEM/DEM matching its scheme. -pub(super) fn hybrid_decrypt( - ct: HybridKemCt, - dec_key: &UnifiedPrivateEncKey, -) -> Result>, CryptographyError> { - match dec_key { - UnifiedPrivateEncKey::MlKem512(dec_key) => { - hybrid_ml_kem::dec::(ct, &dec_key.0) - } - UnifiedPrivateEncKey::MlKem1024(_) => Err(CryptographyError::MlKem1024Unsupported), - UnifiedPrivateEncKey::MlKem1024P384(dec_key) => { - hybrid_composite_ml_kem::dec_ml_kem_1024_p384(ct, dec_key) - } - } -} - // Test scaffolding shared by both envelope formats. At module level rather than // inside `tests` so that `ecdsa_v0` and `composite_v1` can reach it without a // copy. #[cfg(test)] use super::{UnifiedSigncryptionKey, UnifiedUnsigncryptionKey}; #[cfg(test)] -use crate::cryptography::encryption::{Encryption, PkeScheme, PkeSchemeType}; +use crate::cryptography::encryption::{Encryption, PkeScheme, PkeSchemeType, UnifiedPrivateEncKey}; #[cfg(test)] use crate::cryptography::signatures::{ PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet, gen_sig_keys, From 7984c2f2aa45f14cf30323ce1ae4902e6d2180ba Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 15:47:38 +0200 Subject: [PATCH 068/114] refactor: further simpification --- .../cryptography/signcryption/composite_v1.rs | 31 +++----- .../src/cryptography/signcryption/ecdsa_v0.rs | 71 ++++++++----------- .../src/cryptography/signcryption/mod.rs | 20 +++--- 3 files changed, 51 insertions(+), 71 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index d88267ef0d..8c8a2f6094 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -1,14 +1,14 @@ //! The composite signcryption envelope. use super::UnifiedSigncryption; -#[cfg(feature = "non-wasm")] -use super::common::hybrid_encrypt; -use super::common::{hybrid_decrypt, receiver_enc_key_digest}; +use super::common::receiver_enc_key_digest; use crate::consts::SAFE_SER_SIZE_LIMIT; -use crate::cryptography::encryption::{HasPkeScheme, UnifiedPublicEncKey}; +#[cfg(feature = "non-wasm")] +use crate::cryptography::encryption::HasPkeScheme; +use crate::cryptography::encryption::UnifiedPublicEncKey; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; -use crate::cryptography::signatures::StoredTypedSignature; +use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signatures::SigningSchemeType; #[cfg(feature = "non-wasm")] @@ -127,7 +127,7 @@ pub(super) fn seal( envelope.zeroize(); serialized.map_err(|e| CryptographyError::SerializationError(e.to_string()))?; - let ciphertext = hybrid_encrypt(rng, plaintext.as_slice(), receiver_enc_key)?; + let ciphertext = receiver_enc_key.hybrid_encrypt(rng, plaintext.as_slice())?; let mut payload = Vec::new(); safe_serialize(&ciphertext, &mut payload, SAFE_SER_SIZE_LIMIT) .map_err(|e| CryptographyError::SerializationError(e.to_string()))?; @@ -140,30 +140,17 @@ pub(super) fn seal( /// Open a composite signcryption, returning the message only once every /// constituent signature has verified. /// -/// `sender_keys` is the verifier's policy the signatures will be validated against. +/// `sender_keys` is the reader's policy the signatures are validated against. pub(super) fn open( unsign_key: &UnifiedUnsigncryptionKey, + sender_keys: &VerfKeySet, dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { - if cipher.pke_type != unsign_key.encryption_key.encryption_scheme_type() { - return Err(CryptographyError::VerificationError( - "encryption type of cipher does not match the decryption key type".to_string(), - )); - } - let sender_keys = match &unsign_key.sender { - super::SenderAuth::Ecdsa(_public_sig_key) => { - return Err(CryptographyError::VerificationError( - "Unsigncryption key for legacy scheme supplied for composite signcryption" - .to_string(), - )); - } - super::SenderAuth::Multi(verf_key_set) => verf_key_set, - }; let kem_ct: HybridKemCt = safe_deserialize(std::io::Cursor::new(&cipher.payload), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError)?; - let plaintext = hybrid_decrypt(kem_ct, &unsign_key.decryption_key)?; + let plaintext = unsign_key.decryption_key.hybrid_decrypt(kem_ct)?; let mut envelope: CompositeEnvelope = safe_deserialize(std::io::Cursor::new(&*plaintext), SAFE_SER_SIZE_LIMIT) .map_err(CryptographyError::SerializationError)?; diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 9f6a0aa1f9..8e0b1f4db3 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -4,7 +4,7 @@ //! deployed browser-side verifier parses it, so its bytes cannot change. See the //! module documentation of [`super`] for the full list of what pins them. -use super::common::{DSEP_SIGNCRYPTION, hybrid_decrypt, hybrid_encrypt}; +use super::common::DSEP_SIGNCRYPTION; use super::{ SigncryptionPayload, UnifiedSigncryption, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey, }; @@ -61,7 +61,7 @@ pub(super) fn seal( let to_encrypt = Zeroizing::new([msg, sig.to_bytes().as_ref(), verf_key_hash.as_ref()].concat()); - let ciphertext = hybrid_encrypt(rng, &to_encrypt, &signcrypt_key.receiver_enc_key)?; + let ciphertext = signcrypt_key.receiver_enc_key.hybrid_encrypt(rng, &to_encrypt)?; // LEGACY: approach to serialization Ok(UnifiedSigncryption::new( bc2wrap::serialize(&ciphertext) @@ -79,42 +79,44 @@ pub(super) fn open( dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { - if cipher.pke_type != unsign_key.encryption_key.encryption_scheme_type() { - return Err(CryptographyError::VerificationError( - "encryption type of cipher does not match the decryption key type".to_string(), - )); - } // LEGACY Code: should be using safe_deserialization from tfhe-rs let deserialized_payload: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - let decrypted_plaintext = hybrid_decrypt(deserialized_payload, &unsign_key.decryption_key)?; + let decrypted_plaintext = unsign_key.decryption_key.hybrid_decrypt(deserialized_payload)?; let (msg, sig) = parse_msg(decrypted_plaintext, sender_verf_key)?; check_format_and_signature(dsep, &msg, &sig, unsign_key, sender_verf_key)?; Ok(msg) } +/// Split a decrypted frozen-layout plaintext into `msg`, `sig` and +/// `H(sender verification key)`. +fn split_frozen_plaintext(plaintext: &[u8]) -> Result<(&[u8], &[u8], &[u8]), CryptographyError> { + // The plaintext contains msg || sig || H(server_verification_key) + let msg_len = plaintext + .len() + .checked_sub(DIGEST_BYTES) + .and_then(|len| len.checked_sub(SIG_SIZE)) + .ok_or_else(|| { + CryptographyError::LengthError(format!( + "Message is too short ({} bytes) to contain sig || H(server_verification_key) ({} bytes) ", + plaintext.len(), + DIGEST_BYTES + SIG_SIZE + )) + })?; + Ok(( + &plaintext[..msg_len], + &plaintext[msg_len..msg_len + SIG_SIZE], + &plaintext[msg_len + SIG_SIZE..], + )) +} + /// Helper method for parsing a signcrypted message consisting of the _true_ msg || sig || /// H(server_verification_key) fn parse_msg( decrypted_plaintext: Zeroizing>, server_verf_key: &PublicSigKey, ) -> Result<(Zeroizing>, Signature), CryptographyError> { - // The plaintext contains msg || sig || H(server_verification_key) - let msg_len = decrypted_plaintext - .len() - .checked_sub(DIGEST_BYTES) - .and_then(|len| len.checked_sub(SIG_SIZE)) - .ok_or_else(|| - CryptographyError::LengthError( - format!("Message is too short ({} bytes) to contain sig || H(server_verification_key) ({} bytes) ", - decrypted_plaintext.len(), - DIGEST_BYTES + SIG_SIZE) - ) - )?; - let msg = &decrypted_plaintext[..msg_len]; - let sig_bytes = &decrypted_plaintext[msg_len..(msg_len + SIG_SIZE)]; - let server_ver_key_digest = - &decrypted_plaintext[(msg_len + SIG_SIZE)..(msg_len + SIG_SIZE + DIGEST_BYTES)]; + let (msg, sig_bytes, server_ver_key_digest) = split_frozen_plaintext(&decrypted_plaintext)?; // LEGACY: this should just be based on key id. Again legacy code that could be done more proper by using the notion of an id! // Verify verification key digest if sender_verf_key_digest(server_verf_key)? != server_ver_key_digest { @@ -178,23 +180,10 @@ pub(crate) fn insecure_decrypt_ignoring_signature( // LEGACY should be using safe_deserialization from tfhe-rs let cipher: HybridKemCt = bc2wrap::deserialize_slice(cipher) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - let decrypted_plaintext = hybrid_decrypt(cipher, dec_key)?; + let decrypted_plaintext = dec_key.hybrid_decrypt(cipher)?; - // strip off the signature bytes (these are ignored here) - // The sender is not authenticated on this path, so the plaintext length is - // attacker-chosen and the subtraction is checked, as in `parse_msg`. - let msg_len = decrypted_plaintext - .len() - .checked_sub(DIGEST_BYTES) - .and_then(|len| len.checked_sub(SIG_SIZE)) - .ok_or_else(|| { - CryptographyError::LengthError(format!( - "Message is too short ({} bytes) to contain sig || H(server_verification_key) ({} bytes) ", - decrypted_plaintext.len(), - DIGEST_BYTES + SIG_SIZE - )) - })?; - let msg = &decrypted_plaintext[..msg_len]; + // Strip off the signature and the sender key digest; this path checks neither. + let (msg, _sig, _sender_digest) = split_frozen_plaintext(&decrypted_plaintext)?; // LEGACY should be using safe_deserialization from tfhe-rs let signcrypted_msg: SigncryptionPayload = bc2wrap::deserialize_slice(msg) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; @@ -254,7 +243,7 @@ mod tests { assert_eq!(cipher.pke_type, scheme); let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); - let plaintext = hybrid_decrypt(kem_ct, &f.unsigncryption_key.decryption_key).unwrap(); + let plaintext = f.unsigncryption_key.decryption_key.hybrid_decrypt(kem_ct).unwrap(); // Exactly three fields, the last two of fixed size. assert_eq!( diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 144102ed94..9d4147610d 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -19,7 +19,7 @@ //! scheme policy can only open the multi-signature one. mod common; -pub mod composite_v1; +mod composite_v1; mod ecdsa_v0; pub(crate) use ecdsa_v0::insecure_decrypt_ignoring_signature; @@ -69,17 +69,13 @@ pub trait Signcrypt { pub trait Unsigncrypt { /// Decrypt a signcrypted message and verify the signature before returning the result. /// If the signature verification fails, an error is returned. - /// - /// This fn also checks that the provided link parameter corresponds to the link in the signcryption - /// payload. fn unsigncrypt( &self, dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result; - /// Validate the signature of a signcrypted message without decrypting the payload. - /// This can be used to check authenticity if decryption is not needed. + /// Authenticate a signcrypted message and discard the payload. fn validate_signcryption( &self, dsep: &DomainSep, @@ -143,7 +139,8 @@ impl UnifiedSigncryptionKey { /// /// The key becomes a seedless identity: it writes the frozen envelope, and a /// composite one under `Ecdsa256k1` alone. Asking it for any other scheme - /// fails with `MissingRootSeed`. + /// fails with `CryptographyError::Signing`, wrapping the + /// `SigningError::MissingRootSeed` raised by the identity. pub fn from_signing_key( signing_key: PrivateSigKey, receiver_enc_key: UnifiedPublicEncKey, @@ -232,11 +229,18 @@ impl UnifiedUnsigncryptionKey { dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { + // Neither layout can open a ciphertext written for another KEM, so the + // check is made once here rather than at the head of each opener. + if cipher.pke_type != self.encryption_key.encryption_scheme_type() { + return Err(CryptographyError::VerificationError( + "encryption type of cipher does not match the decryption key type".to_string(), + )); + } match &self.sender { SenderAuth::Ecdsa(sender_verf_key) => { ecdsa_v0::open(self, sender_verf_key, dsep, cipher) } - SenderAuth::Multi(_keys) => composite_v1::open(self, dsep, cipher), + SenderAuth::Multi(keys) => composite_v1::open(self, keys, dsep, cipher), } } } From 4a657e1535dcb5bf80957c7130e6724e729643dc Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 16:03:27 +0200 Subject: [PATCH 069/114] test: consolidation --- .../src/cryptography/signcryption/common.rs | 245 +++++++++--------- .../cryptography/signcryption/composite_v1.rs | 122 ++++----- .../src/cryptography/signcryption/ecdsa_v0.rs | 61 ++--- .../src/cryptography/signcryption/mod.rs | 225 +++++----------- 4 files changed, 267 insertions(+), 386 deletions(-) diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index 59fc1f8680..fff2b3ef3c 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -27,142 +27,143 @@ pub(super) fn receiver_enc_key_digest( } } -// Test scaffolding shared by both envelope formats. At module level rather than -// inside `tests` so that `ecdsa_v0` and `composite_v1` can reach it without a -// copy. #[cfg(test)] -use super::{UnifiedSigncryptionKey, UnifiedUnsigncryptionKey}; -#[cfg(test)] -use crate::cryptography::encryption::{Encryption, PkeScheme, PkeSchemeType, UnifiedPrivateEncKey}; -#[cfg(test)] -use crate::cryptography::signatures::{ - PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet, gen_sig_keys, -}; -#[cfg(test)] -use aes_prng::AesRng; -#[cfg(test)] -use rand::SeedableRng; -#[cfg(test)] -use std::sync::Arc; +pub(crate) mod test_support { + //! Fixtures shared by both envelope formats' tests. -/// One sender, one receiver, and the rng that produced them. -#[cfg(test)] -pub(super) struct SigncryptionFixture { - pub(super) rng: AesRng, - pub(super) signcryption_key: UnifiedSigncryptionKey, - pub(super) unsigncryption_key: UnifiedUnsigncryptionKey, -} + use super::super::{SenderAuth, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey}; + use crate::cryptography::encryption::{ + Encryption, PkeScheme, PkeSchemeType, UnifiedPrivateEncKey, UnifiedPublicEncKey, + }; + use crate::cryptography::signatures::{ + PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet, gen_sig_keys, + }; + use crate::cryptography::signing::test_support::seeded_identity; + use aes_prng::AesRng; + use rand::SeedableRng; + use std::sync::Arc; -#[cfg(test)] -impl SigncryptionFixture { - /// The sender's ECDSA verification key, as the frozen reader names it. - pub(super) fn sender_verf_key(&self) -> PublicSigKey { - PublicSigKey::from_sk(self.signcryption_key.signing_key()) + /// One sender, one receiver, and the rng that produced them. + pub(crate) struct SigncryptionFixture { + pub(crate) rng: AesRng, + pub(crate) signcryption_key: UnifiedSigncryptionKey, + pub(crate) unsigncryption_key: UnifiedUnsigncryptionKey, + /// Empty for a frozen fixture, which names no scheme set. + schemes: Vec, } - /// The schemes the reader demands, empty for a frozen reader. - pub(super) fn schemes(&self) -> Vec { - match &self.unsigncryption_key.sender { - super::SenderAuth::Multi(keys) => keys.schemes(), - super::SenderAuth::Ecdsa(_) => Vec::new(), + impl SigncryptionFixture { + /// The sender's ECDSA verification key, as the frozen reader names it. + pub(crate) fn sender_verf_key(&self) -> PublicSigKey { + PublicSigKey::from_sk(self.signcryption_key.signing_key()) } - } - /// A reader that deviates from this fixture in its sender policy or its - /// receiver id, so a test can vary one of them at a time. - pub(super) fn reader_with( - &self, - sender: super::SenderAuth, - receiver_id: Vec, - ) -> UnifiedUnsigncryptionKey { - UnifiedUnsigncryptionKey { - decryption_key: self.unsigncryption_key.decryption_key.clone(), - encryption_key: self.unsigncryption_key.encryption_key.clone(), - sender, - receiver_id, + /// The schemes this fixture's reader demands. + pub(crate) fn schemes(&self) -> Vec { + self.schemes.clone() } - } -} -/// A fixture whose reader demands the frozen, single-ECDSA layout. -#[cfg(test)] -pub(super) fn signcryption_fixture(scheme: PkeSchemeType, seed: u64) -> SigncryptionFixture { - let p = fixture_parts(scheme, seed); - SigncryptionFixture { - signcryption_key: UnifiedSigncryptionKey::from_signing_key( - p.signing_key, - p.enc_key.clone(), - p.receiver_id.clone(), - ), - unsigncryption_key: UnifiedUnsigncryptionKey::new( - Arc::new(p.dec_key), - p.enc_key, - p.sender_verf_key, - p.receiver_id, - ), - rng: p.rng, + /// A reader deviating from this fixture's in its sender policy alone. + pub(crate) fn reader_for(&self, sender: SenderAuth) -> UnifiedUnsigncryptionKey { + self.reader_with(sender, self.unsigncryption_key.receiver_id.clone()) + } + + /// A reader deviating from this fixture's in its receiver id alone. + pub(crate) fn reader_to(&self, receiver_id: Vec) -> UnifiedUnsigncryptionKey { + self.reader_with(self.unsigncryption_key.sender.clone(), receiver_id) + } + + fn reader_with( + &self, + sender: SenderAuth, + receiver_id: Vec, + ) -> UnifiedUnsigncryptionKey { + UnifiedUnsigncryptionKey { + decryption_key: self.unsigncryption_key.decryption_key.clone(), + encryption_key: self.unsigncryption_key.encryption_key.clone(), + sender, + receiver_id, + } + } } -} -/// A fixture whose reader demands a signature under every scheme in `schemes`. -#[cfg(test)] -pub(super) fn composite_fixture( - scheme: PkeSchemeType, - seed: u64, - schemes: &[SigningSchemeType], -) -> SigncryptionFixture { - use crate::cryptography::signing::test_support::seeded_identity; + /// A fixture whose reader demands the frozen, single-ECDSA layout. + pub(crate) fn signcryption_fixture(scheme: PkeSchemeType, seed: u64) -> SigncryptionFixture { + let p = fixture_parts(scheme, seed); + let sender_verf_key = PublicSigKey::from_sk(&p.signing_key); + SigncryptionFixture { + signcryption_key: UnifiedSigncryptionKey::from_signing_key( + p.signing_key, + p.enc_key.clone(), + p.receiver_id.clone(), + ), + unsigncryption_key: UnifiedUnsigncryptionKey::new( + Arc::new(p.dec_key), + p.enc_key, + sender_verf_key, + p.receiver_id, + ), + schemes: Vec::new(), + rng: p.rng, + } + } - // The identity is drawn after the parts, as it was when the composite tests - // built on top of `signcryption_fixture`, so a given seed still yields the - // same keys. - let mut p = fixture_parts(scheme, seed); - let identity = Arc::new(seeded_identity(&mut p.rng)); - let keys = VerfKeySet::from_identity(&identity, schemes).unwrap(); - SigncryptionFixture { - signcryption_key: UnifiedSigncryptionKey::new( - identity, - p.enc_key.clone(), - p.receiver_id.clone(), - ), - unsigncryption_key: UnifiedUnsigncryptionKey::new_multi( - Arc::new(p.dec_key), - p.enc_key, - keys, - p.receiver_id, - ), - rng: p.rng, + /// A fixture whose reader demands a signature under every scheme in `schemes`. + pub(crate) fn composite_fixture( + scheme: PkeSchemeType, + seed: u64, + schemes: &[SigningSchemeType], + ) -> SigncryptionFixture { + // The identity is drawn after the parts, as it was when the composite tests + // built on top of `signcryption_fixture`, so a given seed still yields the + // same keys. + let mut p = fixture_parts(scheme, seed); + let identity = Arc::new(seeded_identity(&mut p.rng)); + let keys = VerfKeySet::from_identity(&identity, schemes).unwrap(); + // Canonical order, which is what the reader will demand. + let demanded = keys.schemes(); + SigncryptionFixture { + signcryption_key: UnifiedSigncryptionKey::new( + identity, + p.enc_key.clone(), + p.receiver_id.clone(), + ), + unsigncryption_key: UnifiedUnsigncryptionKey::new_multi( + Arc::new(p.dec_key), + p.enc_key, + keys, + p.receiver_id, + ), + schemes: demanded, + rng: p.rng, + } } -} -/// What both constructors draw from `seed`, in the order the fixtures have -/// always drawn it. -#[cfg(test)] -struct FixtureParts { - rng: AesRng, - sender_verf_key: PublicSigKey, - signing_key: PrivateSigKey, - dec_key: UnifiedPrivateEncKey, - enc_key: UnifiedPublicEncKey, - receiver_id: Vec, -} + /// What both constructors draw from `seed`, in the order the fixtures have + /// always drawn it. + struct FixtureParts { + rng: AesRng, + signing_key: PrivateSigKey, + dec_key: UnifiedPrivateEncKey, + enc_key: UnifiedPublicEncKey, + receiver_id: Vec, + } -#[cfg(test)] -fn fixture_parts(scheme: PkeSchemeType, seed: u64) -> FixtureParts { - let mut rng = AesRng::seed_from_u64(seed); - let (sender_verf_key, signing_key) = gen_sig_keys(&mut rng); - let (receiver_verf_key, _) = gen_sig_keys(&mut rng); - // Scoped so that `rng` is no longer borrowed once the keys are out. - let (dec_key, enc_key) = { - let mut encryption = Encryption::new(scheme, &mut rng); - encryption.keygen().unwrap() - }; - FixtureParts { - rng, - sender_verf_key, - signing_key, - dec_key, - enc_key, - receiver_id: receiver_verf_key.verf_key_id(), + fn fixture_parts(scheme: PkeSchemeType, seed: u64) -> FixtureParts { + let mut rng = AesRng::seed_from_u64(seed); + let (_sender_verf_key, signing_key) = gen_sig_keys(&mut rng); + let (receiver_verf_key, _) = gen_sig_keys(&mut rng); + // Scoped so that `rng` is no longer borrowed once the keys are out. + let (dec_key, enc_key) = { + let mut encryption = Encryption::new(scheme, &mut rng); + encryption.keygen().unwrap() + }; + FixtureParts { + rng, + signing_key, + dec_key, + enc_key, + receiver_id: receiver_verf_key.verf_key_id(), + } } } diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 8c8a2f6094..4cc288de69 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -8,9 +8,9 @@ use crate::cryptography::encryption::HasPkeScheme; use crate::cryptography::encryption::UnifiedPublicEncKey; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; -use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signatures::SigningSchemeType; +use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signcryption::UnifiedSigncryptionKey; use crate::cryptography::signcryption::UnifiedUnsigncryptionKey; @@ -168,11 +168,11 @@ pub(super) fn open( #[cfg(test)] mod tests { - use super::super::common::{composite_fixture, signcryption_fixture}; - use super::super::{SenderAuth, Signcrypt, UnifiedSigncryptionKey, Unsigncrypt}; + use super::super::common::test_support::{composite_fixture, signcryption_fixture}; + use super::super::{SenderAuth, Signcrypt, Unsigncrypt}; use super::*; use crate::cryptography::encryption::PkeSchemeType; - use crate::cryptography::signatures::{UnifiedPublicSigKey, VerfKeySet}; + use crate::cryptography::signatures::UnifiedPublicSigKey; use crate::cryptography::signing::test_support::seeded_identity; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; @@ -189,18 +189,17 @@ mod tests { fn round_trip() { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { let mut f = composite_fixture(scheme, 100, &pair()); - let cipher = seal( - &f.signcryption_key, - &mut f.rng, - DSEP, - &f.schemes(), - b"a composite message", - ) - .unwrap(); + let schemes = f.schemes(); + let msg = TestType { i: 4711 }; + + let cipher = f + .signcryption_key + .signcrypt_composite(&mut f.rng, DSEP, &schemes, &msg) + .unwrap(); assert_eq!(cipher.pke_type, scheme); - let opened = f.unsigncryption_key.open(DSEP, &cipher).unwrap(); - assert_eq!(&*opened, b"a composite message", "{scheme}"); + let opened: TestType = f.unsigncryption_key.unsigncrypt(DSEP, &cipher).unwrap(); + assert_eq!(opened, msg, "{scheme}"); } } @@ -230,46 +229,41 @@ mod tests { // ...and the same signcryption opens for a verifier that asked for // exactly what was signed, confirming the rejection above is the policy // check and not an unrelated failure. - let weaker_keys = - VerfKeySet::from_identity(&f.signcryption_key.identity, &weaker).unwrap(); + let weaker_keys = VerfKeySet::from_identity(&f.signcryption_key.identity, &weaker).unwrap(); let opened = f - .reader_with( - SenderAuth::Multi(weaker_keys), - f.signcryption_key.receiver_id.clone(), - ) + .reader_for(SenderAuth::Multi(weaker_keys)) .open(DSEP, &cipher) .unwrap(); assert_eq!(&*opened, b"downgrade me"); } - /// Neither reader accepts the other's envelope. - /// The single-ECDSA set holds the very key that signed the envelope and must still refuse - /// it, because the payload is a safe-serialized KEM ciphertext that its - /// bincode parse rejects before anything is decrypted. + /// A frozen reader must refuse a composite envelope even when it holds the + /// very ECDSA key that signed it: the payload is a safe-serialized KEM + /// ciphertext that its bincode parse rejects before anything is decrypted. #[test] - fn the_two_formats_do_not_cross() { + fn a_frozen_reader_rejects_a_composite_envelope() { for (seed, schemes) in [ (300_u64, pair()), (400_u64, vec![SigningSchemeType::Ecdsa256k1]), ] { let mut f = composite_fixture(PkeSchemeType::MlKem512, seed, &schemes); + let demanded = f.schemes(); let composite = seal( &f.signcryption_key, &mut f.rng, DSEP, - &f.schemes(), + &demanded, b"composite payload", ) .unwrap(); - // The envelope does open for the reader it was made for, so each + // The envelope does open for the reader it was made for, so the // rejection below is the format mismatch and not a broken fixture. assert_eq!( &*f.unsigncryption_key.open(DSEP, &composite).unwrap(), b"composite payload" ); - // Frozen reader, handed a composite envelope. let legacy_ecdsa = match &f.unsigncryption_key.sender { SenderAuth::Multi(keys) => { match keys.require(SigningSchemeType::Ecdsa256k1).unwrap() { @@ -280,42 +274,45 @@ mod tests { SenderAuth::Ecdsa(_) => unreachable!("the fixture reader is a composite one"), }; let err = f - .reader_with( - SenderAuth::Ecdsa(legacy_ecdsa), - f.signcryption_key.receiver_id.clone(), - ) + .reader_for(SenderAuth::Ecdsa(legacy_ecdsa)) .unsigncrypt::(DSEP, &composite) .unwrap_err(); assert!( matches!(err, CryptographyError::BincodeError(_)), "the frozen reader must reject a composite envelope on the KEM ciphertext, got: {err}" ); - - // Composite reader, handed a frozen envelope. The frozen layout - // writes its `HybridKemCt` with bincode and no header, so - // `safe_deserialize` refuses it — again before any decryption. - let mut frozen_f = signcryption_fixture(PkeSchemeType::MlKem512, seed); - let frozen = frozen_f - .signcryption_key - .signcrypt(&mut frozen_f.rng, DSEP, &TestType { i: 7 }) - .unwrap(); - let err = f.unsigncryption_key.open(DSEP, &frozen).unwrap_err(); - assert!( - matches!(err, CryptographyError::SerializationError(_)), - "the composite reader must reject a frozen envelope on deserialization, got: {err}" - ); } } + /// ...and the converse. The frozen layout writes its `HybridKemCt` with + /// bincode and no header, so `safe_deserialize` refuses it, again before any + /// decryption. Independent of which schemes the reader demands. + #[test] + fn a_composite_reader_rejects_a_frozen_envelope() { + let f = composite_fixture(PkeSchemeType::MlKem512, 300, &pair()); + let mut frozen_f = signcryption_fixture(PkeSchemeType::MlKem512, 300); + let frozen = frozen_f + .signcryption_key + .signcrypt(&mut frozen_f.rng, DSEP, &TestType { i: 7 }) + .unwrap(); + + let err = f.unsigncryption_key.open(DSEP, &frozen).unwrap_err(); + assert!( + matches!(err, CryptographyError::SerializationError(_)), + "the composite reader must reject a frozen envelope on deserialization, got: {err}" + ); + } + /// Opening fails on every deviation from what was sealed. #[test] fn open_rejects_any_deviation_from_what_was_sealed() { let mut f = composite_fixture(PkeSchemeType::MlKem512, 500, &pair()); + let demanded = f.schemes(); let cipher = seal( &f.signcryption_key, &mut f.rng, DSEP, - &f.schemes(), + &demanded, b"bound to one sender and one receiver", ) .unwrap(); @@ -337,25 +334,18 @@ mod tests { ); let mut rng = AesRng::seed_from_u64(999); - let other_keys = - VerfKeySet::from_identity(&seeded_identity(&mut rng), &f.schemes()).unwrap(); + let other_keys = VerfKeySet::from_identity(&seeded_identity(&mut rng), &demanded).unwrap(); assert!( - f.reader_with( - SenderAuth::Multi(other_keys), - f.signcryption_key.receiver_id.clone() - ) - .open(DSEP, &cipher) - .is_err(), + f.reader_for(SenderAuth::Multi(other_keys)) + .open(DSEP, &cipher) + .is_err(), "another party's key set opened the envelope" ); assert!( - f.reader_with( - f.unsigncryption_key.sender.clone(), - b"a different receiver".to_vec() - ) - .open(DSEP, &cipher) - .is_err(), + f.reader_to(b"a different receiver".to_vec()) + .open(DSEP, &cipher) + .is_err(), "a different receiver id opened the envelope" ); } @@ -375,7 +365,7 @@ mod tests { b"no seed here", ) .unwrap_err(); - assert!(matches!(err, CryptographyError::SigningError(_)), "{err}"); + assert!(matches!(err, CryptographyError::Signing(_)), "{err}"); } /// ...but ECDSA alone needs no seed, so a seedless node can still write a @@ -396,13 +386,9 @@ mod tests { ) .unwrap(); - let keys = - VerfKeySet::from_identity(&f.signcryption_key.identity, &ecdsa_only).unwrap(); + let keys = VerfKeySet::from_identity(&f.signcryption_key.identity, &ecdsa_only).unwrap(); let opened = f - .reader_with( - SenderAuth::Multi(keys), - f.signcryption_key.receiver_id.clone(), - ) + .reader_for(SenderAuth::Multi(keys)) .open(DSEP, &cipher) .unwrap(); assert_eq!(&*opened, b"one signature"); diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 8e0b1f4db3..48c5fe659f 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -194,7 +194,7 @@ pub(crate) fn insecure_decrypt_ignoring_signature( #[cfg(test)] mod tests { use super::super::Signcrypt; - use super::super::common::signcryption_fixture; + use super::super::common::test_support::signcryption_fixture; use super::*; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::encryption::PkeSchemeType; @@ -204,27 +204,41 @@ mod tests { use rand::SeedableRng; use tfhe::safe_serialization::safe_serialize; + /// `parse_msg` rejects a plaintext that is not `msg ‖ sig ‖ H(sender key)`, on + /// both of the things it can check: the length, and the key the tail names. #[test] - fn incorrect_server_verf_key() { + fn parse_msg_rejects_a_malformed_plaintext() { let mut rng = AesRng::seed_from_u64(42); let (server_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); - let to_encrypt = [0_u8; 1 + DIGEST_BYTES + SIG_SIZE]; - // Keep test input under the zeroizing ownership contract. - let res = parse_msg(Zeroizing::new(to_encrypt.to_vec()), &server_verf_key); - // unwrapping fails - assert!(res.is_err()); + + // Too short to hold the two fixed tail fields: a length error, not a panic. + for len in 0..(SIG_SIZE + DIGEST_BYTES) { + // Keep test input under the zeroizing ownership contract. + let short = Zeroizing::new(vec![0u8; len]); + assert!( + matches!( + parse_msg(short, &server_verf_key), + Err(CryptographyError::LengthError(_)) + ), + "a {len}-byte plaintext must be rejected as too short" + ); + } + + // Long enough, but the tail names some other sender. + let attributed_elsewhere = Zeroizing::new(vec![0u8; 1 + DIGEST_BYTES + SIG_SIZE]); + let err = parse_msg(attributed_elsewhere, &server_verf_key).unwrap_err(); assert!( - res.unwrap_err() - .to_string() - .contains("unexpected verification key digest") + err.to_string() + .contains("unexpected verification key digest"), + "{err}" ); } /// The encrypted plaintext is exactly `msg ‖ sig(64) ‖ H(sender key)(32)`, /// with the two tail fields fixed-size and in that order. /// - /// This is the contract [`parse_msg`] relies on when it recovers `msg_len` - /// by subtracting from the end. + /// This is the contract [`split_frozen_plaintext`] relies on when it recovers + /// `msg_len` by subtracting from the end. #[test] fn ecdsa_v0_envelope_layout_is_locked() { const DSEP: &DomainSep = b"ECDSAV0T"; @@ -243,7 +257,11 @@ mod tests { assert_eq!(cipher.pke_type, scheme); let kem_ct: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload).unwrap(); - let plaintext = f.unsigncryption_key.decryption_key.hybrid_decrypt(kem_ct).unwrap(); + let plaintext = f + .unsigncryption_key + .decryption_key + .hybrid_decrypt(kem_ct) + .unwrap(); // Exactly three fields, the last two of fixed size. assert_eq!( @@ -283,23 +301,6 @@ mod tests { } } - /// Truncating the plaintext below the two fixed tail fields is a length - /// error, not a panic. - #[test] - fn a_short_plaintext_is_a_length_error() { - let f = signcryption_fixture(PkeSchemeType::MlKem512, 400); - for len in 0..(SIG_SIZE + DIGEST_BYTES) { - let short = Zeroizing::new(vec![0u8; len]); - assert!( - matches!( - parse_msg(short, &f.sender_verf_key()), - Err(CryptographyError::LengthError(_)) - ), - "a {len}-byte plaintext must be rejected as too short" - ); - } - } - /// The binding must separate recipients on *both* of its inputs, since it is /// the only thing tying a signature to who may open it. #[test] diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 9d4147610d..23584d405b 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -552,237 +552,130 @@ pub struct UnifiedSigncryptionKeyPairOwned { #[cfg(test)] mod tests { - use core::panic; + use super::common::test_support::signcryption_fixture; use super::*; - use crate::cryptography::{ - encryption::{Encryption, PkeScheme, PkeSchemeType}, - signatures::gen_sig_keys, - }; + use crate::cryptography::encryption::PkeSchemeType; + use crate::cryptography::signatures::gen_sig_keys; use crate::vault::storage::tests::TestType; - use aes_prng::AesRng; - use rand::SeedableRng; use tfhe::FheTypes; - /// Helper method that creates an rng, a valid client request (on a dummy fhe cipher) and client - /// signcryption keys SigncryptionPair Returns the rng, client request, client signcryption - /// keys and the dummy fhe cipher the request is made for. - fn test_setup() -> (AesRng, UnifiedSigncryptionKeyPairOwned) { - let mut rng = AesRng::seed_from_u64(1); - let (client_verf_key, _) = gen_sig_keys(&mut rng); - let keys = - ephemeral_signcryption_key_generation(&mut rng, &client_verf_key.verf_key_id(), None); - (rng, keys) - } + const DSEP: &DomainSep = b"TESTTEST"; - fn test_setup_with_scheme(scheme: PkeSchemeType) -> (AesRng, UnifiedSigncryptionKeyPairOwned) { - let mut rng = AesRng::seed_from_u64(1); - let (client_verf_key, _) = gen_sig_keys(&mut rng); - let (server_verf_key, server_sig_key) = gen_sig_keys(&mut rng); - let mut encryption = Encryption::new(scheme, &mut rng); - let (dec_key, enc_key) = encryption.keygen().unwrap(); - let receiver_id = client_verf_key.verf_key_id(); - let keys = UnifiedSigncryptionKeyPairOwned { - signcrypt_key: UnifiedSigncryptionKey::from_signing_key( - server_sig_key, - enc_key.clone(), - receiver_id.clone(), - ), - unsigncryption_key: UnifiedUnsigncryptionKey::new( - Arc::new(dec_key), - enc_key, - server_verf_key, - receiver_id, - ), - }; - (rng, keys) - } - - /// Round-trips under every PKE scheme signcryption supports. + /// Round-trips under every PKE scheme signcryption supports, across the + /// bincode encoding the rest of the KMS moves a `UnifiedSigncryption` in. #[test] fn sunshine() { for scheme in [PkeSchemeType::MlKem512, PkeSchemeType::MlKem1024P384] { - let (mut rng, keys) = test_setup_with_scheme(scheme); + let mut f = signcryption_fixture(scheme, 1); let msg = TestType { i: 1333 }; - let cipher = keys - .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) + let cipher = f + .signcryption_key + .signcrypt(&mut f.rng, DSEP, &msg) .unwrap(); assert_eq!(cipher.pke_type, scheme); - let decrypted_msg = keys - .unsigncryption_key - .unsigncrypt(b"TESTTEST", &cipher) - .unwrap(); + let encoded = bc2wrap::serialize(&cipher).unwrap(); + let cipher: UnifiedSigncryption = bc2wrap::deserialize_slice(&encoded).unwrap(); + + let decrypted_msg = f.unsigncryption_key.unsigncrypt(DSEP, &cipher).unwrap(); assert_eq!(msg, decrypted_msg, "{scheme}"); } } + /// A ciphertext written under one KEM does not open under another. This is the + /// check `UnifiedUnsigncryptionKey::open` makes before it touches the payload. #[test] fn mlkem1024_p384_cipher_is_rejected_by_an_ml_kem_512_key() { - let (mut rng, p384_keys) = test_setup_with_scheme(PkeSchemeType::MlKem1024P384); - let (_, ml_kem_512_keys) = test_setup(); - let msg = TestType { i: 1333 }; - let cipher = p384_keys - .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) + let mut p384 = signcryption_fixture(PkeSchemeType::MlKem1024P384, 1); + let ml_kem_512 = signcryption_fixture(PkeSchemeType::MlKem512, 1); + let cipher = p384 + .signcryption_key + .signcrypt(&mut p384.rng, DSEP, &TestType { i: 1333 }) .unwrap(); - let err = ml_kem_512_keys + let err = ml_kem_512 .unsigncryption_key - .unsigncrypt::(b"TESTTEST", &cipher) + .unsigncrypt::(DSEP, &cipher) .unwrap_err(); - assert!(matches!(err, CryptographyError::VerificationError(_))); - } - - #[test] - fn sunshine_encoding_decoding() { - // test the bincode serialization because that is what we use for all of kms - let (mut rng, client_signcryption_keys) = test_setup(); - let msg = TestType { i: 1333 }; - let cipher = client_signcryption_keys - .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) - .unwrap(); - let serialized_cipher = bc2wrap::serialize(&cipher).unwrap(); - let deserialized_cipher: UnifiedSigncryption = - bc2wrap::deserialize_slice(&serialized_cipher).unwrap(); - - let sender_verf_key = match &client_signcryption_keys.unsigncryption_key.sender { - SenderAuth::Ecdsa(verf_key) => Some(verf_key), - SenderAuth::Multi(_) => panic!("the test reader is a frozen one"), - }; - - let serialized_server_verf_key = bc2wrap::serialize(&sender_verf_key).unwrap(); - let deserialized_server_verf_key: PublicSigKey = - bc2wrap::deserialize_slice(&serialized_server_verf_key).unwrap(); - let client_id = client_signcryption_keys - .unsigncryption_key - .receiver_id - .clone(); - let new_keys = UnifiedUnsigncryptionKey::new( - client_signcryption_keys - .unsigncryption_key - .decryption_key - .clone(), - client_signcryption_keys - .unsigncryption_key - .encryption_key - .clone(), - deserialized_server_verf_key, - client_id, + assert!( + matches!(err, CryptographyError::VerificationError(_)), + "{err}" ); - let decrypted_msg = new_keys - .unsigncrypt(b"TESTTEST", &deserialized_cipher) - .unwrap(); - assert_eq!(msg, decrypted_msg); } + /// Opening fails on every deviation from what was sealed. #[test] fn bad_signcryption() { - let (mut rng, client_signcryption_keys) = test_setup(); + let mut f = signcryption_fixture(PkeSchemeType::MlKem512, 1); let msg = TestType { i: 1333 }; - let correct_cipher = client_signcryption_keys - .signcrypt_key - .signcrypt(&mut rng, b"TESTTEST", &msg) + let correct_cipher = f + .signcryption_key + .signcrypt(&mut f.rng, DSEP, &msg) .unwrap(); // flip a bit in the payload { let mut cipher = correct_cipher.clone(); cipher.payload[0] ^= 1; - assert!( - client_signcryption_keys - .unsigncryption_key - .unsigncrypt::(b"TESTTEST", &cipher) + f.unsigncryption_key + .unsigncrypt::(DSEP, &cipher) .is_err() ); } - // wrong scheme + // use the wrong receiver decryption key, leaving the rest of the reader + // alone so that nothing but the key differs { - let mut cipher = correct_cipher.clone(); - cipher.pke_type = PkeSchemeType::MlKem1024; - assert!( - client_signcryption_keys - .unsigncryption_key - .unsigncrypt::(b"TESTTEST", &cipher) - .is_err() - ); - } - - // use the wrong client signcryption key - { - let mut rng = AesRng::seed_from_u64(2); - let wrong_keys = ephemeral_signcryption_key_generation( - &mut rng, - &client_signcryption_keys.unsigncryption_key.receiver_id, - Some(client_signcryption_keys.signcrypt_key.signing_key()), - ); + let other = signcryption_fixture(PkeSchemeType::MlKem512, 2); + let wrong_reader = UnifiedUnsigncryptionKey { + decryption_key: other.unsigncryption_key.decryption_key, + ..f.unsigncryption_key.clone() + }; assert!( - wrong_keys - .unsigncryption_key - .unsigncrypt::(b"TESTTEST", &correct_cipher) + wrong_reader + .unsigncrypt::(DSEP, &correct_cipher) .is_err() ); } - // use the wrong server key + // use the wrong sender verification key { - let mut rng = AesRng::seed_from_u64(2); - let (wrong_verf_key, _) = gen_sig_keys(&mut rng); - let wrong_keys = UnifiedUnsigncryptionKey::new( - client_signcryption_keys - .unsigncryption_key - .decryption_key - .clone(), - client_signcryption_keys - .unsigncryption_key - .encryption_key - .clone(), - wrong_verf_key, - client_signcryption_keys - .unsigncryption_key - .receiver_id - .clone(), - ); + let (wrong_verf_key, _) = gen_sig_keys(&mut f.rng); assert!( - wrong_keys - .unsigncrypt::(b"TESTTEST", &correct_cipher) + f.reader_for(SenderAuth::Ecdsa(wrong_verf_key)) + .unsigncrypt::(DSEP, &correct_cipher) .is_err() ); } // use bad domain separator - { - assert!( - client_signcryption_keys - .unsigncryption_key - .unsigncrypt::(b"blahblah", &correct_cipher) - .is_err() - ); - } + assert!( + f.unsigncryption_key + .unsigncrypt::(b"blahblah", &correct_cipher) + .is_err() + ); // happy path should still work at the end - let decrypted_msg = client_signcryption_keys + let decrypted_msg = f .unsigncryption_key - .unsigncrypt::(b"TESTTEST", &correct_cipher) + .unsigncrypt::(DSEP, &correct_cipher) .unwrap(); assert_eq!(msg, decrypted_msg); } #[test] fn signcryption_with_bad_link() { - let (mut rng, client_signcryption_keys) = test_setup(); + let mut f = signcryption_fixture(PkeSchemeType::MlKem512, 1); let link = vec![0, 1, 2, 3u8]; - let cipher = client_signcryption_keys - .signcrypt_key - .signcrypt_plaintext(&mut rng, b"TESTTEST", &[1], FheTypes::Bool, &link) + let cipher = f + .signcryption_key + .signcrypt_plaintext(&mut f.rng, DSEP, &[1], FheTypes::Bool, &link) .unwrap(); let bad_link = vec![1, 2, 3, 4u8]; - let _ = client_signcryption_keys + let _ = f .unsigncryption_key - .unsigncrypt_plaintext(b"TESTTEST", &cipher.payload, &bad_link) + .unsigncrypt_plaintext(DSEP, &cipher.payload, &bad_link) .unwrap_err(); } From 473f24685d1f78018dd18fe7681ee01c838afa28 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 16:03:58 +0200 Subject: [PATCH 070/114] fix: fmt --- .../src/cryptography/signcryption/ecdsa_v0.rs | 8 ++++++-- .../threshold/service/user_decryptor.rs | 7 ++----- .../tests/backward_compatibility_kms.rs | 20 ++++++++++--------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index 48c5fe659f..af90e2fe6e 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -61,7 +61,9 @@ pub(super) fn seal( let to_encrypt = Zeroizing::new([msg, sig.to_bytes().as_ref(), verf_key_hash.as_ref()].concat()); - let ciphertext = signcrypt_key.receiver_enc_key.hybrid_encrypt(rng, &to_encrypt)?; + let ciphertext = signcrypt_key + .receiver_enc_key + .hybrid_encrypt(rng, &to_encrypt)?; // LEGACY: approach to serialization Ok(UnifiedSigncryption::new( bc2wrap::serialize(&ciphertext) @@ -82,7 +84,9 @@ pub(super) fn open( // LEGACY Code: should be using safe_deserialization from tfhe-rs let deserialized_payload: HybridKemCt = bc2wrap::deserialize_slice(&cipher.payload) .map_err(|e| CryptographyError::BincodeError(e.to_string()))?; - let decrypted_plaintext = unsign_key.decryption_key.hybrid_decrypt(deserialized_payload)?; + let decrypted_plaintext = unsign_key + .decryption_key + .hybrid_decrypt(deserialized_payload)?; let (msg, sig) = parse_msg(decrypted_plaintext, sender_verf_key)?; check_format_and_signature(dsep, &msg, &sig, unsign_key, sender_verf_key)?; Ok(msg) diff --git a/core/service/src/engine/threshold/service/user_decryptor.rs b/core/service/src/engine/threshold/service/user_decryptor.rs index 0e01229ad4..c616bb8c03 100644 --- a/core/service/src/engine/threshold/service/user_decryptor.rs +++ b/core/service/src/engine/threshold/service/user_decryptor.rs @@ -571,11 +571,8 @@ impl< tonic::Code::Internal, ) })?; - let signcryption_key = UnifiedSigncryptionKey::new( - identity.clone(), - client_enc_key, - client_address.to_vec(), - ); + let signcryption_key = + UnifiedSigncryptionKey::new(identity.clone(), client_enc_key, client_address.to_vec()); // the result of the computation is tracked the tracker let session_maker = self.session_maker.clone(); diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index 54363bf9ed..7bb7fec537 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -752,8 +752,11 @@ fn test_unified_signcryption( let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); let (dec_key, enc_key) = encryption.keygen().unwrap(); let receiver_id = client_verf_key.verf_key_id(); - let signcrypt_key = - UnifiedSigncryptionKey::from_signing_key(server_sig_key, enc_key.clone(), receiver_id.clone()); + let signcrypt_key = UnifiedSigncryptionKey::from_signing_key( + server_sig_key, + enc_key.clone(), + receiver_id.clone(), + ); let new_versionized = signcrypt_key .signcrypt(&mut rng, b"TESTTEST", &verf_key) .unwrap(); @@ -772,13 +775,12 @@ fn test_unified_signcryption( // whether a node can still open material an earlier release produced. The // frozen layout carries no version tag and is recovered by subtracting two // fixed-size tail fields, so a change there is silent until something tries. - let unsign_key = - UnifiedUnsigncryptionKey::new( - std::sync::Arc::new(dec_key), - enc_key, - verf_key.clone(), - receiver_id, - ); + let unsign_key = UnifiedUnsigncryptionKey::new( + std::sync::Arc::new(dec_key), + enc_key, + verf_key.clone(), + receiver_id, + ); let opened: PublicSigKey = unsign_key .unsigncrypt(b"TESTTEST", &original_versionized) .map_err(|e| { From 9774e1c9b7d71a7919d901337320737b88a5a763 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 16:23:41 +0200 Subject: [PATCH 071/114] fix: renaming of verify_uniform --- .../cryptography/signcryption/composite_v1.rs | 9 ++-- .../src/cryptography/signcryption/ecdsa_v0.rs | 1 + .../src/cryptography/signing/composite.rs | 45 +++++++++---------- .../src/cryptography/signing/verf_key_set.rs | 2 +- 4 files changed, 27 insertions(+), 30 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 4cc288de69..01f215ba50 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -15,8 +15,8 @@ use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; use crate::cryptography::signcryption::UnifiedSigncryptionKey; use crate::cryptography::signcryption::UnifiedUnsigncryptionKey; #[cfg(feature = "non-wasm")] -use crate::cryptography::signing::composite::sign_uniform; -use crate::cryptography::signing::composite::verify_uniform; +use crate::cryptography::signing::composite::sign_composite; +use crate::cryptography::signing::composite::verify_composite; #[cfg(feature = "non-wasm")] use crate::cryptography::zeroizing_writer::ZeroizingWriter; use hashing::DomainSep; @@ -112,9 +112,8 @@ pub(super) fn seal( let receiver_enc_key = &signcrypt_key.receiver_enc_key; let mut signed = SigncryptionSignedPayload::new(msg, &signcrypt_key.receiver_id, receiver_enc_key)?; - let signature = sign_uniform(&signcrypt_key.identity, schemes, dsep, &signed); + let signature = sign_composite(&signcrypt_key.identity, schemes, dsep, &signed)?; signed.zeroize(); - let signature = signature?; let mut envelope = CompositeEnvelope { msg: msg.to_vec(), @@ -159,7 +158,7 @@ pub(super) fn open( let mut signed = SigncryptionSignedPayload::new(&msg, &unsign_key.receiver_id, &unsign_key.encryption_key)?; - let verified = verify_uniform(&envelope.signature, sender_keys, dsep, &signed); + let verified = verify_composite(&envelope.signature, sender_keys, dsep, &signed); signed.zeroize(); verified.map_err(|e| CryptographyError::VerificationError(e.to_string()))?; diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index af90e2fe6e..f01ddde2e9 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -94,6 +94,7 @@ pub(super) fn open( /// Split a decrypted frozen-layout plaintext into `msg`, `sig` and /// `H(sender verification key)`. +#[allow(clippy::type_complexity)] fn split_frozen_plaintext(plaintext: &[u8]) -> Result<(&[u8], &[u8], &[u8]), CryptographyError> { // The plaintext contains msg || sig || H(server_verification_key) let msg_len = plaintext diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index b40f333572..20b087952e 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -49,9 +49,9 @@ //! //! # What the encoding gives, and what it does not //! -//! - Verification is all or nothing. [`verify_uniform`] requires the set of +//! - Verification is all or nothing. [`verify_composite`] requires the set of //! entries to equal the set of keys, then checks every entry. -//! - Every component of a [`sign_uniform`] signature names its scheme set and the +//! - Every component of a [`sign_composite`] signature names its scheme set and the //! type of what it signs, so none of them moves to another set, to another //! usage, or out of the composite. //! - The composite is unforgeable if any one component is, as in the draft. @@ -149,12 +149,8 @@ pub fn entry_schemes(entries: &[StoredTypedSignature]) -> Vec } /// Sign `payload` under every scheme in `schemes`, each over the same bytes. -/// -/// The entries come back ordered by scheme, with no duplicate scheme, which is -/// the shape [`verify_uniform`] requires. `verify_uniform` has to be called with -/// an equal `payload` of the same type. #[cfg(feature = "non-wasm")] -pub fn sign_uniform( +pub fn sign_composite( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], dsep: &DomainSep, @@ -185,7 +181,7 @@ where /// Every signature must verify. `entries` is untrusted: it may come straight /// from storage or from the network, so its shape is checked here rather than /// assumed. -pub fn verify_uniform( +pub fn verify_composite( entries: &[StoredTypedSignature], keys: &VerfKeySet, dsep: &DomainSep, @@ -315,12 +311,12 @@ mod tests { #[test] fn round_trip_sunshine() { let (identity, keys, schemes) = setup(1); - let sig = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); + let sig = sign_composite(&identity, &schemes, DSEP, &msg()).unwrap(); assert_eq!(entry_schemes(&sig), schemes); - verify_uniform(&sig, &keys, DSEP, &msg()).unwrap(); + verify_composite(&sig, &keys, DSEP, &msg()).unwrap(); let (_, other_keys, _) = setup(7); - assert!(verify_uniform(&sig, &other_keys, DSEP, &msg()).is_err()); + assert!(verify_composite(&sig, &other_keys, DSEP, &msg()).is_err()); } /// Removing a signature must not leave something that verifies under the @@ -329,14 +325,14 @@ mod tests { #[test] fn a_stripped_signature_is_rejected() { let (identity, keys, schemes) = setup(2); - let sig = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); + let sig = sign_composite(&identity, &schemes, DSEP, &msg()).unwrap(); // Drop the ML-DSA half and relabel the set as ECDSA-only let stripped = vec![sig[0].clone()]; // Against the original policy it is the wrong scheme set... assert!(matches!( - verify_uniform(&stripped, &keys, DSEP, &msg()), + verify_composite(&stripped, &keys, DSEP, &msg()), Err(SigningError::UnexpectedSchemeSet { .. }) )); @@ -346,13 +342,13 @@ mod tests { // *pair*, which does not match the single-scheme preimage. let ecdsa_only = VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); - assert!(verify_uniform(&stripped, &ecdsa_only, DSEP, &msg()).is_err()); + assert!(verify_composite(&stripped, &ecdsa_only, DSEP, &msg()).is_err()); // The same mismatch from the other side: the *whole* pair signature // against that ECDSA-only key set is refused for its shape rather than // verified on the one entry the set holds a key for. assert!(matches!( - verify_uniform(&sig, &ecdsa_only, DSEP, &msg()), + verify_composite(&sig, &ecdsa_only, DSEP, &msg()), Err(SigningError::UnexpectedSchemeSet { .. }) )); } @@ -361,7 +357,7 @@ mod tests { #[test] fn a_non_canonical_entry_list_is_rejected() { let (identity, keys, schemes) = setup(3); - let sig = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); + let sig = sign_composite(&identity, &schemes, DSEP, &msg()).unwrap(); let mut reversed = sig.clone(); reversed.reverse(); @@ -374,7 +370,7 @@ mod tests { ] { assert!( matches!( - verify_uniform(&entries, &keys, DSEP, &msg()), + verify_composite(&entries, &keys, DSEP, &msg()), Err(SigningError::UnexpectedSchemeSet { .. }) ), "a {case} entry list was not rejected" @@ -386,13 +382,13 @@ mod tests { #[test] fn one_tampered_signature_fails_the_composite() { let (identity, keys, schemes) = setup(4); - let base = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); + let base = sign_composite(&identity, &schemes, DSEP, &msg()).unwrap(); for index in 0..base.len() { let mut tampered = base.clone(); tampered[index].signature[0] ^= 0x01; assert!( - verify_uniform(&tampered, &keys, DSEP, &msg()).is_err(), + verify_composite(&tampered, &keys, DSEP, &msg()).is_err(), "tampering with signature {index} was not detected" ); } @@ -401,9 +397,9 @@ mod tests { #[test] fn a_tampered_message_or_dsep_fails() { let (identity, keys, schemes) = setup(5); - let sig = sign_uniform(&identity, &schemes, DSEP, &msg()).unwrap(); - assert!(verify_uniform(&sig, &keys, DSEP, &TestType { i: 4712 }).is_err()); - assert!(verify_uniform(&sig, &keys, b"OTHERDSP", &msg()).is_err()); + let sig = sign_composite(&identity, &schemes, DSEP, &msg()).unwrap(); + assert!(verify_composite(&sig, &keys, DSEP, &TestType { i: 4712 }).is_err()); + assert!(verify_composite(&sig, &keys, b"OTHERDSP", &msg()).is_err()); } /// An identity with no root seed can only do ECDSA, so asking it for the @@ -413,7 +409,7 @@ mod tests { let mut rng = AesRng::seed_from_u64(9); let identity = NodeSigningIdentity::ecdsa_only(gen_sig_keys(&mut rng).1); assert!(matches!( - sign_uniform(&identity, &pair(), DSEP, &msg()), + sign_composite(&identity, &pair(), DSEP, &msg()), Err(SigningError::MissingRootSeed(_)) )); } @@ -458,7 +454,8 @@ mod tests { } } - /// Result entries use the same ordering convention as [`sign_uniform`]: + /// Result entries use the same ordering convention as [`sign_composite + ///`]: /// by scheme, duplicate-free, whatever order the request arrived in. #[test] fn result_entries_are_ordered_by_scheme() { diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 46971a58b6..a4a7e0c147 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -222,7 +222,7 @@ mod tests { let bytes = bc2wrap::serialize(&misfiled).unwrap(); assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); - // The empty set, which would make `verify_uniform` accept a signature + // The empty set, which would make `verify_composite` accept a signature // list having checked nothing. let empty = BTreeMap::::new(); assert!(matches!( From 57c2f8fb768f955d11a720499fae23d88be72850 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 16:40:32 +0200 Subject: [PATCH 072/114] docs: removed incorrect doc --- core/service/src/cryptography/signing/mod.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index 60bc240608..cbaf2ee679 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -1,8 +1,3 @@ -//! Multi-scheme signing support (issue #3078). -//! -//! Every backend signs `dsep ‖ msg` and applies its own normalization/encoding -//! internally. - pub mod composite; pub mod ecdsa; mod eddsa; From b9d699d62b9d0dfe26bbe97eda8947037597982b Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 17:22:47 +0200 Subject: [PATCH 073/114] chore: minor typos --- ai-docs/ARCHITECTURE.md | 5 ++--- core/service/src/engine/base.rs | 5 ----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index 21939d27da..d8d1382f22 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -590,9 +590,8 @@ Legacy metadata has no digest, so its public objects receive a raw presence chec `Eip712Domain` that arrives from a gRPC request. At boot, current private keygen and CRS metadata with a stored domain reconstruct their signed Solidity payload and must recover the node's signing address. Older metadata versions upgrade with no domain and stay unverifiable. The -entries of the other schemes sign the serialized result payload (`keygen_payload_bytes`, -`crs_payload_bytes`) inside a composite preimage, which binds the canonical scheme set and the -role and needs no domain, so they are checked for every current entry. A node that cannot derive a scheme's key, because it holds no +entries of the other schemes sign the serialized result payload inside a composite preimage, which binds the canonical scheme set and the +role. A node that cannot derive a scheme's key, because it holds no root seed, fails boot on such an entry rather than passing it over. `PubDataType::DecompressionKey` has no private-storage counterpart at all diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index 76eba44f05..8a4e27036f 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -2138,11 +2138,6 @@ pub(crate) mod tests { .is_err(), "{scheme:?} must sign the payload, not the EIP-712 hash" ); - // ...and not the unprefixed payload either. - assert!( - unified_verify(&DSEP_PUBDATA_CRS, &signed_payload, &sig, &vk).is_err(), - "{scheme:?} CRS signature must be bound to the scheme set" - ); assert!( unified_verify(&DSEP_PUBDATA_CRS, b"tampered", &sig, &vk).is_err(), "{scheme:?} verified a tampered payload" From 73f824461efd5f688dc8d0419dbbbfe92c609df4 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 17:49:39 +0200 Subject: [PATCH 074/114] chore: pruning --- .../generate-v0.15.0/Cargo.toml | 16 +++++----- .../src/cryptography/signcryption/common.rs | 7 ---- .../cryptography/signcryption/composite_v1.rs | 32 +++++++++---------- .../src/cryptography/signcryption/ecdsa_v0.rs | 6 ++-- .../src/cryptography/signcryption/mod.rs | 5 --- .../src/cryptography/signing/composite.rs | 23 ++----------- .../src/cryptography/signing/verf_key_set.rs | 4 --- core/service/src/engine/signed_payload.rs | 2 -- core/service/tests/versioned_enum_coverage.rs | 2 +- 9 files changed, 30 insertions(+), 67 deletions(-) diff --git a/backward-compatibility/generate-v0.15.0/Cargo.toml b/backward-compatibility/generate-v0.15.0/Cargo.toml index 639d65b2f7..261f9bb538 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.toml +++ b/backward-compatibility/generate-v0.15.0/Cargo.toml @@ -9,7 +9,7 @@ license = "BSD-3-Clause-Clear" [dependencies] # Import the base backward-compatibility crate for shared types and utilities backward-compatibility = { path = ".." } -bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } +bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev = "b9d699d62b9d0dfe26bbe97eda8947037597982b" } # NOTE: Some dependencies below are duplicated from ../Cargo.toml # This is intentional - these crates are excluded from the workspace for isolation. @@ -19,13 +19,13 @@ bc2wrap = { git = "https://github.com/zama-ai/kms.git", package = "bc2wrap", rev # NOTE: v0.15.0 is not tagged yet, so we pin a commit rev on `main`. Bump this rev to a # commit that contains every type this generator serializes, otherwise the generator will # not compile. -kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } -kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } -algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } -threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01", features = ["testing"] } -threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } -threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } -hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "bce6ee2e8d9e10a20d077862a177a5784aebba01" } +kms_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms", rev = "b9d699d62b9d0dfe26bbe97eda8947037597982b" } +kms_grpc_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "kms-grpc", rev = "b9d699d62b9d0dfe26bbe97eda8947037597982b" } +algebra_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-algebra", default-features = false, rev = "b9d699d62b9d0dfe26bbe97eda8947037597982b" } +threshold_execution_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-execution", default-features = false, rev = "b9d699d62b9d0dfe26bbe97eda8947037597982b", features = ["testing"] } +threshold_networking_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-networking", default-features = false, rev = "b9d699d62b9d0dfe26bbe97eda8947037597982b" } +threshold_types_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-types", default-features = false, rev = "b9d699d62b9d0dfe26bbe97eda8947037597982b" } +hashing_0_15_0 = { git = "https://github.com/zama-ai/kms.git", package = "threshold-hashing", default-features = false, rev = "b9d699d62b9d0dfe26bbe97eda8947037597982b" } #error-utils_0_15_0 = { path = "./core/error-utils", default-features = false } #thread-handles_0_15_0 = { path = "./core/thread-handles" } diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index fff2b3ef3c..69e7d3a8bc 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -1,8 +1,3 @@ -//! Code shared by every signcryption envelope format. -//! -//! Signing and verifying build *identical* receiver bindings or nothing -//! verifies at all. - use crate::cryptography::encryption::UnifiedPublicEncKey; use crate::cryptography::error::CryptographyError; use hashing::{DomainSep, serialize_hash_element}; @@ -29,8 +24,6 @@ pub(super) fn receiver_enc_key_digest( #[cfg(test)] pub(crate) mod test_support { - //! Fixtures shared by both envelope formats' tests. - use super::super::{SenderAuth, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey}; use crate::cryptography::encryption::{ Encryption, PkeScheme, PkeSchemeType, UnifiedPrivateEncKey, UnifiedPublicEncKey, diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 01f215ba50..171e9e53e7 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -1,5 +1,3 @@ -//! The composite signcryption envelope. - use super::UnifiedSigncryption; use super::common::receiver_enc_key_digest; use crate::consts::SAFE_SER_SIZE_LIMIT; @@ -55,33 +53,33 @@ impl Zeroize for CompositeEnvelope { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, VersionsDispatch)] -pub enum SigncryptionSignedPayloadVersions { - V0(SigncryptionSignedPayload), +pub enum CompositeSigncryptionPayloadVersions { + V0(CompositeSigncryptionPayload), } /// What every signature of a composite signcryption covers: the message, and the /// two values that tie it to one receiver. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Versionize)] -#[versionize(SigncryptionSignedPayloadVersions)] -pub struct SigncryptionSignedPayload { +#[versionize(CompositeSigncryptionPayloadVersions)] +pub struct CompositeSigncryptionPayload { pub msg: Vec, /// The receiver's identifier, e.g. a blockchain address. pub receiver_id: Vec, pub enc_key_digest: Vec, } -impl Named for SigncryptionSignedPayload { - const NAME: &'static str = "signcryption::SigncryptionSignedPayload"; +impl Named for CompositeSigncryptionPayload { + const NAME: &'static str = "signcryption::CompositeSigncryptionPayload"; } -impl Zeroize for SigncryptionSignedPayload { +impl Zeroize for CompositeSigncryptionPayload { fn zeroize(&mut self) { // The receiver id and the digest are public; `msg` is the secret. self.msg.zeroize(); } } -impl SigncryptionSignedPayload { +impl CompositeSigncryptionPayload { /// What the signatures of a signcryption of `msg` to this receiver cover. fn new( msg: &[u8], @@ -98,9 +96,8 @@ impl SigncryptionSignedPayload { /// Signcrypt `msg` in the composite layout. /// -/// Sign-then-encrypt, as in the frozen layout. Each signature covers a -/// [`SigncryptionSignedPayload`]. The whole envelope is then encrypted to the -/// receiver. +/// Sign-then-encrypt where each signature covers a [`CompositeSigncryptionPayload`]. +/// The whole envelope is then signcrypted to the receiver for the exact choice of `schemes`. #[cfg(feature = "non-wasm")] pub(super) fn seal( signcrypt_key: &UnifiedSigncryptionKey, @@ -111,7 +108,7 @@ pub(super) fn seal( ) -> Result { let receiver_enc_key = &signcrypt_key.receiver_enc_key; let mut signed = - SigncryptionSignedPayload::new(msg, &signcrypt_key.receiver_id, receiver_enc_key)?; + CompositeSigncryptionPayload::new(msg, &signcrypt_key.receiver_id, receiver_enc_key)?; let signature = sign_composite(&signcrypt_key.identity, schemes, dsep, &signed)?; signed.zeroize(); @@ -156,8 +153,11 @@ pub(super) fn open( let msg = Zeroizing::new(std::mem::take(&mut envelope.msg)); - let mut signed = - SigncryptionSignedPayload::new(&msg, &unsign_key.receiver_id, &unsign_key.encryption_key)?; + let mut signed = CompositeSigncryptionPayload::new( + &msg, + &unsign_key.receiver_id, + &unsign_key.encryption_key, + )?; let verified = verify_composite(&envelope.signature, sender_keys, dsep, &signed); signed.zeroize(); verified.map_err(|e| CryptographyError::VerificationError(e.to_string()))?; diff --git a/core/service/src/cryptography/signcryption/ecdsa_v0.rs b/core/service/src/cryptography/signcryption/ecdsa_v0.rs index f01ddde2e9..4c0564f7ed 100644 --- a/core/service/src/cryptography/signcryption/ecdsa_v0.rs +++ b/core/service/src/cryptography/signcryption/ecdsa_v0.rs @@ -46,17 +46,17 @@ pub(super) fn seal( // Note that H(client_verf_key) = client_address // Only serialize the inner structure to ensure backwards compatibility!!! let binding = receiver_binding(&signcrypt_key.receiver_id, &signcrypt_key.receiver_enc_key)?; - let signing_key = signcrypt_key.signing_key(); // Wipe the temporary signed message after signing. let to_sign = Zeroizing::new([msg, binding.as_slice()].concat()); - let sig = internal_sign(dsep, &to_sign, signing_key) + let sig = internal_sign(dsep, &to_sign, signcrypt_key.signing_key()) .map_err(|e| CryptographyError::SigningError(e.to_string()))?; // Encrypt msg || sig || H(server_verification_key) // OBSERVE: serialization is simply r concatenated with s. That is NOT an Ethereum compatible // signature since we preclude the v value. // The verification key is serialized based on the SEC1 standard. - let verf_key_hash = sender_verf_key_digest(&PublicSigKey::from_sk(signing_key))?; + let verf_key_hash = + sender_verf_key_digest(&PublicSigKey::from_sk(signcrypt_key.signing_key()))?; // Wipe the temporary encrypted message after encryption. let to_encrypt = Zeroizing::new([msg, sig.to_bytes().as_ref(), verf_key_hash.as_ref()].concat()); diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 23584d405b..71467a4396 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -136,11 +136,6 @@ impl UnifiedSigncryptionKey { } /// A sealer for a caller that holds only an ECDSA key. - /// - /// The key becomes a seedless identity: it writes the frozen envelope, and a - /// composite one under `Ecdsa256k1` alone. Asking it for any other scheme - /// fails with `CryptographyError::Signing`, wrapping the - /// `SigningError::MissingRootSeed` raised by the identity. pub fn from_signing_key( signing_key: PrivateSigKey, receiver_enc_key: UnifiedPublicEncKey, diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 20b087952e..81465404ad 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -33,7 +33,7 @@ //! the message type carries inside `safe_serialize(M)`. The draft's Label names //! one registered combination of schemes and one usage. Ours names the set of //! schemes, which the draft fixes per combination, and the usage, which the -//! message type states: `SigncryptionSignedPayload` for a signcryption, +//! message type states: `CompositeSigncryptionPayload` for a signcryption, //! `KeygenSignedPayload` or `CrsSignedPayload` for a result, and so on. //! - **ctx** is the domain separator. A [`DomainSep`] is exactly 8 bytes, so the //! length prefix the draft puts on ctx is not necessary. @@ -41,12 +41,6 @@ //! assumes that the hash resists collisions. The message keeps the same //! unforgeability argument without that assumption. //! -//! The message is a type rather than bytes, and this module serializes it. That -//! is what lets the type name do the Label's second job, and it means every value -//! the message binds is framed by `safe_serialize` rather than concatenated. A -//! caller cannot pass bytes that name no usage, or declare a usage that does not -//! match what it signs. -//! //! # What the encoding gives, and what it does not //! //! - Verification is all or nothing. [`verify_composite`] requires the set of @@ -218,23 +212,10 @@ where /// contract every verifier relies on, so it lives here alone: /// /// - [`SigningSchemeType::Ecdsa256k1`] signs `eip712_hash`, producing the -/// recoverable, on-chain-verifiable signature the fhevm contracts verify. It is -/// byte-identical to the result's deprecated `external_signature`, so that -/// `signatures` still carries it once that field goes away. The two match -/// because the caller derives both from one hash and ECDSA signing here is -/// deterministic. +/// recoverable, on-chain-verifiable signature the fhevm contracts verify. /// - Every other scheme signs [`scheme_bound_preimage`] over `payload`, so it /// commits to the scheme set and the payload type as well as to the payload. /// -/// The ECDSA entry is therefore the one component that binds neither the set nor -/// the payload type, and a verifier cannot read it as evidence of either. EIP-712 -/// is an EVM and secp256k1 construction that a post-quantum scheme has no reason -/// to be bound to, so the asymmetry stays. It costs less than it appears to, -/// because `ensure_requested_verified` requires every requested scheme to verify, -/// so a set-bound entry pins the set as soon as a verifier asks for more than -/// ECDSA. A request for ECDSA alone, which is what an empty request resolves to, -/// pins nothing. -/// /// `schemes` may be given in any order; the entries come back ordered by /// scheme. #[cfg(feature = "non-wasm")] diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index a4a7e0c147..c0681c99bd 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -38,10 +38,6 @@ impl Named for VerfKeySet { } /// The unvalidated mirror of [`VerfKeySet`] that carries the version dispatch. -/// way back from a versioned artifact, so both paths check the same invariants. -/// -/// The mirror is a newtype over the map, so the stored bytes hold the versioned -/// map and no extra field. #[derive(Versionize)] #[versionize(VerfKeySetVersions)] pub struct VerfKeySetRepr(BTreeMap); diff --git a/core/service/src/engine/signed_payload.rs b/core/service/src/engine/signed_payload.rs index 8611af798d..df6e0c9214 100644 --- a/core/service/src/engine/signed_payload.rs +++ b/core/service/src/engine/signed_payload.rs @@ -29,8 +29,6 @@ impl Named for UserDecSignedPayload { } /// What every non-ECDSA scheme signs for a user decryption result. -/// -/// See [`super::base::public_dec_payload`]; this is the user-decryption twin. pub(crate) fn user_dec_payload(response_bytes: &[u8], extra_data: &[u8]) -> UserDecSignedPayload { UserDecSignedPayload { response_bytes: response_bytes.to_vec(), diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index 610a7cffc1..c5acc14bfc 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -75,7 +75,7 @@ const ALLOW_UNCOVERED: &[&str] = &[ // What every signature of a composite signcryption covers. Signed, never // stored or transmitted, so no artifact holds it. The generator pins a rev, // so the type has to reach main before a fixture can be produced for it. - "SigncryptionSignedPayload", + "CompositeSigncryptionPayload", // TODO(zama-ai/kms-internal#3168): needs its own fixture once the follow-up // actually writes a key set to storage. The generator pins a rev, so the // type has to reach main before a fixture can be produced for it. From 7b83db7a8c7d4c64ea93db388f3fbfde8df8b9f6 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 18:07:52 +0200 Subject: [PATCH 075/114] refactor: renaming --- core/service/src/cryptography/signcryption/composite_v1.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 171e9e53e7..d1d2bc2532 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -38,7 +38,7 @@ pub enum CompositeEnvelopeVersions { #[versionize(CompositeEnvelopeVersions)] pub struct CompositeEnvelope { pub msg: Vec, - pub signature: Vec, + pub signatures: Vec, } impl Named for CompositeEnvelope { @@ -114,7 +114,7 @@ pub(super) fn seal( let mut envelope = CompositeEnvelope { msg: msg.to_vec(), - signature, + signatures: signature, }; let mut plaintext = ZeroizingWriter::new(); let serialized = safe_serialize(&envelope, &mut plaintext, SAFE_SER_SIZE_LIMIT); @@ -158,7 +158,7 @@ pub(super) fn open( &unsign_key.receiver_id, &unsign_key.encryption_key, )?; - let verified = verify_composite(&envelope.signature, sender_keys, dsep, &signed); + let verified = verify_composite(&envelope.signatures, sender_keys, dsep, &signed); signed.zeroize(); verified.map_err(|e| CryptographyError::VerificationError(e.to_string()))?; From efb0760d9888039da669159f90d8f489a48e7b42 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 18:17:55 +0200 Subject: [PATCH 076/114] fix: backward tests --- .../0_15_0/kms/unified_signcryption.bcode | 2 +- .../generate-v0.14.0/src/data_0_14.rs | 25 ++++--------------- .../generate-v0.15.0/src/data_0_15.rs | 21 ++++++++-------- 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode b/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode index 0e3e6d4f6e..bf33cb10ea 100644 --- a/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode +++ b/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5b40de437b213d7b1194c500cc6720fa812faf9d3667beca0ffc4bb2cf0747f +oid sha256:52eb0746a22458029145bbb2d67bc3d2fede14cce16a9fb38584dbbe16f322c7 size 1019 diff --git a/backward-compatibility/generate-v0.14.0/src/data_0_14.rs b/backward-compatibility/generate-v0.14.0/src/data_0_14.rs index 0b281e51de..143822ae8e 100644 --- a/backward-compatibility/generate-v0.14.0/src/data_0_14.rs +++ b/backward-compatibility/generate-v0.14.0/src/data_0_14.rs @@ -11,9 +11,7 @@ use kms_0_14_0::cryptography::{ encryption::{Encryption, PkeScheme, PkeSchemeType}, hybrid_ml_kem::HybridKemCt, signatures::{compute_eip712_signature, gen_sig_keys}, - signcryption::{ - Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, - }, + signcryption::{Signcrypt, UnifiedSigncryptionKeyOwned}, }; use kms_0_14_0::engine::base::{CrsGenMetadata, KeyGenMetadataInner, KmsFheKeyHandles}; use kms_0_14_0::engine::centralized::central_kms::generate_client_fhe_key; @@ -23,7 +21,7 @@ use kms_0_14_0::engine::threshold::service::{PublicKeyMaterial, ThresholdFheKeys use kms_0_14_0::util::key_setup::FhePublicKey; use kms_0_14_0::vault::keychain::AppKeyBlob; use kms_grpc_0_14_0::{ - kms::v1::{CustodianContext, CustodianSetupMessage, TypedPlaintext}, + kms::v1::TypedPlaintext, rpc_types::{PrivDataType, PubDataType, SignedPubDataHandleInternal}, solidity_types::{ CrsgenVerification, CrsgenVerificationQ126, KeygenVerification, KeygenVerificationQ126, @@ -34,7 +32,6 @@ use rand::{RngCore, SeedableRng}; use std::collections::BTreeMap; use std::num::Wrapping; use std::{borrow::Cow, collections::HashMap, fs::create_dir_all, path::PathBuf}; -use tfhe_1_6_2::safe_serialization::safe_serialize; use tfhe_1_6_2::shortint::parameters::{ AtomicPatternParameters, Backend, LweCiphertextCount, MetaNoiseSquashingParameters, MetaParameters, NoiseSquashingClassicParameters, NoiseSquashingCompressionParameters, @@ -82,8 +79,9 @@ use backward_compatibility::{ NodeInfoTest, PRSSSetupTest, PrfKeyTest, PrivDataTypeTest, PrivateSigKeyTest, PrssSetTest, PrssSetupCombinedTest, PubDataTypeTest, PublicSigKeyTest, ReleasePCRValuesTest, ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SoftwareVersionTest, TestMetadataDD, - TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedSigncryptionTest, - DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, KMS_MODULE_NAME, + TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, + UnifiedSigncryptionTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, + KMS_MODULE_NAME, }; use hashing_0_14_0::hash_versioned; use kms_0_14_0::cryptography::signcryption::SigncryptionPayload; @@ -106,13 +104,6 @@ macro_rules! store_versioned_auxiliary { }; } -/// Fixed timestamp (≈50 years after the Unix epoch) used when generating the custodian -/// fixtures. Using a constant instead of `SystemTime::now()` keeps the generated v0.14.0 -/// data byte-for-byte reproducible, so re-running the generator never churns the LFS objects. -fn fixed_fixture_timestamp() -> std::time::SystemTime { - std::time::UNIX_EPOCH + std::time::Duration::from_secs(50 * 8760 * 3600) -} - // The compact-public-key and compression parameters are left as `None` because they // are optional tfhe-rs types whose backward compatibility is already tested elsewhere. fn convert_dkg_params_sns(value: DKGParamsSnSTest) -> DKGParams { @@ -398,8 +389,6 @@ const UNIFIED_SIGNCRYPTION_TEST: UnifiedSigncryptionTest = UnifiedSigncryptionTe state: 202, }; - - // KMS test const PRSS_SETUP_COMBINED_TEST: PrssSetupCombinedTest = PrssSetupCombinedTest { test_filename: Cow::Borrowed("prss_setup_combined"), @@ -452,10 +441,6 @@ const SOFTWARE_VERSION_TEST: SoftwareVersionTest = SoftwareVersionTest { tag: Cow::Borrowed("super fun version"), }; - - - - fn dummy_domain() -> alloy_sol_types_1_6_0::Eip712Domain { alloy_sol_types_1_6_0::eip712_domain!( name: "Authorization token", diff --git a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs index 82b0e20351..7e3354c6e3 100644 --- a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs +++ b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs @@ -29,9 +29,7 @@ use kms_0_15_0::cryptography::{ compute_eip712_signature, gen_sig_keys, NodeSigningIdentity, RootSigningSeed, SigningSchemeType, StoredTypedSignature, UnifiedPublicSigKey, }, - signcryption::{ - Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKeyOwned, - }, + signcryption::{Signcrypt, UnifiedSigncryption, UnifiedSigncryptionKey}, }; use kms_0_15_0::engine::base::{ CrsGenMetadata, CrsGenMetadataInner, CrsGenMetadataInnerV2, CrsSignedPayload, @@ -58,6 +56,7 @@ use kms_grpc_0_15_0::{ use rand::{RngCore, SeedableRng}; use std::collections::BTreeMap; use std::num::Wrapping; +use std::sync::Arc; use std::{borrow::Cow, collections::HashMap, fs::create_dir_all, path::PathBuf}; use strum::IntoEnumIterator; use tfhe_1_8_1::safe_serialization::safe_serialize; @@ -113,11 +112,10 @@ use backward_compatibility::{ PrepKeygenSignedPayloadTest, PrfKeyTest, PrivDataTypeTest, PrivateSigKeyTest, PrssSetTest, PrssSetupCombinedTest, PubDataTypeTest, PublicDecSignedPayloadTest, PublicSigKeyTest, RecoveryValidationMaterialTest, ReleasePCRValuesTest, RootSigningSeedTest, SchemeDigestsTest, - ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, - SoftwareVersionTest, StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataDD, - TestMetadataKMS, TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, - UnifiedCipherTest, UnifiedPublicSigKeyTest, - UnifiedSigncryptionTest, UserDecSignedPayloadTest, + ShareTest, SigncryptionPayloadTest, SignedPubDataHandleInternalTest, SoftwareVersionTest, + StoredEip712DomainTest, StoredTypedSignatureTest, TestMetadataDD, TestMetadataKMS, + TestMetadataKmsGrpc, ThresholdFheKeysTest, TypedPlaintextTest, UnifiedCipherTest, + UnifiedPublicSigKeyTest, UnifiedSigncryptionTest, UserDecSignedPayloadTest, DISTRIBUTED_DECRYPTION_MODULE_NAME, KMS_GRPC_MODULE_NAME, KMS_MODULE_NAME, }; use hashing_0_15_0::hash_versioned; @@ -1126,12 +1124,13 @@ impl KmsV0_15_0 { fn gen_unified_signcryption(dir: &PathBuf) -> TestMetadataKMS { let mut rng = AesRng::seed_from_u64(UNIFIED_SIGNCRYPTION_TEST.state); + let (client_verf_key, _client_sig_key) = gen_sig_keys(&mut rng); let (verf_key, server_sig_key) = gen_sig_keys(&mut rng); - let (client_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); + let server_id = NodeSigningIdentity::new(server_sig_key, RootSigningSeed::random(&mut rng)); let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); let (_dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedSigncryptionKeyOwned::new( - server_sig_key, + let signcrypt_key = UnifiedSigncryptionKey::new( + Arc::new(server_id), enc_key, client_verf_key.verf_key_id(), ); From f8e764be15c1a47e7de9aed6abda26851616ee5a Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 18:23:35 +0200 Subject: [PATCH 077/114] fix: backward take 2 --- .../0_15_0/kms/unified_signcryption.bcode | 2 +- .../generate-v0.15.0/Cargo.lock | 26 ++++++++++--------- .../generate-v0.15.0/src/data_0_15.rs | 10 +++---- .../tests/backward_compatibility_kms.rs | 7 +++-- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode b/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode index bf33cb10ea..0e3e6d4f6e 100644 --- a/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode +++ b/backward-compatibility/data/0_15_0/kms/unified_signcryption.bcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52eb0746a22458029145bbb2d67bc3d2fede14cce16a9fb38584dbbe16f322c7 +oid sha256:d5b40de437b213d7b1194c500cc6720fa812faf9d3667beca0ffc4bb2cf0747f size 1019 diff --git a/backward-compatibility/generate-v0.15.0/Cargo.lock b/backward-compatibility/generate-v0.15.0/Cargo.lock index 3012cdc559..e8b7f96768 100644 --- a/backward-compatibility/generate-v0.15.0/Cargo.lock +++ b/backward-compatibility/generate-v0.15.0/Cargo.lock @@ -1832,7 +1832,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bc2wrap" version = "2.0.1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "bincode 2.0.1", "serde", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "error-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "anyhow", "tracing", @@ -4091,7 +4091,7 @@ dependencies = [ [[package]] name = "kms" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "aes 0.9.3", "aes-gcm 0.11.1", @@ -4112,6 +4112,7 @@ dependencies = [ "bc2wrap", "bincode 2.0.1", "bip39", + "bytes", "cbc", "cfg-if", "ciborium", @@ -4181,13 +4182,14 @@ dependencies = [ [[package]] name = "kms-grpc" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "alloy-dyn-abi", "alloy-primitives", "alloy-sol-types", "anyhow", "bc2wrap", + "bytes", "cfg-if", "hex", "prost", @@ -4633,7 +4635,7 @@ dependencies = [ [[package]] name = "observability" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "anyhow", "axum", @@ -6811,7 +6813,7 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test-utils" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "anyhow", "bc2wrap", @@ -6987,7 +6989,7 @@ dependencies = [ [[package]] name = "thread-handles" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "anyhow", "error-utils", @@ -7017,7 +7019,7 @@ dependencies = [ [[package]] name = "threshold-algebra" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "anyhow", "error-utils", @@ -7038,7 +7040,7 @@ dependencies = [ [[package]] name = "threshold-execution" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "aes 0.9.3", "aes-prng", @@ -7080,7 +7082,7 @@ dependencies = [ [[package]] name = "threshold-hashing" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "anyhow", "bc2wrap", @@ -7093,7 +7095,7 @@ dependencies = [ [[package]] name = "threshold-networking" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "anyhow", "async-trait", @@ -7130,7 +7132,7 @@ dependencies = [ [[package]] name = "threshold-types" version = "0.15.0-1" -source = "git+https://github.com/zama-ai/kms.git?rev=bce6ee2e8d9e10a20d077862a177a5784aebba01#bce6ee2e8d9e10a20d077862a177a5784aebba01" +source = "git+https://github.com/zama-ai/kms.git?rev=b9d699d62b9d0dfe26bbe97eda8947037597982b#b9d699d62b9d0dfe26bbe97eda8947037597982b" dependencies = [ "anyhow", "async-trait", diff --git a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs index 7e3354c6e3..bf676e6251 100644 --- a/backward-compatibility/generate-v0.15.0/src/data_0_15.rs +++ b/backward-compatibility/generate-v0.15.0/src/data_0_15.rs @@ -56,7 +56,6 @@ use kms_grpc_0_15_0::{ use rand::{RngCore, SeedableRng}; use std::collections::BTreeMap; use std::num::Wrapping; -use std::sync::Arc; use std::{borrow::Cow, collections::HashMap, fs::create_dir_all, path::PathBuf}; use strum::IntoEnumIterator; use tfhe_1_8_1::safe_serialization::safe_serialize; @@ -1124,14 +1123,13 @@ impl KmsV0_15_0 { fn gen_unified_signcryption(dir: &PathBuf) -> TestMetadataKMS { let mut rng = AesRng::seed_from_u64(UNIFIED_SIGNCRYPTION_TEST.state); - let (client_verf_key, _client_sig_key) = gen_sig_keys(&mut rng); let (verf_key, server_sig_key) = gen_sig_keys(&mut rng); - let server_id = NodeSigningIdentity::new(server_sig_key, RootSigningSeed::random(&mut rng)); + let (client_verf_key, _client_sig_key) = gen_sig_keys(&mut rng); let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); let (_dec_key, enc_key) = encryption.keygen().unwrap(); - let signcrypt_key = UnifiedSigncryptionKey::new( - Arc::new(server_id), - enc_key, + let signcrypt_key = UnifiedSigncryptionKey::from_signing_key( + server_sig_key, + enc_key.clone(), client_verf_key.verf_key_id(), ); let signcryption = signcrypt_key diff --git a/core/service/tests/backward_compatibility_kms.rs b/core/service/tests/backward_compatibility_kms.rs index 7bb7fec537..2e77388aa5 100644 --- a/core/service/tests/backward_compatibility_kms.rs +++ b/core/service/tests/backward_compatibility_kms.rs @@ -748,14 +748,13 @@ fn test_unified_signcryption( let original_versionized: UnifiedSigncryption = load_and_unversionize(dir, test, format)?; let mut rng = AesRng::seed_from_u64(test.state); let (verf_key, server_sig_key) = gen_sig_keys(&mut rng); - let (client_verf_key, _server_sig_key) = gen_sig_keys(&mut rng); + let (client_verf_key, _client_sig_key) = gen_sig_keys(&mut rng); let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); let (dec_key, enc_key) = encryption.keygen().unwrap(); - let receiver_id = client_verf_key.verf_key_id(); let signcrypt_key = UnifiedSigncryptionKey::from_signing_key( server_sig_key, enc_key.clone(), - receiver_id.clone(), + client_verf_key.verf_key_id(), ); let new_versionized = signcrypt_key .signcrypt(&mut rng, b"TESTTEST", &verf_key) @@ -779,7 +778,7 @@ fn test_unified_signcryption( std::sync::Arc::new(dec_key), enc_key, verf_key.clone(), - receiver_id, + client_verf_key.verf_key_id(), ); let opened: PublicSigKey = unsign_key .unsigncrypt(b"TESTTEST", &original_versionized) From 184f980fe968247633dd28363671fdc5a44bf128 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Fri, 25 Sep 2026 18:25:32 +0200 Subject: [PATCH 078/114] chore: fixed an unclarity --- .../src/cryptography/signing/composite.rs | 28 ++++++++++--------- core/service/src/engine/base.rs | 7 +++-- .../engine/storage_material_verification.rs | 10 +++---- core/service/src/engine/validation_wasm.rs | 2 +- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 81465404ad..63b61defab 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -50,8 +50,9 @@ //! usage, or out of the composite. //! - The composite is unforgeable if any one component is, as in the draft. //! - The ECDSA entry of [`sign_result_entries`] is the deliberate exception. It -//! signs the EIP-712 hash, so it binds neither the set nor the usage, and it -//! moves freely between them. +//! signs the EIP-712 hash, which binds the usage and the domain through the +//! struct type hash, but not the scheme set, so that entry moves freely +//! between sets. Requiring the set is left to the verifier. #[cfg(feature = "non-wasm")] use super::identity::NodeSigningIdentity; use super::typed_signature::StoredTypedSignature; @@ -216,6 +217,11 @@ where /// - Every other scheme signs [`scheme_bound_preimage`] over `payload`, so it /// commits to the scheme set and the payload type as well as to the payload. /// +/// The result is therefore not a composite in the sense of [`verify_composite`], +/// which rejects it: the ECDSA entry is bound to the EIP-712 message rather than +/// to the scheme set. A result is checked by `verify_response_signatures`, which +/// requires the set the verifier asked for. +/// /// `schemes` may be given in any order; the entries come back ordered by /// scheme. #[cfg(feature = "non-wasm")] @@ -229,9 +235,6 @@ pub fn sign_result_entries( where T: Serialize + Versionize + Named, { - if schemes.is_empty() { - return Ok(Vec::new()); - } let schemes = canonical_schemes(schemes)?; // Every non-ECDSA entry commits to the scheme set, so one cannot be lifted // out of a larger response and presented as a complete smaller one. @@ -459,17 +462,16 @@ mod tests { ); } - /// No schemes requested means no per-scheme entries, which is why this - /// returns a plain list of entries. + /// An empty set is a caller mistake, not a request for no signatures: a + /// result nothing signed is one the verifier refuses. #[test] - fn result_entries_tolerate_an_empty_request() { + fn result_entries_reject_an_empty_request() { let mut rng = AesRng::seed_from_u64(21); let identity = seeded_identity(&mut rng); - assert!( - sign_result_entries(&identity, &[], DSEP, &[0u8; 32], &msg()) - .unwrap() - .is_empty() - ); + assert!(matches!( + sign_result_entries(&identity, &[], DSEP, &[0u8; 32], &msg()), + Err(SigningError::EmptySchemeSet) + )); } /// Distinct scheme sets give distinct preimages, which is what stops a diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index 8a4e27036f..ccc85560fa 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -3079,10 +3079,11 @@ pub(crate) mod tests { let preproc_id = RequestId::new_random(&mut rng); let domain = dummy_domain(); let extra_data = vec![0x0Au8, 0x0B, 0x0C]; - // `external_signature` is always produced regardless of requested schemes. + // The minimal scheme set, which is what an empty request resolves to. + // `external_signature` is produced either way. let (sig, _signatures) = compute_preprocessing_signatures( &NodeSigningIdentity::ecdsa_only(sk.clone()), - &[], + &[SigningSchemeType::Ecdsa256k1], &preproc_id, &domain, extra_data.clone(), @@ -3125,7 +3126,7 @@ pub(crate) mod tests { let (_, bad_sk) = gen_sig_keys(&mut rng); let (sig, _signatures) = compute_preprocessing_signatures( &NodeSigningIdentity::ecdsa_only(bad_sk.clone()), - &[], + &[SigningSchemeType::Ecdsa256k1], &preproc_id, &domain, extra_data.clone(), diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index 612f801fde..36586c48a5 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -2169,7 +2169,7 @@ mod tests { let key_id = RequestId::new_random(&mut rng); let metadata = crate::engine::base::compute_info_standard_keygen_from_digests( &NodeSigningIdentity::ecdsa_only(signing_key.clone()), - &[], + &[SigningSchemeType::Ecdsa256k1], &prep_id, &key_id, vec![0x11; 32], @@ -2216,7 +2216,7 @@ mod tests { let key_id = RequestId::new_random(&mut rng); let metadata = crate::engine::base::compute_info_compressed_keygen_from_digests( &NodeSigningIdentity::ecdsa_only(signing_key.clone()), - &[], + &[SigningSchemeType::Ecdsa256k1], &prep_id, &key_id, vec![0x11; 32], @@ -2263,7 +2263,7 @@ mod tests { let crs_id = RequestId::new_random(&mut rng); let metadata = crate::engine::base::compute_info_crs_from_digest( &NodeSigningIdentity::ecdsa_only(signing_key.clone()), - &[], + &[SigningSchemeType::Ecdsa256k1], &crs_id, vec![0x33; 32], 64, @@ -3001,7 +3001,7 @@ mod tests { let metadata_identity = NodeSigningIdentity::ecdsa_only(metadata_sk.clone()); let key_metadata = crate::engine::base::compute_info_standard_keygen_from_digests( &metadata_identity, - &[], + &[SigningSchemeType::Ecdsa256k1], &material.preproc_id, &material.key_id, material @@ -3023,7 +3023,7 @@ mod tests { let crs_digest = setup_crs(&mut storage, &crs_id).await; let crs_metadata = crate::engine::base::compute_info_crs_from_digest( &metadata_identity, - &[], + &[SigningSchemeType::Ecdsa256k1], &crs_id, crs_digest, 64, diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 948eb8a93c..4fac1cd503 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -1008,7 +1008,7 @@ mod tests { ) -> anyhow::Result> { Ok(sign_user_decryption_result( &NodeSigningIdentity::ecdsa_only(server_sk.clone()), - &[], + &[SigningSchemeType::Ecdsa256k1], payload.clone(), user_pk_buf, extra_data.to_vec(), From e255c65138ee4c41b419b30400fc816a397e46b9 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Sat, 26 Sep 2026 11:01:47 +0200 Subject: [PATCH 079/114] fix: dylint --- .../src/cryptography/signing/verf_key_set.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index c0681c99bd..6a6f2188fc 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -28,7 +28,7 @@ const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; /// belongs to. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Versionize)] #[serde(transparent)] -#[versionize(try_convert = "VerfKeySetRepr")] +#[versionize(try_convert = "VerfKeySet")] pub struct VerfKeySet { keys: BTreeMap, } @@ -40,24 +40,24 @@ impl Named for VerfKeySet { /// The unvalidated mirror of [`VerfKeySet`] that carries the version dispatch. #[derive(Versionize)] #[versionize(VerfKeySetVersions)] -pub struct VerfKeySetRepr(BTreeMap); +pub struct VerfKeySet(BTreeMap); #[derive(VersionsDispatch)] pub enum VerfKeySetVersions { - V0(VerfKeySetRepr), + V0(VerfKeySet), } -impl From for VerfKeySetRepr { +impl From for VerfKeySet { fn from(value: VerfKeySet) -> Self { Self(value.keys) } } /// Reading a set back re-establishes the invariants. -impl TryFrom for VerfKeySet { +impl TryFrom for VerfKeySet { type Error = SigningError; - fn try_from(versioned: VerfKeySetRepr) -> Result { + fn try_from(versioned: VerfKeySet) -> Result { Self::new(versioned.0) } } @@ -234,7 +234,7 @@ mod tests { good ); for bad in [misfiled, empty] { - let smuggled = VerfKeySetRepr(bad).versionize_owned(); + let smuggled = VerfKeySet(bad).versionize_owned(); assert!(matches!( VerfKeySet::unversionize(smuggled), Err(UnversionizeError::Conversion { .. }) From 1c0f641e93a8416e4aee110f6ae37b86a5070de5 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Sat, 26 Sep 2026 11:13:54 +0200 Subject: [PATCH 080/114] test: fix --- core/service/src/engine/base.rs | 43 ++++++++++++++++----------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index ccc85560fa..003d366add 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -2084,7 +2084,7 @@ pub(crate) mod tests { // Go through the production entry point, so the payload the signer builds // is the one asserted against here. let signatures_for = |schemes: &[SigningSchemeType]| { - let meta = super::compute_info_crs_from_digest( + super::compute_info_crs_from_digest( &sk, schemes, &crs_id, @@ -2093,19 +2093,20 @@ pub(crate) mod tests { &domain, extra_data.clone(), ) - .unwrap(); - match meta { + .map(|meta| match meta { super::CrsGenMetadata::Current(inner) => { (inner.external_signature, inner.signatures) } super::CrsGenMetadata::LegacyV0(_) => panic!("expected current metadata"), - } + }) }; - // Requesting no scheme: `external_signature` is still produced, `signatures` is empty. - let (external_signature, sigs) = signatures_for(&[]); - assert_eq!(external_signature, expected_external); - assert!(sigs.is_empty(), "no schemes requested ⇒ empty signatures"); + // Requesting no scheme is refused rather than answered with an empty + // list. + assert!( + signatures_for(&[]).is_err(), + "an empty scheme set must be refused" + ); // Requesting a classic + two post-quantum schemes: `signatures` reflects // exactly the request. @@ -2114,7 +2115,7 @@ pub(crate) mod tests { SigningSchemeType::Ed25519, SigningSchemeType::MlDsa65, ]; - let (external_signature, sigs) = signatures_for(&schemes); + let (external_signature, sigs) = signatures_for(&schemes).unwrap(); assert_eq!(external_signature, expected_external); assert_eq!(sigs.len(), schemes.len()); @@ -2232,19 +2233,17 @@ pub(crate) mod tests { }; // ECDSA needs no seed, so an ECDSA-only request still succeed - for schemes in [vec![], vec![SigningSchemeType::Ecdsa256k1]] { - let (external_signature, sigs) = signatures_for(&schemes) - .unwrap_or_else(|e| panic!("{schemes:?} must not need a root seed: {e}")); - assert_eq!(sigs.len(), schemes.len()); - let sol_type = PrepKeygenVerification::new(&prep_id, extra_data.clone()); - assert_eq!( - recover_address_from_ext_signature(&sol_type, &domain, &external_signature) - .unwrap(), - sk.verf_key().address() - ); - for stored in &sigs { - assert_eq!(stored.signature, external_signature); - } + let schemes = vec![SigningSchemeType::Ecdsa256k1]; + let (external_signature, sigs) = signatures_for(&schemes) + .unwrap_or_else(|e| panic!("{schemes:?} must not need a root seed: {e}")); + assert_eq!(sigs.len(), schemes.len()); + let sol_type = PrepKeygenVerification::new(&prep_id, extra_data.clone()); + assert_eq!( + recover_address_from_ext_signature(&sol_type, &domain, &external_signature).unwrap(), + sk.verf_key().address() + ); + for stored in &sigs { + assert_eq!(stored.signature, external_signature); } // Every other scheme fails, whether asked for alone or beside ECDSA, and the From 0a63eca6733b417ae0adf25faa7398b4ae7b9404 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Sun, 27 Sep 2026 17:48:14 +0200 Subject: [PATCH 081/114] fix: review VerfKeySet --- .../src/cryptography/signing/verf_key_set.rs | 23 +++++++++++-------- core/service/tests/versioned_enum_coverage.rs | 7 +++--- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 6a6f2188fc..45c642d77b 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -18,7 +18,7 @@ const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; /// to travel together. /// /// The set is persisted, so it is versioned. Its own shape is tagged by -/// [`VerfKeySetVersions`], and the scheme tags and keys inside it each carry +/// [`VerfKeySetReprVersions`], and the scheme tags and keys inside it each carry /// their own dispatch, so a later change to [`UnifiedPublicSigKey`] is handled /// by `UnifiedPublicSigKeyVersions` without a new version here. /// @@ -28,7 +28,7 @@ const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; /// belongs to. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Versionize)] #[serde(transparent)] -#[versionize(try_convert = "VerfKeySet")] +#[versionize(try_convert = "VerfKeySetRepr")] pub struct VerfKeySet { keys: BTreeMap, } @@ -38,26 +38,29 @@ impl Named for VerfKeySet { } /// The unvalidated mirror of [`VerfKeySet`] that carries the version dispatch. +/// +/// `try_convert` versions the target type, not `VerfKeySet` itself, so the +/// dispatch enum is named after this type rather than after the validated one. #[derive(Versionize)] -#[versionize(VerfKeySetVersions)] -pub struct VerfKeySet(BTreeMap); +#[versionize(VerfKeySetReprVersions)] +pub struct VerfKeySetRepr(BTreeMap); #[derive(VersionsDispatch)] -pub enum VerfKeySetVersions { - V0(VerfKeySet), +pub enum VerfKeySetReprVersions { + V0(VerfKeySetRepr), } -impl From for VerfKeySet { +impl From for VerfKeySetRepr { fn from(value: VerfKeySet) -> Self { Self(value.keys) } } /// Reading a set back re-establishes the invariants. -impl TryFrom for VerfKeySet { +impl TryFrom for VerfKeySet { type Error = SigningError; - fn try_from(versioned: VerfKeySet) -> Result { + fn try_from(versioned: VerfKeySetRepr) -> Result { Self::new(versioned.0) } } @@ -234,7 +237,7 @@ mod tests { good ); for bad in [misfiled, empty] { - let smuggled = VerfKeySet(bad).versionize_owned(); + let smuggled = VerfKeySetRepr(bad).versionize_owned(); assert!(matches!( VerfKeySet::unversionize(smuggled), Err(UnversionizeError::Conversion { .. }) diff --git a/core/service/tests/versioned_enum_coverage.rs b/core/service/tests/versioned_enum_coverage.rs index c5acc14bfc..6394accbfa 100644 --- a/core/service/tests/versioned_enum_coverage.rs +++ b/core/service/tests/versioned_enum_coverage.rs @@ -76,10 +76,11 @@ const ALLOW_UNCOVERED: &[&str] = &[ // stored or transmitted, so no artifact holds it. The generator pins a rev, // so the type has to reach main before a fixture can be produced for it. "CompositeSigncryptionPayload", + // The versioned mirror of VerfKeySet; see its `try_convert` there. // TODO(zama-ai/kms-internal#3168): needs its own fixture once the follow-up - // actually writes a key set to storage. The generator pins a rev, so the - // type has to reach main before a fixture can be produced for it. - "VerfKeySet", + // actually writes a key set to storage and then VerfKeySetRepr should be made private. + // The generator pins a rev, so the type has to reach main before a fixture can be produced for it. + "VerfKeySetRepr", // Field of RecoveryValidationMaterial.payload. // Covered via RecoveryValidationMaterialTest. "RecoveryValidationMaterialPayload", From a0c8b05a45857c8553862a990d31ee571101d292 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 29 Sep 2026 16:22:03 +0200 Subject: [PATCH 082/114] docs: updated docs according to Daniel's review --- ai-docs/ARCHITECTURE.md | 6 +++--- core/service/src/cryptography/signcryption/mod.rs | 10 ++++------ core/service/src/cryptography/signing/composite.rs | 8 ++++---- core/service/src/cryptography/signing/verf_key_set.rs | 5 ----- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/ai-docs/ARCHITECTURE.md b/ai-docs/ARCHITECTURE.md index d8d1382f22..ee801230e0 100644 --- a/ai-docs/ARCHITECTURE.md +++ b/ai-docs/ARCHITECTURE.md @@ -590,9 +590,9 @@ Legacy metadata has no digest, so its public objects receive a raw presence chec `Eip712Domain` that arrives from a gRPC request. At boot, current private keygen and CRS metadata with a stored domain reconstruct their signed Solidity payload and must recover the node's signing address. Older metadata versions upgrade with no domain and stay unverifiable. The -entries of the other schemes sign the serialized result payload inside a composite preimage, which binds the canonical scheme set and the -role. A node that cannot derive a scheme's key, because it holds no -root seed, fails boot on such an entry rather than passing it over. +entries of the other schemes sign the serialized result payload inside a composite preimage, +which binds the canonical scheme set and payload type. A node that cannot derive a scheme's key, +because it holds no root seed, fails boot on such an entry rather than passing it over. `PubDataType::DecompressionKey` has no private-storage counterpart at all (`write_decompression_key` persists no private data), so a published decompression key cannot be diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 71467a4396..3b17a96559 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -3,10 +3,8 @@ //! Client requests to the server should be validated against the client's wallet address, //! which is derived from a ECDSA secp256k1 key. //! Based on the request the server does sign-then-encrypt to securely encrypt a payload for the -//! client. Signing for the server is also carried out using ECDSA with secp256k1 and the client -//! can validate this against the server's public key. -//! Unfortunately we cannot use PQ signatures such as ML-DSA because the server identities -//! must be compatible with EVM on-chain verification. +//! client. Signing for the server is also carried out using ECDSA with secp256k1 (and possibly +//! other signing schemes as well) and the client can validate this against the server's public key(s). //! //! For encryption a hybrid encryption scheme is used based on ML-KEM and AES GCM. //! @@ -15,8 +13,8 @@ //! A signcryption's encrypted plaintext has a layout, and there is more than //! one. **A [`UnifiedSigncryption`] does not say which.** The layout follows from //! the key material a reader holds, named by [`SenderAuth`]: a single -//! [`PublicSigKey`] can only open the frozen layout, and a [`VerfKeySet`] plus a -//! scheme policy can only open the multi-signature one. +//! [`PublicSigKey`] can only open the frozen layout, and a [`VerfKeySet`] (defining the choice +//! of signing schemes) can only open the multi-signature one. mod common; mod composite_v1; diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 63b61defab..2b7701b9d9 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -7,8 +7,9 @@ //! //! # Relation to the IETF composite signature draft //! -//! The IETF supports exactly one legacy scheme and ML-DSA, whereas we want more -//! flexibility and hence will support both a single scheme choice and more than 2. +//! The IETF supports exactly one legacy scheme and ML-DSA, whereas we need more +//! flexibility, and thus we support both the choice of a single scheme, along +//! with the choice of more than two schemes. //! Our construction follows `draft-ietf-lamps-pq-composite-sigs` in its form but //! does not interoperate with the draft. //! @@ -438,8 +439,7 @@ mod tests { } } - /// Result entries use the same ordering convention as [`sign_composite - ///`]: + /// Result entries use the same ordering convention as [`sign_composite`]: /// by scheme, duplicate-free, whatever order the request arrived in. #[test] fn result_entries_are_ordered_by_scheme() { diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 45c642d77b..634dbe3c9f 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -17,11 +17,6 @@ const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; /// signs under several schemes, so a verifier needs several keys, and needs them /// to travel together. /// -/// The set is persisted, so it is versioned. Its own shape is tagged by -/// [`VerfKeySetReprVersions`], and the scheme tags and keys inside it each carry -/// their own dispatch, so a later change to [`UnifiedPublicSigKey`] is handled -/// by `UnifiedPublicSigKeyVersions` without a new version here. -/// /// # Invariants /// /// The set is non-empty, and every key is filed under the scheme it actually From dfc34a241a3d6a3048b386055f748cd611ae7f17 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 29 Sep 2026 20:09:02 +0200 Subject: [PATCH 083/114] chore: accept signed super sets per review request --- .../src/cryptography/signing/composite.rs | 71 ++++++++++++-- .../service/src/engine/validation_non_wasm.rs | 92 ++++++++++++------- core/service/src/engine/validation_wasm.rs | 49 ++++++++-- 3 files changed, 160 insertions(+), 52 deletions(-) diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 2b7701b9d9..c4fc21fcb8 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -87,15 +87,15 @@ pub fn canonical_schemes( Ok(canonical) } -/// The unambiguous byte encoding of `schemes`, for use inside a signed preimage. +/// The unambiguous byte encoding of a set of schemes, given by their wire discriminants. /// /// Length-prefixed, so no set's encoding is a prefix of a longer set's. -fn canonical_scheme_bytes(schemes: &[SigningSchemeType]) -> Vec { +fn canonical_wire_scheme_bytes(schemes: &[i32]) -> Vec { let mut out = Vec::with_capacity(4 + 4 * schemes.len()); // Bounded by the number of known schemes, so the cast cannot truncate. out.extend_from_slice(&(schemes.len() as u32).to_le_bytes()); for scheme in schemes { - out.extend_from_slice(&scheme.tag()); + out.extend_from_slice(&scheme.to_le_bytes()); } out } @@ -129,11 +129,12 @@ where T: Serialize + Versionize + Named, { let schemes = canonical_schemes(schemes)?; + let wire: Vec = schemes.iter().map(|scheme| scheme.as_wire()).collect(); + let scheme_bytes = canonical_wire_scheme_bytes(&wire); let mut out = ZeroizingWriter::new(); let framed = |e: std::io::Error| SigningError::Serialization(e.to_string()); out.write_all(COMPOSITE_PREFIX).map_err(framed)?; - out.write_all(&canonical_scheme_bytes(&schemes)) - .map_err(framed)?; + out.write_all(&scheme_bytes).map_err(framed)?; safe_serialize(payload, &mut out, SAFE_SER_SIZE_LIMIT) .map_err(|e| SigningError::Serialization(e.to_string()))?; Ok(out.into_inner()) @@ -221,7 +222,8 @@ where /// The result is therefore not a composite in the sense of [`verify_composite`], /// which rejects it: the ECDSA entry is bound to the EIP-712 message rather than /// to the scheme set. A result is checked by `verify_response_signatures`, which -/// requires the set the verifier asked for. +/// rebuilds the preimage from the entries it receives and requires every scheme +/// the verifier asked for to be among them, so the signed set may be larger. /// /// `schemes` may be given in any order; the entries come back ordered by /// scheme. @@ -271,6 +273,7 @@ mod tests { use crate::vault::storage::tests::TestType; use aes_prng::AesRng; use rand::SeedableRng; + use strum::IntoEnumIterator; const DSEP: &DomainSep = b"COMPSIGT"; @@ -488,7 +491,8 @@ mod tests { // one starting with it, so the shorter set's encoding appears nowhere in // the longer set's preimage. let pair_preimage = scheme_bound_preimage(&pair(), &msg()).unwrap(); - let single_scheme_bytes = canonical_scheme_bytes(&single); + let wire: Vec = single.iter().map(|scheme| scheme.as_wire()).collect(); + let single_scheme_bytes = canonical_wire_scheme_bytes(&wire); assert!( !pair_preimage .windows(single_scheme_bytes.len()) @@ -496,6 +500,59 @@ mod tests { ); } + /// The presented form agrees with the typed one for every known set, in any + /// order and with duplicates, and binds a scheme it does not know rather than + /// dropping it. + #[test] + fn presented_preimage_matches_the_typed_one() { + let typed = [ + SigningSchemeType::MlDsa65, + SigningSchemeType::Ecdsa256k1, + SigningSchemeType::Ed25519, + ]; + let wire: Vec = typed.iter().map(|scheme| scheme.as_wire()).collect(); + let mut shuffled = wire.clone(); + shuffled.reverse(); + shuffled.push(wire[0]); + + let expected = scheme_bound_preimage(&typed, &msg()).unwrap(); + assert_eq!( + presented_scheme_bound_preimage(&wire, &msg()).unwrap(), + expected + ); + assert_eq!( + presented_scheme_bound_preimage(&shuffled, &msg()).unwrap(), + expected + ); + + // Every single known scheme, and the full set, agree as well. + let every_scheme: Vec<_> = SigningSchemeType::iter().collect(); + for scheme in &every_scheme { + assert_eq!( + presented_scheme_bound_preimage(&[scheme.as_wire()], &msg()).unwrap(), + scheme_bound_preimage(&[*scheme], &msg()).unwrap() + ); + } + let every_wire: Vec = every_scheme.iter().map(|scheme| scheme.as_wire()).collect(); + assert_eq!( + presented_scheme_bound_preimage(&every_wire, &msg()).unwrap(), + scheme_bound_preimage(&every_scheme, &msg()).unwrap() + ); + + // An unknown discriminant is part of the set, so it changes the preimage. + let mut with_unknown = wire.clone(); + with_unknown.push(i32::MAX); + assert_ne!( + presented_scheme_bound_preimage(&with_unknown, &msg()).unwrap(), + expected + ); + + assert!(matches!( + presented_scheme_bound_preimage(&[], &msg()), + Err(SigningError::EmptySchemeSet) + )); + } + /// Canonicalisation normalises order and duplicates, and refuses the empty /// set — the case that would make "every scheme verified" vacuous. #[test] diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 3ed7817d78..4b03fdffef 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -2449,53 +2449,75 @@ mod tests { let no_request_ctx = ctx_for(None); assert!(!verify(&no_request_ctx, &extra_data)); - // An entry of a scheme nobody asked for carries no weight either way: it - // neither rescues a response that is missing a requested scheme, nor sinks - // one that carries every requested scheme. + // A response signed under a superset of the request is accepted: the + // post-quantum entry is checked against the set the response presents, + // and the entry nobody asked for is not checked at all. + let superset = [SigningSchemeType::Ed25519, scheme]; + let superset_signatures: Vec = sign_result_entries( + &identity, + &superset, + &DSEP_PUBLIC_DECRYPTION, + &[0u8; 32], + &signed, + ) + .unwrap() + .iter() + .map(TypedSignature::from) + .collect(); + let verify_list = |ctx: &PublicDecTrustedValidationContext, list: &[TypedSignature]| { + verify_public_decrypt_signatures(ctx, &payload, 1, &vk, &[], &[], list, &extra_data) + }; + assert!(verify_list(&pq_ctx, &superset_signatures)); + + // So is the same response for a verifier that asked for the whole superset + // and holds a key for each of its schemes. + let superset_keys = HashMap::from([( + 1u32, + VerfKeySet::from_identity(&identity, &superset).unwrap(), + )]); + let superset_request = request_for(vec![ + kms_grpc::kms::v1::SigningSchemeType::Ed25519 as i32, + kms_grpc::kms::v1::SigningSchemeType::Mldsa65 as i32, + ]); + let superset_ctx = PublicDecTrustedValidationContext::new( + &server_pks, + &superset_keys, + None, + &[], + None, + Some(&superset_request), + ) + .unwrap(); + assert!(verify_list(&superset_ctx, &superset_signatures)); + + // Dropping the unrequested entry changes the presented set, which the + // requested entry is bound to, so what is left no longer verifies. + let stripped: Vec<_> = superset_signatures + .iter() + .filter(|typed| typed.scheme == scheme.as_wire()) + .cloned() + .collect(); + assert!(!verify_list(&pq_ctx, &stripped)); + + // Appending an entry the server did not sign changes the presented set in + // the same way, so it breaks a response that was otherwise complete. let mut with_junk = signatures.clone(); with_junk.push(TypedSignature { scheme: kms_grpc::kms::v1::SigningSchemeType::Ed25519 as i32, signature: vec![0u8; 64], }); - assert!(verify_public_decrypt_signatures( - &pq_ctx, - &payload, - 1, - &vk, - &[], - &[], - &with_junk, - &extra_data - )); + assert!(!verify_list(&pq_ctx, &with_junk)); let junk_only = vec![with_junk.pop().unwrap()]; - assert!(!verify_public_decrypt_signatures( - &pq_ctx, - &payload, - 1, - &vk, - &[], - &[], - &junk_only, - &extra_data - )); + assert!(!verify_list(&pq_ctx, &junk_only)); - // An entry of a scheme this release does not know is skipped the same way, so a - // newer node can add a scheme during a rolling upgrade. + // An entry of a scheme this release does not know is bound into the preimage + // like any other, so appending one the server did not sign is rejected too. let mut with_unknown = signatures.clone(); with_unknown.push(TypedSignature { scheme: i32::MAX, signature: vec![0u8; 64], }); - assert!(verify_public_decrypt_signatures( - &pq_ctx, - &payload, - 1, - &vk, - &[], - &[], - &with_unknown, - &extra_data - )); + assert!(!verify_list(&pq_ctx, &with_unknown)); } #[test] diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 4fac1cd503..200d09b46c 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -1,3 +1,4 @@ +use crate::cryptography::signing::composite::scheme_bound_preimage; use crate::cryptography::signing::ecdsa::recover_address_from_eip712_hash; use crate::{ anyhow_error_and_log, anyhow_tracked, @@ -17,6 +18,7 @@ use kms_grpc::kms::v1::{TypedSignature, UserDecryptionResponse, UserDecryptionRe use std::collections::{HashMap, HashSet}; use tfhe::FheTypes; use threshold_types::role::Role; +use zeroize::Zeroizing; pub(crate) const DSEP_USER_DECRYPTION: DomainSep = *b"USER_DEC"; @@ -402,17 +404,24 @@ fn attribute_scheme_entry( /// /// # What gets checked /// +/// - First, before any cryptography, that `list` has an entry for every requested +/// scheme, save an ECDSA that a deprecated field may meet. /// - The deprecated internal `signature`, when the result kind carries one. It covers the /// payload alone, so it satisfies a requested ECDSA only when no EIP-712 domain is /// available. /// - The deprecated `external_signature`, whenever an EIP-712 domain is available. It is /// checked *in addition to* the internal one, not instead of it. /// - Every entry of `list` for a requested scheme. An entry for a scheme nobody asked -/// for carries no weight either way, so it is skipped, and so is an entry of a scheme -/// this release does not know. +/// for is not checked, and neither is an entry of a scheme this release does not +/// know, so a response signed under a superset of the request is accepted. /// - Finally, that every requested scheme was verified, and that every signature agreed /// on one party. /// +/// The non-ECDSA entries are checked against a preimage bound to the schemes `list` +/// presents, which is the set the server signed under, not against `requested`. An +/// unchecked entry still counts towards that set, so adding or removing one makes every +/// non-ECDSA entry fail. +/// /// A result whose `list` is empty is still authenticated by the deprecated fields, which /// is what a node from a release before the list sends. A `requested` that is empty, by /// contrast, is a rejection rather than a lenient request. @@ -438,12 +447,31 @@ where .to_string(), )); } - // Bound to the set this verifier requested, so an entry lifted from a - // response signed under a larger set does not verify here. The ECDSA entry is - // the exception; see `sign_result_entries`. - let signed_bytes = - crate::cryptography::signing::composite::scheme_bound_preimage(requested, payloads.payload) - .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))?; + // Every requested scheme other than ECDSA must only be met by an entry in `list`. + // ECDSA may also be met by a deprecated field, which the final check decides. + let presented: Vec = sigs.list.iter().map(|typed| typed.scheme).collect(); + if let Some(missing) = requested + .iter() + .filter(|scheme| **scheme != SigningSchemeType::Ecdsa256k1) + .find(|scheme| !presented.contains(&scheme.as_wire())) + { + return Err(anyhow_tracked(format!( + "the response carries no {missing} signature, but {missing} was requested" + ))); + } + // Bound to the set this verifier response, as we just checked if any schemes were missing. + // The ECDSA entry is the exception; see `sign_result_entries`. + let signed_bytes = if sigs.list.is_empty() { + Zeroizing::new(Vec::new()) + } else { + let presented: Vec = sigs + .list + .iter() + .map(|typed| typed.scheme.try_into()) + .collect::, _>>()?; + scheme_bound_preimage(&presented, payloads.payload) + .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))? + }; let mut verified: Vec = Vec::with_capacity(sigs.list.len() + 1); let mut signer: Option<(u32, Address)> = None; @@ -505,8 +533,9 @@ where for typed in sigs.list { // A scheme this release does not know cannot have been requested, so its entry - // is passed over like an unrequested one. That keeps a verifier working while a - // newer node adds a scheme during a rolling upgrade. + // is passed over like an unrequested one. Its discriminant is still bound into + // the preimage above, so a verifier keeps working when a newer node signs + // under a scheme this release has never heard of. let Ok(scheme) = SigningSchemeType::try_from(typed.scheme) else { tracing::warn!( "A response carries a signature of the unknown scheme {}, which is skipped", From b8538d6851c0d9cc6b53ae60d5afd6aa31faee02 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 29 Sep 2026 20:14:24 +0200 Subject: [PATCH 084/114] fix: redundant test --- .../src/cryptography/signing/composite.rs | 54 ------------------- 1 file changed, 54 deletions(-) diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index c4fc21fcb8..d61b8ce7fc 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -273,7 +273,6 @@ mod tests { use crate::vault::storage::tests::TestType; use aes_prng::AesRng; use rand::SeedableRng; - use strum::IntoEnumIterator; const DSEP: &DomainSep = b"COMPSIGT"; @@ -500,59 +499,6 @@ mod tests { ); } - /// The presented form agrees with the typed one for every known set, in any - /// order and with duplicates, and binds a scheme it does not know rather than - /// dropping it. - #[test] - fn presented_preimage_matches_the_typed_one() { - let typed = [ - SigningSchemeType::MlDsa65, - SigningSchemeType::Ecdsa256k1, - SigningSchemeType::Ed25519, - ]; - let wire: Vec = typed.iter().map(|scheme| scheme.as_wire()).collect(); - let mut shuffled = wire.clone(); - shuffled.reverse(); - shuffled.push(wire[0]); - - let expected = scheme_bound_preimage(&typed, &msg()).unwrap(); - assert_eq!( - presented_scheme_bound_preimage(&wire, &msg()).unwrap(), - expected - ); - assert_eq!( - presented_scheme_bound_preimage(&shuffled, &msg()).unwrap(), - expected - ); - - // Every single known scheme, and the full set, agree as well. - let every_scheme: Vec<_> = SigningSchemeType::iter().collect(); - for scheme in &every_scheme { - assert_eq!( - presented_scheme_bound_preimage(&[scheme.as_wire()], &msg()).unwrap(), - scheme_bound_preimage(&[*scheme], &msg()).unwrap() - ); - } - let every_wire: Vec = every_scheme.iter().map(|scheme| scheme.as_wire()).collect(); - assert_eq!( - presented_scheme_bound_preimage(&every_wire, &msg()).unwrap(), - scheme_bound_preimage(&every_scheme, &msg()).unwrap() - ); - - // An unknown discriminant is part of the set, so it changes the preimage. - let mut with_unknown = wire.clone(); - with_unknown.push(i32::MAX); - assert_ne!( - presented_scheme_bound_preimage(&with_unknown, &msg()).unwrap(), - expected - ); - - assert!(matches!( - presented_scheme_bound_preimage(&[], &msg()), - Err(SigningError::EmptySchemeSet) - )); - } - /// Canonicalisation normalises order and duplicates, and refuses the empty /// set — the case that would make "every scheme verified" vacuous. #[test] From d9c67863252ad50e622a6659fa75ac89bb645b06 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 30 Sep 2026 10:11:38 +0200 Subject: [PATCH 085/114] test: fix --- core/service/src/client/client_non_wasm.rs | 10 +++- .../src/cryptography/signing/composite.rs | 55 ++++++++++++++++++- core/service/src/engine/validation_wasm.rs | 28 +++++----- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index 3419ad4fb7..c9f8cece32 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -559,12 +559,16 @@ mod tests { assert!(verify(&client, &signatures, &payload()).is_err()); } - /// Every requested scheme must verify, even when other entries verify. + /// Every requested scheme must have an entry in the list, even when the other + /// entries and the legacy signature verify. #[test] fn requested_schemes_cannot_be_stripped() { let identity = seeded_identity(10); let every_scheme: Vec<_> = SigningSchemeType::iter().collect(); let composite = vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa65]; + // A current node always sends the legacy external signature alongside the + // list, so every response here carries it. + let legacy = legacy_external_signature(&identity); for (case, requested, offered) in [ ( @@ -582,7 +586,7 @@ mod tests { // Signed under exactly the set offered, not a larger list let offered = signatures_for(&identity, &offered, &payload()); assert_eq!( - verify(&client, &offered, &payload()).unwrap(), + verify_with_legacy(&client, &offered, &legacy, &payload()).unwrap(), (PARTY, identity.verf_key().address()), "{case}" ); @@ -593,7 +597,7 @@ mod tests { .filter(|typed| typed.scheme != dropped.as_wire()) .cloned() .collect(); - let err = verify(&client, &stripped, &payload()) + let err = verify_with_legacy(&client, &stripped, &legacy, &payload()) .unwrap_err() .to_string(); assert!( diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index d61b8ce7fc..83189dd264 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -128,9 +128,31 @@ pub fn scheme_bound_preimage( where T: Serialize + Versionize + Named, { - let schemes = canonical_schemes(schemes)?; let wire: Vec = schemes.iter().map(|scheme| scheme.as_wire()).collect(); - let scheme_bytes = canonical_wire_scheme_bytes(&wire); + wire_scheme_bound_preimage(&wire, payload) +} + +/// [`scheme_bound_preimage`] for schemes given by their wire discriminants, the +/// form a received response carries them in. +/// +/// A discriminant this release does not know is bound like any other, since the +/// encoding uses nothing but the discriminant. +/// +/// Errors when `schemes` is empty. +pub fn wire_scheme_bound_preimage( + schemes: &[i32], + payload: &T, +) -> Result>, SigningError> +where + T: Serialize + Versionize + Named, +{ + let mut canonical = schemes.to_vec(); + canonical.sort_unstable(); + canonical.dedup(); + if canonical.is_empty() { + return Err(SigningError::EmptySchemeSet); + } + let scheme_bytes = canonical_wire_scheme_bytes(&canonical); let mut out = ZeroizingWriter::new(); let framed = |e: std::io::Error| SigningError::Serialization(e.to_string()); out.write_all(COMPOSITE_PREFIX).map_err(framed)?; @@ -499,6 +521,35 @@ mod tests { ); } + /// The wire form agrees with the typed one for a known set, in any order and + /// with duplicates, and binds a scheme this release does not know rather than + /// dropping it, so a newer signer's preimage can be rebuilt. + #[test] + fn wire_preimage_matches_the_typed_one_and_binds_unknown_schemes() { + let expected = scheme_bound_preimage(&pair(), &msg()).unwrap(); + let mut wire: Vec = pair().iter().map(|scheme| scheme.as_wire()).collect(); + wire.reverse(); + wire.push(wire[0]); + assert_eq!(wire_scheme_bound_preimage(&wire, &msg()).unwrap(), expected); + + // An unknown discriminant is part of the set, so it changes the preimage, + // and where it sits in the input does not. + let mut with_unknown = wire.clone(); + with_unknown.push(i32::MAX); + let unknown_first: Vec = std::iter::once(i32::MAX).chain(wire).collect(); + let bound = wire_scheme_bound_preimage(&with_unknown, &msg()).unwrap(); + assert_ne!(bound, expected); + assert_eq!( + wire_scheme_bound_preimage(&unknown_first, &msg()).unwrap(), + bound + ); + + assert!(matches!( + wire_scheme_bound_preimage(&[], &msg()), + Err(SigningError::EmptySchemeSet) + )); + } + /// Canonicalisation normalises order and duplicates, and refuses the empty /// set — the case that would make "every scheme verified" vacuous. #[test] diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 200d09b46c..47ac4d11f8 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -1,4 +1,4 @@ -use crate::cryptography::signing::composite::scheme_bound_preimage; +use crate::cryptography::signing::composite::wire_scheme_bound_preimage; use crate::cryptography::signing::ecdsa::recover_address_from_eip712_hash; use crate::{ anyhow_error_and_log, anyhow_tracked, @@ -405,7 +405,8 @@ fn attribute_scheme_entry( /// # What gets checked /// /// - First, before any cryptography, that `list` has an entry for every requested -/// scheme, save an ECDSA that a deprecated field may meet. +/// scheme. Only when `list` is empty may a deprecated field meet a requested ECDSA +/// instead. /// - The deprecated internal `signature`, when the result kind carries one. It covers the /// payload alone, so it satisfies a requested ECDSA only when no EIP-712 domain is /// available. @@ -447,29 +448,28 @@ where .to_string(), )); } - // Every requested scheme other than ECDSA must only be met by an entry in `list`. - // ECDSA may also be met by a deprecated field, which the final check decides. + // A node that sends `list` signs every requested scheme into it, ECDSA included. + // Only a node from before `list` sends it empty, and then a deprecated field may + // meet a requested ECDSA, which the final check decides. Nothing else can be met + // without an entry. let presented: Vec = sigs.list.iter().map(|typed| typed.scheme).collect(); + let legacy_only = presented.is_empty(); if let Some(missing) = requested .iter() - .filter(|scheme| **scheme != SigningSchemeType::Ecdsa256k1) + .filter(|scheme| !(legacy_only && **scheme == SigningSchemeType::Ecdsa256k1)) .find(|scheme| !presented.contains(&scheme.as_wire())) { return Err(anyhow_tracked(format!( "the response carries no {missing} signature, but {missing} was requested" ))); } - // Bound to the set this verifier response, as we just checked if any schemes were missing. - // The ECDSA entry is the exception; see `sign_result_entries`. - let signed_bytes = if sigs.list.is_empty() { + // Bound to the set the response presents, which may be a superset of `requested` + // and may name schemes this release does not know. The ECDSA entry is the + // exception; see `sign_result_entries`. + let signed_bytes = if presented.is_empty() { Zeroizing::new(Vec::new()) } else { - let presented: Vec = sigs - .list - .iter() - .map(|typed| typed.scheme.try_into()) - .collect::, _>>()?; - scheme_bound_preimage(&presented, payloads.payload) + wire_scheme_bound_preimage(&presented, payloads.payload) .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))? }; let mut verified: Vec = Vec::with_capacity(sigs.list.len() + 1); From acf5743bcf75615441a47a922af0f61bf29be2d0 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 30 Sep 2026 14:33:24 +0200 Subject: [PATCH 086/114] fix: compiler --- .../src/cryptography/signcryption/common.rs | 27 +++-- .../cryptography/signcryption/composite_v1.rs | 95 ++++++++------- .../src/cryptography/signcryption/mod.rs | 95 +++++++++------ .../src/cryptography/signing/composite.rs | 64 ++++++---- .../src/cryptography/signing/verf_key_set.rs | 111 ++++++++++++------ .../src/engine/centralized/central_kms.rs | 6 +- 6 files changed, 245 insertions(+), 153 deletions(-) diff --git a/core/service/src/cryptography/signcryption/common.rs b/core/service/src/cryptography/signcryption/common.rs index 69e7d3a8bc..07c326801f 100644 --- a/core/service/src/cryptography/signcryption/common.rs +++ b/core/service/src/cryptography/signcryption/common.rs @@ -24,17 +24,18 @@ pub(super) fn receiver_enc_key_digest( #[cfg(test)] pub(crate) mod test_support { - use super::super::{SenderAuth, UnifiedSigncryptionKey, UnifiedUnsigncryptionKey}; + use super::super::{UnifiedSigncryptionKey, UnifiedUnsigncryptionKey}; use crate::cryptography::encryption::{ Encryption, PkeScheme, PkeSchemeType, UnifiedPrivateEncKey, UnifiedPublicEncKey, }; use crate::cryptography::signatures::{ - PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet, gen_sig_keys, + PrivateSigKey, PublicSigKey, SigningSchemeType, VerfKeySet, canonical_schemes, gen_sig_keys, }; use crate::cryptography::signing::test_support::seeded_identity; use aes_prng::AesRng; use rand::SeedableRng; use std::sync::Arc; + use strum::IntoEnumIterator; /// One sender, one receiver, and the rng that produced them. pub(crate) struct SigncryptionFixture { @@ -51,36 +52,37 @@ pub(crate) mod test_support { PublicSigKey::from_sk(self.signcryption_key.signing_key()) } - /// The schemes this fixture's reader demands. + /// The schemes this fixture's tests demand of a composite envelope. pub(crate) fn schemes(&self) -> Vec { self.schemes.clone() } - /// A reader deviating from this fixture's in its sender policy alone. - pub(crate) fn reader_for(&self, sender: SenderAuth) -> UnifiedUnsigncryptionKey { - self.reader_with(sender, self.unsigncryption_key.receiver_id.clone()) + /// A reader deviating from this fixture's in the sender keys it holds alone. + pub(crate) fn reader_for(&self, sender_keys: VerfKeySet) -> UnifiedUnsigncryptionKey { + self.reader_with(sender_keys, self.unsigncryption_key.receiver_id.clone()) } /// A reader deviating from this fixture's in its receiver id alone. pub(crate) fn reader_to(&self, receiver_id: Vec) -> UnifiedUnsigncryptionKey { - self.reader_with(self.unsigncryption_key.sender.clone(), receiver_id) + self.reader_with(self.unsigncryption_key.sender_keys.clone(), receiver_id) } fn reader_with( &self, - sender: SenderAuth, + sender_keys: VerfKeySet, receiver_id: Vec, ) -> UnifiedUnsigncryptionKey { UnifiedUnsigncryptionKey { decryption_key: self.unsigncryption_key.decryption_key.clone(), encryption_key: self.unsigncryption_key.encryption_key.clone(), - sender, + sender_keys, receiver_id, } } } - /// A fixture whose reader demands the frozen, single-ECDSA layout. + /// A fixture whose sender publishes a single ECDSA key and writes the frozen + /// layout. pub(crate) fn signcryption_fixture(scheme: PkeSchemeType, seed: u64) -> SigncryptionFixture { let p = fixture_parts(scheme, seed); let sender_verf_key = PublicSigKey::from_sk(&p.signing_key); @@ -112,9 +114,10 @@ pub(crate) mod test_support { // same keys. let mut p = fixture_parts(scheme, seed); let identity = Arc::new(seeded_identity(&mut p.rng)); - let keys = VerfKeySet::from_identity(&identity, schemes).unwrap(); + let every_scheme: Vec<_> = SigningSchemeType::iter().collect(); + let keys = VerfKeySet::from_identity(&identity, &every_scheme).unwrap(); // Canonical order, which is what the reader will demand. - let demanded = keys.schemes(); + let demanded = canonical_schemes(schemes).unwrap(); SigncryptionFixture { signcryption_key: UnifiedSigncryptionKey::new( identity, diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index d1d2bc2532..35f5150870 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -6,9 +6,7 @@ use crate::cryptography::encryption::HasPkeScheme; use crate::cryptography::encryption::UnifiedPublicEncKey; use crate::cryptography::error::CryptographyError; use crate::cryptography::hybrid_ml_kem::HybridKemCt; -#[cfg(feature = "non-wasm")] -use crate::cryptography::signatures::SigningSchemeType; -use crate::cryptography::signatures::{StoredTypedSignature, VerfKeySet}; +use crate::cryptography::signatures::{SigningSchemeType, StoredTypedSignature, VerfKeySet}; #[cfg(feature = "non-wasm")] use crate::cryptography::signcryption::UnifiedSigncryptionKey; use crate::cryptography::signcryption::UnifiedUnsigncryptionKey; @@ -136,10 +134,12 @@ pub(super) fn seal( /// Open a composite signcryption, returning the message only once every /// constituent signature has verified. /// -/// `sender_keys` is the reader's policy the signatures are validated against. +/// The envelope must be signed under exactly `schemes`, each signature checked +/// against its key in `sender_keys`. pub(super) fn open( unsign_key: &UnifiedUnsigncryptionKey, sender_keys: &VerfKeySet, + schemes: &[SigningSchemeType], dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { @@ -158,7 +158,7 @@ pub(super) fn open( &unsign_key.receiver_id, &unsign_key.encryption_key, )?; - let verified = verify_composite(&envelope.signatures, sender_keys, dsep, &signed); + let verified = verify_composite(&envelope.signatures, sender_keys, schemes, dsep, &signed); signed.zeroize(); verified.map_err(|e| CryptographyError::VerificationError(e.to_string()))?; @@ -168,10 +168,9 @@ pub(super) fn open( #[cfg(test)] mod tests { use super::super::common::test_support::{composite_fixture, signcryption_fixture}; - use super::super::{SenderAuth, Signcrypt, Unsigncrypt}; + use super::super::{Signcrypt, Unsigncrypt}; use super::*; use crate::cryptography::encryption::PkeSchemeType; - use crate::cryptography::signatures::UnifiedPublicSigKey; use crate::cryptography::signing::test_support::seeded_identity; use crate::vault::storage::tests::TestType; use aes_prng::AesRng; @@ -197,7 +196,10 @@ mod tests { .unwrap(); assert_eq!(cipher.pke_type, scheme); - let opened: TestType = f.unsigncryption_key.unsigncrypt(DSEP, &cipher).unwrap(); + let opened: TestType = f + .unsigncryption_key + .unsigncrypt_composite(DSEP, &schemes, &cipher) + .unwrap(); assert_eq!(opened, msg, "{scheme}"); } } @@ -219,28 +221,31 @@ mod tests { ) .unwrap(); - let err = f.unsigncryption_key.open(DSEP, &cipher).unwrap_err(); + let err = f + .unsigncryption_key + .open_composite(DSEP, &f.schemes(), &cipher) + .unwrap_err(); assert!( matches!(err, CryptographyError::VerificationError(_)), "{err}" ); - // ...and the same signcryption opens for a verifier that asked for - // exactly what was signed, confirming the rejection above is the policy - // check and not an unrelated failure. - let weaker_keys = VerfKeySet::from_identity(&f.signcryption_key.identity, &weaker).unwrap(); + // ...and the same reader opens it when asked for exactly what was + // signed, confirming the rejection above is the policy check and not an + // unrelated failure. The policy is the call's, not the reader's. let opened = f - .reader_for(SenderAuth::Multi(weaker_keys)) - .open(DSEP, &cipher) + .unsigncryption_key + .open_composite(DSEP, &weaker, &cipher) .unwrap(); assert_eq!(&*opened, b"downgrade me"); } - /// A frozen reader must refuse a composite envelope even when it holds the - /// very ECDSA key that signed it: the payload is a safe-serialized KEM - /// ciphertext that its bincode parse rejects before anything is decrypted. + /// The frozen opener refuses a composite envelope even though the reader + /// holds the very ECDSA key that signed it: the payload is a safe-serialized + /// KEM ciphertext that its bincode parse rejects before anything is + /// decrypted. So calling the wrong method cannot downgrade a composite. #[test] - fn a_frozen_reader_rejects_a_composite_envelope() { + fn the_frozen_opener_rejects_a_composite_envelope() { for (seed, schemes) in [ (300_u64, pair()), (400_u64, vec![SigningSchemeType::Ecdsa256k1]), @@ -256,38 +261,31 @@ mod tests { ) .unwrap(); - // The envelope does open for the reader it was made for, so the + // The envelope does open through the composite opener, so the // rejection below is the format mismatch and not a broken fixture. assert_eq!( - &*f.unsigncryption_key.open(DSEP, &composite).unwrap(), + &*f.unsigncryption_key + .open_composite(DSEP, &demanded, &composite) + .unwrap(), b"composite payload" ); - let legacy_ecdsa = match &f.unsigncryption_key.sender { - SenderAuth::Multi(keys) => { - match keys.require(SigningSchemeType::Ecdsa256k1).unwrap() { - UnifiedPublicSigKey::Ecdsa256k1(key) => key.clone(), - _ => unreachable!("the ECDSA member of the set is an ECDSA key"), - } - } - SenderAuth::Ecdsa(_) => unreachable!("the fixture reader is a composite one"), - }; let err = f - .reader_for(SenderAuth::Ecdsa(legacy_ecdsa)) + .unsigncryption_key .unsigncrypt::(DSEP, &composite) .unwrap_err(); assert!( matches!(err, CryptographyError::BincodeError(_)), - "the frozen reader must reject a composite envelope on the KEM ciphertext, got: {err}" + "the frozen opener must reject a composite envelope on the KEM ciphertext, got: {err}" ); } } /// ...and the converse. The frozen layout writes its `HybridKemCt` with /// bincode and no header, so `safe_deserialize` refuses it, again before any - /// decryption. Independent of which schemes the reader demands. + /// decryption. Independent of which schemes the call demands. #[test] - fn a_composite_reader_rejects_a_frozen_envelope() { + fn the_composite_opener_rejects_a_frozen_envelope() { let f = composite_fixture(PkeSchemeType::MlKem512, 300, &pair()); let mut frozen_f = signcryption_fixture(PkeSchemeType::MlKem512, 300); let frozen = frozen_f @@ -295,10 +293,13 @@ mod tests { .signcrypt(&mut frozen_f.rng, DSEP, &TestType { i: 7 }) .unwrap(); - let err = f.unsigncryption_key.open(DSEP, &frozen).unwrap_err(); + let err = f + .unsigncryption_key + .open_composite(DSEP, &f.schemes(), &frozen) + .unwrap_err(); assert!( matches!(err, CryptographyError::SerializationError(_)), - "the composite reader must reject a frozen envelope on deserialization, got: {err}" + "the composite opener must reject a frozen envelope on deserialization, got: {err}" ); } @@ -318,32 +319,38 @@ mod tests { // The untouched envelope opens, so each rejection below is the // deviation and not an unrelated failure. - f.unsigncryption_key.open(DSEP, &cipher).unwrap(); + f.unsigncryption_key + .open_composite(DSEP, &demanded, &cipher) + .unwrap(); assert!( - f.unsigncryption_key.open(b"OTHERDSP", &cipher).is_err(), + f.unsigncryption_key + .open_composite(b"OTHERDSP", &demanded, &cipher) + .is_err(), "a different domain separator opened the envelope" ); let mut flipped = cipher.clone(); flipped.payload[0] ^= 0x01; assert!( - f.unsigncryption_key.open(DSEP, &flipped).is_err(), + f.unsigncryption_key + .open_composite(DSEP, &demanded, &flipped) + .is_err(), "a tampered ciphertext opened the envelope" ); let mut rng = AesRng::seed_from_u64(999); let other_keys = VerfKeySet::from_identity(&seeded_identity(&mut rng), &demanded).unwrap(); assert!( - f.reader_for(SenderAuth::Multi(other_keys)) - .open(DSEP, &cipher) + f.reader_for(other_keys) + .open_composite(DSEP, &demanded, &cipher) .is_err(), "another party's key set opened the envelope" ); assert!( f.reader_to(b"a different receiver".to_vec()) - .open(DSEP, &cipher) + .open_composite(DSEP, &demanded, &cipher) .is_err(), "a different receiver id opened the envelope" ); @@ -387,8 +394,8 @@ mod tests { let keys = VerfKeySet::from_identity(&f.signcryption_key.identity, &ecdsa_only).unwrap(); let opened = f - .reader_for(SenderAuth::Multi(keys)) - .open(DSEP, &cipher) + .reader_for(keys) + .open_composite(DSEP, &ecdsa_only, &cipher) .unwrap(); assert_eq!(&*opened, b"one signature"); } diff --git a/core/service/src/cryptography/signcryption/mod.rs b/core/service/src/cryptography/signcryption/mod.rs index 3b17a96559..a964e9a142 100644 --- a/core/service/src/cryptography/signcryption/mod.rs +++ b/core/service/src/cryptography/signcryption/mod.rs @@ -11,10 +11,10 @@ //! # Envelope formats //! //! A signcryption's encrypted plaintext has a layout, and there is more than -//! one. **A [`UnifiedSigncryption`] does not say which.** The layout follows from -//! the key material a reader holds, named by [`SenderAuth`]: a single -//! [`PublicSigKey`] can only open the frozen layout, and a [`VerfKeySet`] (defining the choice -//! of signing schemes) can only open the multi-signature one. +//! one. **A [`UnifiedSigncryption`] does not say which.** The code that opens it +//! chooses, by calling [`Unsigncrypt::unsigncrypt`] for the frozen layout or +//! [`Unsigncrypt::unsigncrypt_composite`] for the multi-signature one, just as the +//! writer chooses between `signcrypt` and `signcrypt_composite`. mod common; mod composite_v1; @@ -65,14 +65,24 @@ pub trait Signcrypt { } pub trait Unsigncrypt { - /// Decrypt a signcrypted message and verify the signature before returning the result. - /// If the signature verification fails, an error is returned. + /// Decrypt a signcrypted message in the frozen layout and verify the signature + /// before returning the result. fn unsigncrypt( &self, dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result; + /// Decrypt a signcrypted message in the multi-signature layout, requiring it to + /// be signed under exactly `schemes`, and verify every signature before + /// returning the result. + fn unsigncrypt_composite( + &self, + dsep: &DomainSep, + schemes: &[SigningSchemeType], + cipher: &UnifiedSigncryption, + ) -> Result; + /// Authenticate a signcrypted message and discard the payload. fn validate_signcryption( &self, @@ -158,32 +168,22 @@ impl HasPkeScheme for UnifiedSigncryptionKey { } } -/// What a reader authenticates a signcryption with — and therefore which -/// envelope layout it can open. +/// Who is reading a signcryption, and the keys it authenticates the sender with. /// -/// The two are the same choice, so they are the same value. There is no way to -/// build a key that is ambiguous about the layout it reads, and no layout tag on -/// the message for the two to disagree with. -#[derive(Clone, Debug)] -pub enum SenderAuth { - /// A single ECDSA verification key: the frozen layout. - Ecdsa(PublicSigKey), - /// One verification key per scheme: the multi-signature layout. - Multi(VerfKeySet), -} - -/// Who is reading a signcryption, and what it will authenticate the sender with. +/// Key material only: the layout, and the schemes a composite must carry, are +/// chosen by the method called. #[derive(Clone, Debug)] pub struct UnifiedUnsigncryptionKey { pub decryption_key: Arc, pub encryption_key: UnifiedPublicEncKey, // Needed for validation of the signcrypted payload - pub sender: SenderAuth, + /// Every verification key the sender publishes. + pub sender_keys: VerfKeySet, /// The ID of the receiver of the signcryption, e.g. blockchain address pub receiver_id: Vec, } impl UnifiedUnsigncryptionKey { - /// A reader of the frozen, single-ECDSA layout. + /// A reader for a sender that publishes a single ECDSA key. pub fn new( decryption_key: Arc, encryption_key: UnifiedPublicEncKey, @@ -191,9 +191,9 @@ impl UnifiedUnsigncryptionKey { receiver_id: Vec, ) -> Self { Self { - sender: SenderAuth::Ecdsa(sender_verf_key), decryption_key, encryption_key, + sender_keys: VerfKeySet::ecdsa_only(sender_verf_key), receiver_id, } } @@ -203,38 +203,48 @@ impl UnifiedUnsigncryptionKey { pub fn new_multi( decryption_key: Arc, encryption_key: UnifiedPublicEncKey, - keys: VerfKeySet, + sender_keys: VerfKeySet, receiver_id: Vec, ) -> Self { Self { - sender: SenderAuth::Multi(keys), decryption_key, encryption_key, + sender_keys, receiver_id, } } - /// Decrypt and authenticate `cipher`, returning the message bytes. - /// - /// The layout follows from `sender`; see the module documentation. + /// Decrypt `cipher` in the frozen layout and check it against the sender's + /// ECDSA key. fn open( &self, dsep: &DomainSep, cipher: &UnifiedSigncryption, ) -> Result>, CryptographyError> { - // Neither layout can open a ciphertext written for another KEM, so the - // check is made once here rather than at the head of each opener. + self.check_pke_type(cipher)?; + ecdsa_v0::open(self, self.sender_keys.ecdsa()?, dsep, cipher) + } + + /// Decrypt `cipher` in the multi-signature layout, requiring it to be signed + /// under exactly `schemes`. + fn open_composite( + &self, + dsep: &DomainSep, + schemes: &[SigningSchemeType], + cipher: &UnifiedSigncryption, + ) -> Result>, CryptographyError> { + self.check_pke_type(cipher)?; + composite_v1::open(self, &self.sender_keys, schemes, dsep, cipher) + } + + /// Neither layout can open a ciphertext written for another KEM. + fn check_pke_type(&self, cipher: &UnifiedSigncryption) -> Result<(), CryptographyError> { if cipher.pke_type != self.encryption_key.encryption_scheme_type() { return Err(CryptographyError::VerificationError( "encryption type of cipher does not match the decryption key type".to_string(), )); } - match &self.sender { - SenderAuth::Ecdsa(sender_verf_key) => { - ecdsa_v0::open(self, sender_verf_key, dsep, cipher) - } - SenderAuth::Multi(keys) => composite_v1::open(self, keys, dsep, cipher), - } + Ok(()) } } @@ -456,6 +466,17 @@ impl Unsigncrypt for UnifiedUnsigncryptionKey { .map_err(CryptographyError::SerializationError) } + fn unsigncrypt_composite( + &self, + dsep: &DomainSep, + schemes: &[SigningSchemeType], + cipher: &UnifiedSigncryption, + ) -> Result { + let msg_vec = self.open_composite(dsep, schemes, cipher)?; + safe_deserialize(std::io::Cursor::new(&*msg_vec), SAFE_SER_SIZE_LIMIT) + .map_err(CryptographyError::SerializationError) + } + fn validate_signcryption( &self, dsep: &DomainSep, @@ -636,7 +657,7 @@ mod tests { { let (wrong_verf_key, _) = gen_sig_keys(&mut f.rng); assert!( - f.reader_for(SenderAuth::Ecdsa(wrong_verf_key)) + f.reader_for(VerfKeySet::ecdsa_only(wrong_verf_key)) .unsigncrypt::(DSEP, &correct_cipher) .is_err() ); diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 83189dd264..baef8ae31c 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -45,7 +45,8 @@ //! # What the encoding gives, and what it does not //! //! - Verification is all or nothing. [`verify_composite`] requires the set of -//! entries to equal the set of keys, then checks every entry. +//! entries to equal the set of schemes the verifier requires, then checks +//! every entry. //! - Every component of a [`sign_composite`] signature names its scheme set and the //! type of what it signs, so none of them moves to another set, to another //! usage, or out of the composite. @@ -195,25 +196,26 @@ where } /// Check every signature in `entries` against `keys`, having first checked that -/// they were made under exactly the schemes `keys` holds keys for. +/// they were made under exactly the schemes in `schemes`, given in any order. /// -/// Every signature must verify. `entries` is untrusted: it may come straight +/// `schemes` is the verifier's policy; `keys` is every key the sender publishes +/// and must hold one for each of `schemes`. Every signature must verify. `entries` is untrusted: it may come straight /// from storage or from the network, so its shape is checked here rather than /// assumed. pub fn verify_composite( entries: &[StoredTypedSignature], keys: &VerfKeySet, + schemes: &[SigningSchemeType], dsep: &DomainSep, payload: &T, ) -> Result<(), SigningError> where T: Serialize + Versionize + Named, { - let expected = keys.schemes(); + let expected = canonical_schemes(schemes)?; // The scheme-set comparison happens before any cryptography, so a composite // signature presented with one of its parts removed, reordered or repeated - // is rejected for being the wrong shape. `expected` is canonical and - // non-empty by the `VerfKeySet` invariants. + // is rejected for being the wrong shape. let schemes = entry_schemes(entries); if schemes != expected { return Err(SigningError::UnexpectedSchemeSet { @@ -224,7 +226,6 @@ where let preimage = scheme_bound_preimage(&schemes, payload)?; for entry in entries { let signature = Signature::new(entry.scheme, entry.signature.clone()); - // Cannot fail: `schemes` equals `keys.schemes()` on this path. unified_verify(dsep, &preimage, &signature, keys.require(entry.scheme)?)?; } Ok(()) @@ -322,10 +323,29 @@ mod tests { let (identity, keys, schemes) = setup(1); let sig = sign_composite(&identity, &schemes, DSEP, &msg()).unwrap(); assert_eq!(entry_schemes(&sig), schemes); - verify_composite(&sig, &keys, DSEP, &msg()).unwrap(); + verify_composite(&sig, &keys, &schemes, DSEP, &msg()).unwrap(); let (_, other_keys, _) = setup(7); - assert!(verify_composite(&sig, &other_keys, DSEP, &msg()).is_err()); + assert!(verify_composite(&sig, &other_keys, &schemes, DSEP, &msg()).is_err()); + } + + /// A policy naming a scheme the sender has no key for is refused rather than + /// checked on the keys it has, and so is an empty policy. + #[test] + fn a_policy_the_keys_cannot_cover_is_rejected() { + let (identity, keys, schemes) = setup(11); + let sig = sign_composite(&identity, &schemes, DSEP, &msg()).unwrap(); + + let ecdsa_only = + VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); + assert!(matches!( + verify_composite(&sig, &ecdsa_only, &schemes, DSEP, &msg()), + Err(SigningError::NoVerificationKey(SigningSchemeType::MlDsa87)) + )); + assert!(matches!( + verify_composite(&sig, &keys, &[], DSEP, &msg()), + Err(SigningError::EmptySchemeSet) + )); } /// Removing a signature must not leave something that verifies under the @@ -341,23 +361,21 @@ mod tests { // Against the original policy it is the wrong scheme set... assert!(matches!( - verify_composite(&stripped, &keys, DSEP, &msg()), + verify_composite(&stripped, &keys, &schemes, DSEP, &msg()), Err(SigningError::UnexpectedSchemeSet { .. }) )); - // ...and a verifier downgraded all the way to an ECDSA-only key set — - // the only way to ask for less, now that the key set *is* the policy — - // still rejects it: the surviving signature covers a preimage naming the + // ...and a verifier downgraded all the way to an ECDSA-only policy still + // rejects it: the surviving signature covers a preimage naming the // *pair*, which does not match the single-scheme preimage. - let ecdsa_only = - VerfKeySet::from_identity(&identity, &[SigningSchemeType::Ecdsa256k1]).unwrap(); - assert!(verify_composite(&stripped, &ecdsa_only, DSEP, &msg()).is_err()); + let ecdsa_only = [SigningSchemeType::Ecdsa256k1]; + assert!(verify_composite(&stripped, &keys, &ecdsa_only, DSEP, &msg()).is_err()); // The same mismatch from the other side: the *whole* pair signature - // against that ECDSA-only key set is refused for its shape rather than - // verified on the one entry the set holds a key for. + // against that ECDSA-only policy is refused for its shape rather than + // verified on the one entry the policy names. assert!(matches!( - verify_composite(&sig, &ecdsa_only, DSEP, &msg()), + verify_composite(&sig, &keys, &ecdsa_only, DSEP, &msg()), Err(SigningError::UnexpectedSchemeSet { .. }) )); } @@ -379,7 +397,7 @@ mod tests { ] { assert!( matches!( - verify_composite(&entries, &keys, DSEP, &msg()), + verify_composite(&entries, &keys, &schemes, DSEP, &msg()), Err(SigningError::UnexpectedSchemeSet { .. }) ), "a {case} entry list was not rejected" @@ -397,7 +415,7 @@ mod tests { let mut tampered = base.clone(); tampered[index].signature[0] ^= 0x01; assert!( - verify_composite(&tampered, &keys, DSEP, &msg()).is_err(), + verify_composite(&tampered, &keys, &schemes, DSEP, &msg()).is_err(), "tampering with signature {index} was not detected" ); } @@ -407,8 +425,8 @@ mod tests { fn a_tampered_message_or_dsep_fails() { let (identity, keys, schemes) = setup(5); let sig = sign_composite(&identity, &schemes, DSEP, &msg()).unwrap(); - assert!(verify_composite(&sig, &keys, DSEP, &TestType { i: 4712 }).is_err()); - assert!(verify_composite(&sig, &keys, b"OTHERDSP", &msg()).is_err()); + assert!(verify_composite(&sig, &keys, &schemes, DSEP, &TestType { i: 4712 }).is_err()); + assert!(verify_composite(&sig, &keys, &schemes, b"OTHERDSP", &msg()).is_err()); } /// An identity with no root seed can only do ECDSA, so asking it for the diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 634dbe3c9f..a4cd4bfaaf 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -1,14 +1,17 @@ //! The verification keys one party publishes, one per signature scheme. +use super::ecdsa::PublicSigKey; use super::identity::NodeSigningIdentity; -use super::{HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey}; +use super::{ + HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey, canonical_schemes, +}; use hashing::{DomainSep, hash_element}; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; use tfhe::named::Named; use tfhe_versionable::{Versionize, VersionsDispatch}; -/// Domain separator for the digest that identifies a whole verification-key set. +/// Domain separator for the digest that identifies the keys a signature is made with. const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; /// One party's verification keys, keyed by the scheme each belongs to. @@ -91,6 +94,17 @@ impl VerfKeySet { Ok(Self { keys }) } + /// The set holding nothing but the ECDSA key `key`. + pub fn ecdsa_only(key: PublicSigKey) -> Self { + // Non-empty and correctly filed, so the invariants hold without `new`. + Self { + keys: BTreeMap::from([( + SigningSchemeType::Ecdsa256k1, + UnifiedPublicSigKey::Ecdsa256k1(key), + )]), + } + } + /// The key set `identity` publishes for `schemes`. pub fn from_identity( identity: &NodeSigningIdentity, @@ -120,23 +134,35 @@ impl VerfKeySet { .ok_or(SigningError::NoVerificationKey(scheme)) } - /// The identifier of this key set. - /// - /// A digest over every member key, so changing, adding or removing one - /// changes the identity. - pub fn id(&self) -> Result, SigningError> { - Ok(hash_element(&DSEP_VERF_KEY_SET, &self.canonical_bytes()?)) + /// The ECDSA key of the set, or an error if it holds none. + pub fn ecdsa(&self) -> Result<&PublicSigKey, SigningError> { + match self.require(SigningSchemeType::Ecdsa256k1)? { + UnifiedPublicSigKey::Ecdsa256k1(key) => Ok(key), + // Unreachable: `new` files every key under its own scheme. + _ => Err(SigningError::NoVerificationKey( + SigningSchemeType::Ecdsa256k1, + )), + } + } + + /// The identifier of the keys a signature under `schemes` is made with. + pub fn id(&self, schemes: &[SigningSchemeType]) -> Result, SigningError> { + let schemes = canonical_schemes(schemes)?; + Ok(hash_element( + &DSEP_VERF_KEY_SET, + &self.canonical_bytes(&schemes)?, + )) } - /// The unambiguous byte encoding of this key set, for use inside a digest. - fn canonical_bytes(&self) -> Result, SigningError> { + /// The unambiguous byte encoding of the keys for the canonical `schemes`, + /// for use inside a digest. + fn canonical_bytes(&self, schemes: &[SigningSchemeType]) -> Result, SigningError> { let mut out = Vec::new(); // Bounded by the number of known schemes, so the cast cannot truncate. - out.extend_from_slice(&(self.keys.len() as u32).to_le_bytes()); - // Note that this is deterministic based on the `Ord` implementation of SigningSchemeType. - for (scheme, key) in &self.keys { + out.extend_from_slice(&(schemes.len() as u32).to_le_bytes()); + for &scheme in schemes { out.extend_from_slice(&scheme.tag()); - let bytes = key.digest(); + let bytes = self.require(scheme)?.digest(); out.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); out.extend_from_slice(&bytes); } @@ -168,6 +194,26 @@ mod tests { } } + /// An ECDSA-only set holds and returns exactly its key, and a set without an + /// ECDSA key says so. + #[test] + fn an_ecdsa_only_set_holds_and_returns_its_key() { + let mut rng = AesRng::seed_from_u64(6); + let (pk, _sk) = crate::cryptography::signatures::gen_sig_keys(&mut rng); + let set = VerfKeySet::ecdsa_only(pk.clone()); + assert_eq!(set.schemes(), vec![SigningSchemeType::Ecdsa256k1]); + assert_eq!(set.ecdsa().unwrap(), &pk); + + let identity = seeded_identity(&mut rng); + let no_ecdsa = VerfKeySet::from_identity(&identity, &[SigningSchemeType::MlDsa65]).unwrap(); + assert!(matches!( + no_ecdsa.ecdsa(), + Err(SigningError::NoVerificationKey( + SigningSchemeType::Ecdsa256k1 + )) + )); + } + /// A seedless identity can only do ECDSA, so asking it for more must fail /// rather than yield a smaller set than requested. #[test] @@ -216,8 +262,8 @@ mod tests { let bytes = bc2wrap::serialize(&misfiled).unwrap(); assert!(bc2wrap::deserialize_slice::(&bytes).is_err()); - // The empty set, which would make `verify_composite` accept a signature - // list having checked nothing. + // The empty set, which publishes nothing any signature could be checked + // against. let empty = BTreeMap::::new(); assert!(matches!( VerfKeySet::new(empty.clone()), @@ -240,37 +286,34 @@ mod tests { } } - /// The id names the whole set: swapping any single member key changes it. + /// The id names the selected keys: swapping a selected key, or changing the + /// selection, changes it, while a key outside the selection does not. #[test] - fn id_changes_when_any_member_key_changes() { + fn id_names_exactly_the_selected_keys() { let mut rng = AesRng::seed_from_u64(4); let identity = seeded_identity(&mut rng); let other_identity = seeded_identity(&mut rng); - let schemes: Vec<_> = SigningSchemeType::iter().collect(); + let every_scheme: Vec<_> = SigningSchemeType::iter().collect(); + let selected = [SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]; - let base = VerfKeySet::from_identity(&identity, &schemes).unwrap(); - let base_id = base.id().unwrap(); - assert_eq!(base_id, base.id().unwrap(), "the id is not deterministic"); + let base = VerfKeySet::from_identity(&identity, &every_scheme).unwrap(); + let base_id = base.id(&selected).unwrap(); - for &scheme in &schemes { + for &scheme in &every_scheme { let mut swapped = base.keys.clone(); swapped.insert( scheme, other_identity.unified_verifying_key(scheme).unwrap(), ); - let swapped = VerfKeySet::new(swapped).unwrap(); - assert_ne!( - base_id, - swapped.id().unwrap(), - "swapping the {scheme} key left the set id unchanged" + let swapped_id = VerfKeySet::new(swapped).unwrap().id(&selected).unwrap(); + assert_eq!( + base_id == swapped_id, + !selected.contains(&scheme), + "swapping the {scheme} key had the wrong effect on the id" ); } - // Dropping a scheme is also a different identity, not a weaker form of - // the same one. - let mut fewer = base.keys.clone(); - fewer.remove(&SigningSchemeType::MlDsa87); - let fewer = VerfKeySet::new(fewer).unwrap(); - assert_ne!(base_id, fewer.id().unwrap()); + assert_ne!(base_id, base.id(&[SigningSchemeType::Ecdsa256k1]).unwrap()); + assert_ne!(base_id, base.id(&every_scheme).unwrap()); } } diff --git a/core/service/src/engine/centralized/central_kms.rs b/core/service/src/engine/centralized/central_kms.rs index 1f62025d61..46a4c3d7b6 100644 --- a/core/service/src/engine/centralized/central_kms.rs +++ b/core/service/src/engine/centralized/central_kms.rs @@ -1179,10 +1179,10 @@ pub(crate) mod tests { DEFAULT_EPOCH_ID, DEFAULT_PARAM, OTHER_CENTRAL_TEST_ID, TEST_CENTRAL_KEY_ID, TEST_PARAM, }; use crate::cryptography::error::CryptographyError; - use crate::cryptography::signatures::PublicSigKey; use crate::cryptography::signatures::gen_sig_keys; + use crate::cryptography::signatures::{PublicSigKey, VerfKeySet}; use crate::cryptography::signcryption::{ - SenderAuth, UnsigncryptFHEPlaintext, ephemeral_signcryption_key_generation, + UnsigncryptFHEPlaintext, ephemeral_signcryption_key_generation, }; use crate::cryptography::signing::identity::NodeSigningIdentity; use crate::dummy_domain; @@ -1858,7 +1858,7 @@ pub(crate) mod tests { if sim_type == SimulationType::BadSigKey { // Change the signing key let (server_sig_pk, _server_sig_sk) = gen_sig_keys(&mut rng); - keys.unsigncryption_key.sender = SenderAuth::Ecdsa(server_sig_pk); + keys.unsigncryption_key.sender_keys = VerfKeySet::ecdsa_only(server_sig_pk); } keys }; From d0f3b5475540cf83b5f4e2355c6021e04d92e327 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 30 Sep 2026 18:14:00 +0200 Subject: [PATCH 087/114] chore: simplification of logic --- core-client/src/decrypt.rs | 33 +- core/service/src/client/client_non_wasm.rs | 45 +- core/service/src/client/public_decryption.rs | 45 +- .../centralized/public_decryption_tests.rs | 2 +- .../src/client/tests/threshold/misc_tests.rs | 2 +- .../threshold/public_decryption_tests.rs | 8 +- .../src/client/user_decryption_wasm.rs | 8 +- .../src/cryptography/signing/composite.rs | 34 +- .../engine/storage_material_verification.rs | 71 +- .../service/src/engine/validation_non_wasm.rs | 223 +++--- core/service/src/engine/validation_wasm.rs | 661 +++++++++--------- 11 files changed, 518 insertions(+), 614 deletions(-) diff --git a/core-client/src/decrypt.rs b/core-client/src/decrypt.rs index 8876a6794c..c48a0e6af6 100644 --- a/core-client/src/decrypt.rs +++ b/core-client/src/decrypt.rs @@ -2177,10 +2177,16 @@ fn verify_public_decrypt_responses( kms_addrs: &[alloy_primitives::Address], num_expected_responses: usize, ) -> anyhow::Result<()> { - // Resolve the verification material into (domain, external handles, extra_data) plus the - // optional original request used for the internal request-binding check. - let (domain, external_handles, extra_data, request) = match verification { + // Resolve the verification material into (domain, external handles, extra_data). + let (domain, external_handles, extra_data) = match verification { PubDecVerificationMaterial::Request(decryption_request) => { + // With the request at hand, the responses are also validated against it: + // bound to it, and accepted by majority. + internal_client.process_decryption_resp( + &decryption_request, + num_expected_responses as u32, + resp_response_vec, + )?; let domain_msg = decryption_request .domain .as_ref() @@ -2192,19 +2198,13 @@ fn verify_public_decrypt_responses( .iter() .map(|ct| ct.external_handle.clone()) .collect(); - let extra_data = decryption_request.extra_data.clone(); - ( - domain, - external_handles, - extra_data, - Some(decryption_request), - ) + (domain, external_handles, decryption_request.extra_data) } PubDecVerificationMaterial::External { domain, external_handles, extra_data, - } => (domain, external_handles, extra_data, None), + } => (domain, external_handles, extra_data), }; // If an expected answer is provided, use it; otherwise consider the first answer. @@ -2222,15 +2222,8 @@ fn verify_public_decrypt_responses( .clone(), }; - // check the internal signatures (verifies responses are signed by the trusted KMS keys; - // request-binding only applies for the `Request` variant) - internal_client.process_decryption_resp( - request, - num_expected_responses as u32, - resp_response_vec, - )?; - - // check the per-scheme signatures + // Check every response's signatures, signer and plaintext. For `External` material this + // is the only check, since without the request there is nothing to bind the responses to. check_external_decryption_signature( resp_response_vec, ptxt, diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index c9f8cece32..c19a4666ed 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -155,7 +155,7 @@ impl Client { dsep, internal_bytes: &[], payload, - eip712_hash: Some(sol_type.eip712_signing_hash(domain)), + eip712_hash: sol_type.eip712_signing_hash(domain), }, &self.signing_schemes, &ExpectedSigner::Discover { @@ -382,36 +382,25 @@ mod tests { assert!(verify_with_legacy(&client, &[], &[0u8; 65], &payload()).is_err()); } - /// The legacy signature is checked *alongside* the list, not instead of it. A result - /// whose list verifies but whose deprecated field does not is still rejected: the two - /// are independent statements about the same result, and they have to agree. + /// The legacy signature is only checked for a node from before the list. Once the + /// list carries the ECDSA signature, the legacy field beside it is not checked; without + /// the list, a bad legacy signature is a rejection. #[test] - fn a_bad_legacy_signature_is_rejected_even_when_the_list_verifies() { + fn a_bad_legacy_signature_is_ignored_once_the_list_carries_ecdsa() { let identity = seeded_identity(16); let client = client_for(&identity, &[]); let signatures = signatures_for(&identity, &[SigningSchemeType::Ecdsa256k1], &payload()); + let bad_legacy = [ + vec![0xAA; 65], + legacy_external_signature(&seeded_identity(17)), + ]; - // Both copies present and agreeing is the honest case. - let (party_id, _address) = verify_with_legacy( - &client, - &signatures, - &legacy_external_signature(&identity), - &payload(), - ) - .unwrap(); - assert_eq!(party_id, PARTY); - - // A garbage legacy signature is a rejection, and so is one of another party. - assert!(verify_with_legacy(&client, &signatures, &[0xAA; 65], &payload()).is_err()); - assert!( - verify_with_legacy( - &client, - &signatures, - &legacy_external_signature(&seeded_identity(17)), - &payload(), - ) - .is_err() - ); + for legacy in &bad_legacy { + let (party_id, _address) = + verify_with_legacy(&client, &signatures, legacy, &payload()).unwrap(); + assert_eq!(party_id, PARTY); + assert!(verify_with_legacy(&client, &[], legacy, &payload()).is_err()); + } } /// A MlDsa65 signature is attributed to the correct signing party, provided @@ -536,8 +525,8 @@ mod tests { .unwrap_err() .to_string(); assert!( - err.contains("party 1 signed under Ed25519") - && err.contains("no Ed25519 verification key is known"), + err.contains("party 1") + && err.contains("no party published a Ed25519 verification key"), "the error does not name the missing key: {err}" ); } diff --git a/core/service/src/client/public_decryption.rs b/core/service/src/client/public_decryption.rs index f6dddd3ecc..97d0532878 100644 --- a/core/service/src/client/public_decryption.rs +++ b/core/service/src/client/public_decryption.rs @@ -47,26 +47,16 @@ impl Client { } /// Validates the aggregated decryption response `agg_resp` against the - /// original `DecryptionRequest` `request`, and returns the decrypted - /// plaintext if valid and at least `min_agree_count` agree on the result. - /// - /// __NOTE__: If the original request is not provided, we can __not__ check - /// that the response correctly contains the digest of the request. + /// original `request`, and returns the decrypted plaintext if valid and at + /// least `min_agree_count` agree on the result. /// /// # Arguments /// /// All arguments except `agg_resp` are **trusted** (client-side state): /// /// * `request` — The original public decryption request constructed by this - /// client. Used to verify that the server responses match the request - /// (digest, ciphertext handles, domain). - /// - /// Passing `None` skips the request-level checks, and with them the EIP-712 - /// domain, so neither `external_signature` nor the ECDSA entry of - /// `signatures` can be checked. A response is then authenticated by the - /// deprecated internal `signature`, which covers the serialized payload and - /// needs no domain. That is enough for a caller that only wants to inspect a - /// result. + /// client. The responses are verified against its EIP-712 domain, ciphertext + /// handles, extra data and signing schemes, and bound to it. /// * `min_agree_count` — Minimum number of server responses that must agree /// on the same plaintext for the result to be accepted. /// @@ -76,30 +66,23 @@ impl Client { /// (signatures, digest matching, majority agreement) before use. pub fn process_decryption_resp( &self, - request: Option, + request: &PublicDecryptionRequest, min_agree_count: u32, agg_resp: &[PublicDecryptionResponse], ) -> anyhow::Result> { - let eip712_domain = match &request { - Some(req) => Some(optional_protobuf_to_alloy_domain(req.domain.as_ref())?), - None => None, - }; - let ext_handles_bytes: Vec> = match &request { - Some(req) => req - .ciphertexts - .iter() - .map(|c| c.external_handle.clone()) - .collect(), - None => vec![], - }; - let extra_data = request.as_ref().map(|req| req.extra_data.as_slice()); + let eip712_domain = optional_protobuf_to_alloy_domain(request.domain.as_ref())?; + let ext_handles_bytes: Vec> = request + .ciphertexts + .iter() + .map(|c| c.external_handle.clone()) + .collect(); let trusted_ctx = PublicDecTrustedValidationContext::new( self.get_server_pks()?, &self.scheme_verf_keys, - eip712_domain.as_ref(), + &eip712_domain, &ext_handles_bytes, - extra_data, - request.as_ref(), + &request.extra_data, + Some(request), )?; // Partition the untrusted responses and enforce the majority threshold. Partitioning is diff --git a/core/service/src/client/tests/centralized/public_decryption_tests.rs b/core/service/src/client/tests/centralized/public_decryption_tests.rs index ce56ddc351..52b4d33d60 100644 --- a/core/service/src/client/tests/centralized/public_decryption_tests.rs +++ b/core/service/src/client/tests/centralized/public_decryption_tests.rs @@ -287,7 +287,7 @@ pub(crate) async fn run_decryption_centralized( assert_eq!(responses.len(), 1); let received_plaintexts = internal_client - .process_decryption_resp(Some(req.clone()), 1, &responses) + .process_decryption_resp(&req, 1, &responses) .unwrap(); // we need 1 plaintext for each ciphertext in the batch diff --git a/core/service/src/client/tests/threshold/misc_tests.rs b/core/service/src/client/tests/threshold/misc_tests.rs index d1061ad43d..503dec05c1 100644 --- a/core/service/src/client/tests/threshold/misc_tests.rs +++ b/core/service/src/client/tests/threshold/misc_tests.rs @@ -559,7 +559,7 @@ async fn test_complete_session_notification() -> Result<()> { let threshold = max_threshold(amount_parties); let min_count_agree = (threshold + 1) as u32; let received_plaintexts = internal_client - .process_decryption_resp(Some(req.clone()), min_count_agree, &responses) + .process_decryption_resp(&req, min_count_agree, &responses) .unwrap(); // check that the plaintexts are correct diff --git a/core/service/src/client/tests/threshold/public_decryption_tests.rs b/core/service/src/client/tests/threshold/public_decryption_tests.rs index 555461364d..b1c976ebe8 100644 --- a/core/service/src/client/tests/threshold/public_decryption_tests.rs +++ b/core/service/src/client/tests/threshold/public_decryption_tests.rs @@ -447,7 +447,7 @@ pub async fn run_decryption_threshold_optionally_fail( let threshold = max_threshold(amount_parties); let min_count_agree = (threshold + 1) as u32; let received_plaintexts = internal_client - .process_decryption_resp(Some(req.clone()), min_count_agree, &responses) + .process_decryption_resp(&req, min_count_agree, &responses) .unwrap(); // we need 1 plaintext for each ciphertext in the batch @@ -466,11 +466,7 @@ pub async fn run_decryption_threshold_optionally_fail( responses_wo_internal_sig[0].signature = vec![]; assert_eq!( internal_client - .process_decryption_resp( - Some(req.clone()), - min_count_agree, - &responses_wo_internal_sig - ) + .process_decryption_resp(&req, min_count_agree, &responses_wo_internal_sig) .unwrap(), received_plaintexts ); diff --git a/core/service/src/client/user_decryption_wasm.rs b/core/service/src/client/user_decryption_wasm.rs index 678efd5730..7137c1ca5b 100644 --- a/core/service/src/client/user_decryption_wasm.rs +++ b/core/service/src/client/user_decryption_wasm.rs @@ -263,12 +263,6 @@ impl Client { )); } - // A response has to carry at least one of the two deprecated fields until 0.16, - // so that a node from a release before `signatures` stays verifiable. - if resp.signature.is_empty() && resp.external_signature.is_empty() { - return Err(anyhow_error_and_log("empty signature")); - } - let response_bytes = bc2wrap::serialize(&payload)?; verify_response_signatures( &ResponseSignatures { @@ -280,7 +274,7 @@ impl Client { dsep: &DSEP_USER_DECRYPTION, internal_bytes: &response_bytes, payload: &user_dec_payload(&response_bytes, &resp.extra_data), - eip712_hash: Some(user_decrypt_eip712_hash(&payload, request, eip712_domain)?), + eip712_hash: user_decrypt_eip712_hash(&payload, request, eip712_domain)?, }, request.signing_schemes(), &ExpectedSigner::Known { diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index baef8ae31c..e3d1cb5cb7 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -195,13 +195,28 @@ where .collect() } +/// Verify each of `entries` against its key in `keys`, over `preimage`. +pub fn verify_scheme_bound_entries<'a>( + entries: impl IntoIterator, + keys: &VerfKeySet, + dsep: &DomainSep, + preimage: &[u8], +) -> Result<(), SigningError> { + for (scheme, signature) in entries { + let key = keys.require(scheme)?; + let signature = Signature::new(scheme, signature.to_vec()); + unified_verify(dsep, preimage, &signature, key)?; + } + Ok(()) +} + /// Check every signature in `entries` against `keys`, having first checked that /// they were made under exactly the schemes in `schemes`, given in any order. /// /// `schemes` is the verifier's policy; `keys` is every key the sender publishes -/// and must hold one for each of `schemes`. Every signature must verify. `entries` is untrusted: it may come straight -/// from storage or from the network, so its shape is checked here rather than -/// assumed. +/// and must hold one for each of `schemes`. Every signature must verify. +/// `entries` is untrusted: it may come straight from storage or from the +/// network, so its shape is checked here rather than assumed. pub fn verify_composite( entries: &[StoredTypedSignature], keys: &VerfKeySet, @@ -224,11 +239,14 @@ where }); } let preimage = scheme_bound_preimage(&schemes, payload)?; - for entry in entries { - let signature = Signature::new(entry.scheme, entry.signature.clone()); - unified_verify(dsep, &preimage, &signature, keys.require(entry.scheme)?)?; - } - Ok(()) + verify_scheme_bound_entries( + entries + .iter() + .map(|entry| (entry.scheme, entry.signature.as_slice())), + keys, + dsep, + &preimage, + ) } /// The per-scheme signatures of a *result*: a keygen, CRS, preprocessing or diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index 36586c48a5..1a6a463ea9 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -35,13 +35,12 @@ use crate::backup::operator::RecoveryValidationMaterial; use crate::consts::{SIGNING_KEY_ID, signing_material_id}; +use crate::cryptography::signing::composite::{scheme_bound_preimage, verify_scheme_bound_entries}; use crate::cryptography::signing::ecdsa::{ PrivateSigKey, PublicSigKey, recover_address_from_eip712_hash, }; use crate::cryptography::signing::identity::NodeSigningIdentity; -use crate::cryptography::signing::{ - Signature, SigningSchemeType, StoredTypedSignature, unified_verify, -}; +use crate::cryptography::signing::{SigningSchemeType, StoredTypedSignature, VerfKeySet}; use crate::engine::base::{ CrsGenMetadata, CrsGenMetadataInner, CurrentPublicMaterialLayout, DSEP_PUBDATA_CRS, DSEP_PUBDATA_KEY, KeyGenMetadata, KeyGenMetadataInner, classify_current_public_material, @@ -549,41 +548,45 @@ where return Ok(()); } - // The set is derived from the stored entries rather than requested from - // outside: at boot there is no request to measure against. - let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); - let signed_bytes = - crate::cryptography::signing::composite::scheme_bound_preimage(&stored_schemes, payload)?; - + let mut keys = BTreeMap::new(); + let mut scheme_entries = Vec::new(); for stored in signatures { - match stored.scheme { - SigningSchemeType::Ecdsa256k1 => { - if let Some(hash) = eip712_hash { - verify_eip712_metadata_signature( - metadata_kind, - metadata_id, - hash, - &stored.signature, - expected_address, - )?; - } - } - scheme => { - let verf_key = identity.unified_verifying_key(scheme).map_err(|e| { - anyhow::anyhow!( - "Private {metadata_kind} metadata for id={metadata_id} carries a {scheme} signature, but this node cannot derive the {scheme} verification key to check it against: {e}" - ) - })?; - let signature = Signature::new(scheme, stored.signature.clone()); - unified_verify(dsep, &signed_bytes, &signature, &verf_key).map_err(|e| { - anyhow::anyhow!( - "Invalid {scheme} signature in private {metadata_kind} metadata for id={metadata_id}: {e}" - ) - })?; + let scheme = stored.scheme; + if scheme == SigningSchemeType::Ecdsa256k1 { + if let Some(hash) = eip712_hash { + verify_eip712_metadata_signature( + metadata_kind, + metadata_id, + hash, + &stored.signature, + expected_address, + )?; } + continue; } + let verf_key = identity.unified_verifying_key(scheme).map_err(|e| { + anyhow::anyhow!( + "Private {metadata_kind} metadata for id={metadata_id} carries a {scheme} signature, but this node cannot derive the {scheme} verification key to check it against: {e}" + ) + })?; + keys.insert(scheme, verf_key); + scheme_entries.push((scheme, stored.signature.as_slice())); } - Ok(()) + if scheme_entries.is_empty() { + return Ok(()); + } + + // The set is derived from the stored entries rather than requested from + // outside: at boot there is no request to measure against. + let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); + let preimage = scheme_bound_preimage(&stored_schemes, payload)?; + verify_scheme_bound_entries(scheme_entries, &VerfKeySet::new(keys)?, dsep, &preimage).map_err( + |e| { + anyhow::anyhow!( + "Invalid signature in private {metadata_kind} metadata for id={metadata_id}: {e}", + ) + }, + ) } fn verify_eip712_metadata_signature( diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 4b03fdffef..5b518135e3 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -53,9 +53,10 @@ pub(crate) struct PublicDecTrustedValidationContext<'a> { /// client built without storage access, such as the browser, supplies an /// empty map and can then only check ECDSA. scheme_verf_keys: &'a SchemeVerfKeys, - eip712_domain: Option<&'a Eip712Domain>, + /// Every result is signed under an EIP-712 domain, so the verifier always has one. + eip712_domain: &'a Eip712Domain, ext_handles_bytes: &'a [Vec], - extra_data: Option<&'a [u8]>, + extra_data: &'a [u8], request: Option<&'a PublicDecryptionRequest>, } @@ -63,9 +64,9 @@ impl<'a> PublicDecTrustedValidationContext<'a> { pub fn new( server_pks: &'a HashMap, scheme_verf_keys: &'a SchemeVerfKeys, - eip712_domain: Option<&'a Eip712Domain>, + eip712_domain: &'a Eip712Domain, ext_handles_bytes: &'a [Vec], - extra_data: Option<&'a [u8]>, + extra_data: &'a [u8], request: Option<&'a PublicDecryptionRequest>, ) -> anyhow::Result { // Sanity check uniqueness of server public keys. This is a trusted context, so if the server keys are not unique, it is a configuration error. @@ -485,19 +486,12 @@ fn check_public_decrypt_signatures( let response_bytes = bc2wrap::serialize(&response)?; let payload = public_dec_payload(&response_bytes, response_extra_data); - // Built only when a domain is available: without one no ECDSA signature of this - // response can be checked, and the message would be of no use. - let eip712_hash = match trusted_ctx.eip712_domain { - Some(domain) => Some( - compute_public_decryption_message( - trusted_ctx.ext_handles_bytes, - &response.plaintexts, - response_extra_data, - )? - .eip712_signing_hash(domain), - ), - None => None, - }; + let eip712_hash = compute_public_decryption_message( + trusted_ctx.ext_handles_bytes, + &response.plaintexts, + response_extra_data, + )? + .eip712_signing_hash(trusted_ctx.eip712_domain); verify_response_signatures( &ResponseSignatures { @@ -675,9 +669,7 @@ fn authenticate_public_decrypt_response( tracing::warn!("A request ID must be present!"); return Err(PublicRejectReason::MissingRequestId); } - if let Some(expected_extra_data) = trusted_ctx.extra_data - && cur_resp.extra_data != expected_extra_data - { + if cur_resp.extra_data != trusted_ctx.extra_data { tracing::warn!("Extra data mismatch in public decryption!"); return Err(PublicRejectReason::ExtraDataMismatch); } @@ -1728,9 +1720,9 @@ mod tests { let ctx = PublicDecTrustedValidationContext::new( &server_pks, &scheme_verf_keys, - Some(&domain), + &domain, + &[], &[], - None, None, ) .unwrap(); @@ -1836,9 +1828,9 @@ mod tests { let trusted_ctx = PublicDecTrustedValidationContext { server_pks: &pks, - eip712_domain: Some(&alloy_domain), + eip712_domain: &alloy_domain, ext_handles_bytes: &ext_handles_bytes, - extra_data: Some(&extra_data_0), + extra_data: &extra_data_0, request: None, scheme_verf_keys: &HashMap::new(), }; @@ -2059,9 +2051,9 @@ mod tests { let trusted_ctx = PublicDecTrustedValidationContext { server_pks: &pks, - eip712_domain: Some(&alloy_domain), + eip712_domain: &alloy_domain, ext_handles_bytes: &ext_handles_bytes, - extra_data: Some(&extra_data), + extra_data: &extra_data, request: Some(&request), scheme_verf_keys: &HashMap::new(), }; @@ -2112,9 +2104,9 @@ mod tests { }; let bad_ctx = PublicDecTrustedValidationContext { server_pks: &pks, - eip712_domain: Some(&alloy_domain), + eip712_domain: &alloy_domain, ext_handles_bytes: &ext_handles_bytes, - extra_data: Some(&extra_data), + extra_data: &extra_data, request: Some(&bad_request), scheme_verf_keys: &HashMap::new(), }; @@ -2163,9 +2155,9 @@ mod tests { }; let bad_ctx = PublicDecTrustedValidationContext { server_pks: &pks, - eip712_domain: Some(&alloy_domain), + eip712_domain: &alloy_domain, ext_handles_bytes: &ext_handles_bytes, - extra_data: Some(&extra_data), + extra_data: &extra_data, request: Some(&bad_request), scheme_verf_keys: &HashMap::new(), }; @@ -2177,18 +2169,19 @@ mod tests { ); } - // request is empty, which should pass + // Without the request the responses are not bound to it, but are still + // verified against the domain, the handles and the extra data it would carry. { let agg_resp = vec![resp0.clone(), resp1.clone()]; - let none_ctx = PublicDecTrustedValidationContext { + let external_ctx = PublicDecTrustedValidationContext { server_pks: &pks, - eip712_domain: None, - ext_handles_bytes: &[], - extra_data: None, + eip712_domain: &alloy_domain, + ext_handles_bytes: &ext_handles_bytes, + extra_data: &extra_data, request: None, scheme_verf_keys: &HashMap::new(), }; - validate_public_decrypt_responses(&none_ctx, 2, &agg_resp).unwrap(); + validate_public_decrypt_responses(&external_ctx, 2, &agg_resp).unwrap(); } // happy path @@ -2244,114 +2237,60 @@ mod tests { let server_pks = HashMap::from([(1u32, vk_of(&pivot))]); let scheme_verf_keys = HashMap::new(); - let ctx = |domain| { - PublicDecTrustedValidationContext::new( - &server_pks, - &scheme_verf_keys, - domain, - &ext_handles_bytes, - None, - None, - ) - .unwrap() - }; - let verify = |ctx: &PublicDecTrustedValidationContext, sigs: &[TypedSignature]| { + let ctx = PublicDecTrustedValidationContext::new( + &server_pks, + &scheme_verf_keys, + &alloy_domain, + &ext_handles_bytes, + &extra_data, + None, + ) + .unwrap(); + let verify = |internal: &[u8], external: &[u8], list: &[TypedSignature]| { verify_public_decrypt_signatures( - ctx, + &ctx, &pivot, 1, &vk_of(&pivot), - &[], - &[], - sigs, + internal, + external, + list, &extra_data, ) }; // an empty list, with no internal field to fall back on - assert!(!verify(&ctx(Some(&alloy_domain)), &[])); + assert!(!verify(&[], &[], &[])); // a tampered ECDSA signature recovers to another address - assert!(!verify(&ctx(Some(&alloy_domain)), &tampered)); - - // without a domain the EIP-712 message cannot be rebuilt, so the only entry - // there is gets passed over and the requested ECDSA is left unverified - assert!(!verify(&ctx(None), &signatures)); + assert!(!verify(&[], &[], &tampered)); // happy path - assert!(verify(&ctx(Some(&alloy_domain)), &signatures)); + assert!(verify(&[], &[], &signatures)); - // The deprecated internal fields authenticate the same response. The raw ECDSA - // signature needs no domain. - let domainless = ctx(None); - assert!(verify_public_decrypt_signatures( - &domainless, - &pivot, - 1, - &vk_of(&pivot), - &signed.signature, - &[], - &[], - &extra_data, - )); - // `external_signature` does the same, but only with a domain to rebuild the - // EIP-712 message from. - let with_domain = ctx(Some(&alloy_domain)); - assert!(verify_public_decrypt_signatures( - &with_domain, - &pivot, - 1, - &vk_of(&pivot), - &[], - &signed.external_signature, - &[], - &extra_data, - )); - assert!(!verify_public_decrypt_signatures( - &domainless, - &pivot, - 1, - &vk_of(&pivot), - &[], - &signed.external_signature, - &[], - &extra_data, - )); - // With a domain the internal signature does not stand in for the EIP-712 forms: - // it covers the payload alone, not the handles or the extra data the EIP-712 - // message binds, so the requested ECDSA is left unverified. - assert!(!verify_public_decrypt_signatures( - &with_domain, - &pivot, - 1, - &vk_of(&pivot), - &signed.signature, - &[], - &[], - &extra_data, - )); - // A corrupt internal signature is a rejection, not something the list can - // paper over. + // A node from before the list is authenticated by `external_signature`. + assert!(verify(&[], &signed.external_signature, &[])); + + // The internal signature does not stand in for the EIP-712 forms: it covers + // the payload alone, not the handles or the extra data the EIP-712 message + // binds, so it only confirms the party and the requested ECDSA is left + // unverified. + assert!(!verify(&signed.signature, &[], &[])); + + // For a node from before the list, a corrupt internal signature is a rejection + // even beside a valid `external_signature`... let mut bad_internal = signed.signature.clone(); bad_internal[0] ^= 1; - assert!(!verify_public_decrypt_signatures( - &with_domain, - &pivot, - 1, - &vk_of(&pivot), - &bad_internal, - &[], - &signatures, - &extra_data, - )); + assert!(!verify(&bad_internal, &signed.external_signature, &[])); + // ...but once the list carries ECDSA, the deprecated fields are not checked. + assert!(verify(&bad_internal, &[], &signatures)); } - /// The EIP-712 domain is a precondition of the ECDSA entry alone, so a - /// request that asked only for a post-quantum scheme is verified without one, - /// while a request that also asked for ECDSA needs both the domain and the - /// ECDSA entry. + /// A request that asked only for a post-quantum scheme is satisfied by that entry + /// of the list, a request that also asked for ECDSA needs the ECDSA entry too, and + /// the list may be a superset of what was asked for. #[test] - fn test_public_decrypt_signatures_without_an_eip712_domain() { + fn test_public_decrypt_signatures_per_scheme() { use crate::cryptography::signatures::RootSigningSeed; let mut rng = AesRng::seed_from_u64(77); @@ -2389,6 +2328,7 @@ mod tests { .map(TypedSignature::from) .collect(); + let domain = dummy_domain(); let server_pks = HashMap::from([(1u32, vk.clone())]); let scheme_verf_keys = HashMap::from([( 1u32, @@ -2398,14 +2338,14 @@ mod tests { signing_schemes: schemes, ..Default::default() }; - // No domain in either context: what differs is only what was asked for. + // What differs between the contexts is only what was asked for. let ctx_for = |request| { PublicDecTrustedValidationContext::new( &server_pks, &scheme_verf_keys, - None, + &domain, &[], - None, + &extra_data, request, ) .unwrap() @@ -2428,7 +2368,7 @@ mod tests { let pq_only = request_for(vec![kms_grpc::kms::v1::SigningSchemeType::Mldsa65 as i32]); let pq_ctx = ctx_for(Some(&pq_only)); - // No domain, yet the post-quantum entry the request asked for verifies. + // The post-quantum entry the request asked for verifies on its own. assert!(verify(&pq_ctx, &extra_data)); // The extra data is part of what that entry covers. @@ -2443,9 +2383,8 @@ mod tests { let composite_ctx = ctx_for(Some(&composite)); assert!(!verify(&composite_ctx, &extra_data)); - // Neither is an absent request, which names nothing and so means ECDSA: - // the domain that entry needs is missing, and the list has no ECDSA entry - // to check in any case. + // Neither is an absent request, which names nothing and so means ECDSA, for + // which the list has no entry. let no_request_ctx = ctx_for(None); assert!(!verify(&no_request_ctx, &extra_data)); @@ -2482,9 +2421,9 @@ mod tests { let superset_ctx = PublicDecTrustedValidationContext::new( &server_pks, &superset_keys, - None, + &domain, &[], - None, + &extra_data, Some(&superset_request), ) .unwrap(); @@ -2690,9 +2629,17 @@ mod tests { let (vk0, _sk0) = gen_sig_keys(&mut rng); let (vk1, _sk1) = gen_sig_keys(&mut rng); let server_pks = HashMap::from([(1, vk0.clone()), (2, vk1.clone())]); + let domain = dummy_domain(); - PublicDecTrustedValidationContext::new(&server_pks, &HashMap::new(), None, &[], None, None) - .unwrap(); + PublicDecTrustedValidationContext::new( + &server_pks, + &HashMap::new(), + &domain, + &[], + &[], + None, + ) + .unwrap(); // Error if the server_pks has duplicate keys let server_pks = HashMap::from([(1, vk1.clone()), (2, vk1)]); @@ -2700,9 +2647,9 @@ mod tests { PublicDecTrustedValidationContext::new( &server_pks, &HashMap::new(), - None, + &domain, + &[], &[], - None, None ) .is_err() diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 47ac4d11f8..91b6bfec0e 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -1,4 +1,6 @@ -use crate::cryptography::signing::composite::wire_scheme_bound_preimage; +use crate::cryptography::signing::composite::{ + verify_scheme_bound_entries, wire_scheme_bound_preimage, +}; use crate::cryptography::signing::ecdsa::recover_address_from_eip712_hash; use crate::{ anyhow_error_and_log, anyhow_tracked, @@ -6,7 +8,7 @@ use crate::{ cryptography::{ compute_user_decrypt_message, signatures::{PublicSigKey, Signature, internal_verify_sig}, - signing::{SchemeVerfKeys, SigningSchemeType, unified_verify, verf_key_for}, + signing::{SchemeVerfKeys, SigningError, SigningSchemeType}, }, engine::signed_payload::user_dec_payload, }; @@ -15,10 +17,9 @@ use alloy_primitives::{Address, B256}; use alloy_sol_types::SolStruct; use hashing::DomainSep; use kms_grpc::kms::v1::{TypedSignature, UserDecryptionResponse, UserDecryptionResponsePayload}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use tfhe::FheTypes; use threshold_types::role::Role; -use zeroize::Zeroizing; pub(crate) const DSEP_USER_DECRYPTION: DomainSep = *b"USER_DEC"; @@ -158,8 +159,6 @@ pub(crate) struct Eip712VerificationParams<'a> { pub trusted_eip712_domain: &'a Eip712Domain, } -const ERR_VALIDATE_USER_DECRYPTION_MISSING_SIGNATURE: &str = - "Missing signature in user decryption response"; const ERR_VALIDATE_USER_DECRYPTION_ID_NOT_FOUND: &str = "ID claimed in payload not found"; const ERR_VALIDATE_USER_DECRYPTION_WRONG_ADDRESS: &str = "ID or address claimed in payload is incorrect"; @@ -182,83 +181,11 @@ pub(crate) fn user_decrypt_eip712_hash( Ok(message.eip712_signing_hash(eip712_domain)) } -/// Verify one non-ECDSA entry of a result's `signatures` list against the key -/// `party_id` published for that scheme. -/// -/// A scheme the verifier holds no key for is a rejection rather than a skip: -/// accepting an entry nobody can check would let a party satisfy a requested -/// scheme without producing a valid signature for it. -/// -/// ECDSA is not handled here — it signs the EIP-712 hash rather than -/// `signed_bytes`, and what that hash covers differs per result kind. -/// -/// The error is returned **unlogged**: whether a failure here is a fault or an -/// expected Byzantine rejection is the caller's to know, and so is the level it -/// deserves. Public-decryption validation logs it at `warn!`, the client paths at -/// `error!`. -pub(crate) fn verify_scheme_entry( - keys: &SchemeVerfKeys, - party_id: u32, - scheme: SigningSchemeType, - signature: &[u8], - dsep: &DomainSep, - signed_bytes: &[u8], -) -> anyhow::Result<()> { - debug_assert_ne!( - scheme, - SigningSchemeType::Ecdsa256k1, - "the ECDSA entry covers the EIP-712 hash, not the serialized payload" - ); - let verf_key = verf_key_for(keys, party_id, scheme).ok_or_else(|| { - anyhow_tracked(format!( - "party {party_id} signed under {scheme}, but no {scheme} verification key is known \ - for it" - )) - })?; - let signature = Signature::new(scheme, signature.to_vec()); - unified_verify(dsep, signed_bytes, &signature, verf_key).map_err(|e| { - anyhow_tracked(format!( - "the {scheme} signature of party {party_id} did not verify: {e}" - )) - }) -} - -/// Check that every scheme the request asked for was actually *verified*, not -/// merely present in the list. -/// -/// If `requested` is empty, the request is also rejected. -/// -/// `party_id` is the party the response was attributed to, if any signature identified -/// one. The error is unlogged. -pub(crate) fn ensure_requested_verified( - verified: &[SigningSchemeType], - requested: &[SigningSchemeType], - party_id: Option, -) -> anyhow::Result<()> { - let response = || match party_id { - Some(party_id) => format!("the response of party {party_id}"), - None => "the response".to_string(), - }; - if requested.is_empty() { - return Err(anyhow_tracked(format!( - "{} was measured against no signing scheme at all, which any signature would \ - satisfy and none would fail", - response() - ))); - } - match requested.iter().find(|scheme| !verified.contains(scheme)) { - Some(missing) => Err(anyhow_tracked(format!( - "{} carries no verified {missing} signature, but {missing} was requested", - response() - ))), - None => Ok(()), - } -} - /// The signatures one server response carries, whatever kind of result it is. /// -/// All three are checked. They are independent statements about the same result, so a -/// verifier that read only one would accept a response whose other copy disagrees. +/// A node from 0.15 on carries every signature in `list`, and the deprecated fields +/// are then ignored. They are only checked for a node from before `list`, which sends +/// it empty. pub(crate) struct ResponseSignatures<'a> { /// The deprecated raw ECDSA signature over the serialized response payload. Only a /// decryption response carries one; every other result kind leaves this empty. @@ -278,9 +205,9 @@ pub(crate) struct SignedPayloads<'a, T> { pub internal_bytes: &'a [u8], /// The payload every non-ECDSA scheme covers. pub payload: &'a T, - /// The EIP-712 signing hash both ECDSA signatures recover from, or `None` when no - /// domain is available and neither can therefore be checked. - pub eip712_hash: Option, + /// The EIP-712 signing hash the ECDSA signatures recover from. Every result is + /// signed under a domain, so the verifier always has one. + pub eip712_hash: B256, } /// The party a response has to belong to. @@ -334,98 +261,83 @@ impl ExpectedSigner<'_> { } } -/// Hold every signature of one response to the same party. -fn agree(signer: Option<(u32, Address)>, found: (u32, Address)) -> anyhow::Result<(u32, Address)> { - match signer { - Some((party, _)) if party != found.0 => Err(anyhow_tracked(format!( - "the response mixes signatures of party {party} and party {}", - found.0 - ))), - _ => Ok(found), - } +/// What the signatures of a response checked so far establish: who signed it, and +/// under which schemes. +#[derive(Default)] +struct Attribution { + signer: Option<(u32, Address)>, + verified: BTreeSet, } -fn push_once(verified: &mut Vec, scheme: SigningSchemeType) { - if !verified.contains(&scheme) { - verified.push(scheme); +impl Attribution { + /// Record a verified signature of `party`, which meets `scheme` if one is given. + /// Every signature of one response has to belong to the same party. + fn record( + &mut self, + party: (u32, Address), + scheme: Option, + ) -> anyhow::Result<()> { + if let Some((known, _)) = self.signer + && known != party.0 + { + return Err(anyhow_tracked(format!( + "the response mixes signatures of party {known} and party {}", + party.0 + ))); + } + self.signer = Some(party); + if let Some(scheme) = scheme { + self.verified.insert(scheme); + } + Ok(()) } -} -/// Verify one non-ECDSA entry and say which party it belongs to. -fn attribute_scheme_entry( - signature: &[u8], - scheme: SigningSchemeType, - dsep: &DomainSep, - signed_bytes: &[u8], - expected: &ExpectedSigner, - keys: &SchemeVerfKeys, - signer: Option<(u32, Address)>, -) -> anyhow::Result<(u32, Address)> { - let check = - |party_id: u32| verify_scheme_entry(keys, party_id, scheme, signature, dsep, signed_bytes); - match expected { - ExpectedSigner::Known { - party_id, address, .. - } => { - check(*party_id)?; - Ok((*party_id, *address)) - } - // A party another signature already fixed decides which key this entry must - // verify under, rather than whichever key happens to match. - ExpectedSigner::Discover { addresses } => match signer { - Some((party_id, address)) => { - check(party_id)?; - Ok((party_id, address)) - } - None => { - let parsed = Signature::new(scheme, signature.to_vec()); - keys.iter() - .find_map(|(party_id, party_keys)| { - let verf_key = party_keys.get(scheme)?; - unified_verify(dsep, signed_bytes, &parsed, verf_key).ok()?; - addresses.get(party_id).map(|address| (*party_id, *address)) - }) - .ok_or_else(|| { - anyhow_tracked(format!( - "the {scheme} signature of the response verifies under no known \ - party key" - )) - }) - } - }, + /// The party that signed, once every requested scheme was verified. + fn finish(self, requested: &[SigningSchemeType]) -> anyhow::Result<(u32, Address)> { + // A requested scheme that was never verified is the actionable cause, so it is + // reported before the absence of any signer. + if let Some(missing) = requested + .iter() + .find(|scheme| !self.verified.contains(scheme)) + { + let response = match self.signer { + Some((party_id, _)) => format!("the response of party {party_id}"), + None => "the response".to_string(), + }; + return Err(anyhow_tracked(format!( + "{response} carries no verified {missing} signature, but {missing} was requested" + ))); + } + self.signer.ok_or_else(|| { + anyhow_tracked( + "no signature of the response could be checked, so it identified no party" + .to_string(), + ) + }) } } -/// Verify **every** signature a server response carries that can be checked, and -/// require every requested scheme to end up verified. This is the one signature check -/// behind decryption, key generation, CRS generation and preprocessing. +/// Verify the signatures of a server response that meet the request, and return the +/// party that signed it. This is the one signature check behind decryption, key +/// generation, CRS generation and preprocessing. /// -/// Returns the party the response is attributed to. +/// # What gets checked, in order /// -/// # What gets checked +/// 1. `requested` names at least one scheme; an empty request is a rejection rather +/// than a lenient one. +/// 2. Before any cryptography, `list` carries an entry for every requested scheme. +/// Only when `list` is empty, as a node from before it sends, may a deprecated +/// field meet a requested ECDSA instead. +/// 3. When ECDSA was requested: the ECDSA entry of `list`, or for a node from before +/// `list`, the deprecated fields. Both need the EIP-712 domain. +/// 4. Every other requested entry of `list`, against the keys of the party that +/// signed. These entries are bound to the scheme set `list` presents, which may be +/// a superset of `requested` and may name schemes this release does not know. +/// 5. Every requested scheme ended up verified, and every signature agreed on one +/// party. /// -/// - First, before any cryptography, that `list` has an entry for every requested -/// scheme. Only when `list` is empty may a deprecated field meet a requested ECDSA -/// instead. -/// - The deprecated internal `signature`, when the result kind carries one. It covers the -/// payload alone, so it satisfies a requested ECDSA only when no EIP-712 domain is -/// available. -/// - The deprecated `external_signature`, whenever an EIP-712 domain is available. It is -/// checked *in addition to* the internal one, not instead of it. -/// - Every entry of `list` for a requested scheme. An entry for a scheme nobody asked -/// for is not checked, and neither is an entry of a scheme this release does not -/// know, so a response signed under a superset of the request is accepted. -/// - Finally, that every requested scheme was verified, and that every signature agreed -/// on one party. -/// -/// The non-ECDSA entries are checked against a preimage bound to the schemes `list` -/// presents, which is the set the server signed under, not against `requested`. An -/// unchecked entry still counts towards that set, so adding or removing one makes every -/// non-ECDSA entry fail. -/// -/// A result whose `list` is empty is still authenticated by the deprecated fields, which -/// is what a node from a release before the list sends. A `requested` that is empty, by -/// contrast, is a rejection rather than a lenient request. +/// A signature nobody requested carries no weight either way: it is not checked, +/// whether it is an entry of `list` or one of the deprecated fields. /// /// # Errors /// @@ -441,145 +353,240 @@ pub(crate) fn verify_response_signatures( where T: serde::Serialize + tfhe::Versionize + tfhe::named::Named, { + // 1–2. Something was requested, and the list carries all of it. + ensure_requested_present(requested, sigs.list)?; + let (ecdsa_entries, scheme_entries) = requested_entries(sigs.list, requested); + let mut attribution = Attribution::default(); + // 3. ECDSA in each form it is carried in, when it was requested. + if requested.contains(&SigningSchemeType::Ecdsa256k1) { + verify_ecdsa(sigs, &ecdsa_entries, payloads, expected, &mut attribution)?; + } + // 4. Every other requested scheme, against the signing party's keys. + verify_scheme_entries( + &scheme_entries, + sigs.list, + payloads, + expected, + keys, + &mut attribution, + )?; + // 5. Every requested scheme verified, all by one party. + attribution.finish(requested) +} + +/// Steps 1 and 2: `requested` names at least one scheme, and `list` carries an entry +/// for each of them. +/// +/// A node that sends `list` must sign using every requested scheme, ECDSA included. +/// A legacy node is allowed to send an empty list, in which case ECDSA must be verified. +fn ensure_requested_present( + requested: &[SigningSchemeType], + list: &[TypedSignature], +) -> anyhow::Result<()> { if requested.is_empty() { return Err(anyhow_tracked( - "the response was measured against no signing scheme at all, which any signature would \ - satisfy and none would fail" + "the response was measured against no signing scheme at all, which any signature \ + would satisfy and none would fail" .to_string(), )); } - // A node that sends `list` signs every requested scheme into it, ECDSA included. - // Only a node from before `list` sends it empty, and then a deprecated field may - // meet a requested ECDSA, which the final check decides. Nothing else can be met - // without an entry. - let presented: Vec = sigs.list.iter().map(|typed| typed.scheme).collect(); - let legacy_only = presented.is_empty(); - if let Some(missing) = requested - .iter() - .filter(|scheme| !(legacy_only && **scheme == SigningSchemeType::Ecdsa256k1)) - .find(|scheme| !presented.contains(&scheme.as_wire())) - { + if let Some(missing) = requested.iter().find(|scheme| { + let in_list = list.iter().any(|typed| typed.scheme == scheme.as_wire()); + // A legacy node may send an empty list, in which case an ECDSA signature must be + // done. Step 3 verifies such signatures. + let met_by_deprecated_fields = list.is_empty() && **scheme == SigningSchemeType::Ecdsa256k1; + !in_list && !met_by_deprecated_fields + }) { return Err(anyhow_tracked(format!( "the response carries no {missing} signature, but {missing} was requested" ))); } - // Bound to the set the response presents, which may be a superset of `requested` - // and may name schemes this release does not know. The ECDSA entry is the - // exception; see `sign_result_entries`. - let signed_bytes = if presented.is_empty() { - Zeroizing::new(Vec::new()) - } else { - wire_scheme_bound_preimage(&presented, payloads.payload) - .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))? - }; - let mut verified: Vec = Vec::with_capacity(sigs.list.len() + 1); - let mut signer: Option<(u32, Address)> = None; - - // The deprecated raw ECDSA signature is not recoverable, so it is checked against - // the key the caller established rather than used to find one. - if !sigs.internal.is_empty() { - let ExpectedSigner::Known { - party_id, - address, - verf_key, - } = expected - else { - return Err(anyhow_tracked( - "the response carries a deprecated internal signature, but its signer has to be \ - discovered from its signatures and that field is not recoverable" - .to_string(), - )); - }; - let parsed = k256::ecdsa::Signature::from_slice(sigs.internal).map_err(|e| { - anyhow_tracked(format!( - "could not parse the deprecated internal signature: {e}" - )) - })?; - internal_verify_sig( - payloads.dsep, - payloads.internal_bytes, - &Signature::from_ecdsa(parsed), - verf_key, - ) - .map_err(|e| { - anyhow_tracked(format!( - "the deprecated internal signature of party {party_id} did not verify: {e}" - )) - })?; - signer = Some((*party_id, *address)); - // The internal signature covers the response payload alone, not the fields the - // EIP-712 message binds, such as the handles and the extra data. So it meets a - // requested ECDSA only when no EIP-712 form can be checked; with a domain, one of - // the two EIP-712 forms has to verify. - if payloads.eip712_hash.is_none() { - push_once(&mut verified, SigningSchemeType::Ecdsa256k1); - } - } - - if !sigs.external.is_empty() { - match payloads.eip712_hash.as_ref() { - Some(hash) => { - let found = - expected.attribute(recover_address_from_eip712_hash(hash, sigs.external)?)?; - signer = Some(agree(signer, found)?); - push_once(&mut verified, SigningSchemeType::Ecdsa256k1); - } - None => tracing::warn!( - "No EIP-712 domain is available, so the deprecated external signature of a \ - response cannot be checked" - ), - } - } + Ok(()) +} - for typed in sigs.list { - // A scheme this release does not know cannot have been requested, so its entry - // is passed over like an unrequested one. Its discriminant is still bound into - // the preimage above, so a verifier keeps working when a newer node signs - // under a scheme this release has never heard of. +/// The entries of `list` for a requested scheme, split into the ECDSA signatures and +/// the scheme-bound rest. +/// +/// An entry for a scheme that was not requested is skipped, but does not cause a failure. +/// Still, even if it was not requested, it still count towards the scheme set the preimage +/// which is signed such that a verifier keeps working when a newer node signs under more schemes. +fn requested_entries<'a>( + list: &'a [TypedSignature], + requested: &[SigningSchemeType], +) -> (Vec<&'a [u8]>, Vec<(SigningSchemeType, &'a [u8])>) { + let mut ecdsa = Vec::new(); + let mut scheme_bound = Vec::new(); + for typed in list { let Ok(scheme) = SigningSchemeType::try_from(typed.scheme) else { tracing::warn!( "A response carries a signature of the unknown scheme {}, which is skipped", typed.scheme ); + // Skip validation of un-requested schemes continue; }; if !requested.contains(&scheme) { tracing::warn!("A response carries a {scheme} signature that was not requested"); - continue; - } - let found = if scheme == SigningSchemeType::Ecdsa256k1 { - // Only this entry is an EIP-712 signature, so only it needs the domain. - let Some(hash) = payloads.eip712_hash.as_ref() else { - tracing::warn!( - "No EIP-712 domain is available, so the ECDSA entry of a response cannot \ - be checked" - ); - continue; - }; - expected.attribute(recover_address_from_eip712_hash(hash, &typed.signature)?)? + } else if scheme == SigningSchemeType::Ecdsa256k1 { + ecdsa.push(typed.signature.as_slice()); } else { - attribute_scheme_entry( - &typed.signature, - scheme, + scheme_bound.push((scheme, typed.signature.as_slice())); + } + } + (ecdsa, scheme_bound) +} + +/// Step 3: ECDSA, as the ECDSA entry of `list` or, for a node from before `list`, the +/// deprecated fields. +fn verify_ecdsa( + sigs: &ResponseSignatures, + ecdsa_entries: &[&[u8]], + payloads: &SignedPayloads, + expected: &ExpectedSigner, + attribution: &mut Attribution, +) -> anyhow::Result<()> { + let hash = &payloads.eip712_hash; + if ecdsa_entries.is_empty() { + return verify_legacy_ecdsa(sigs, hash, payloads, expected, attribution); + } + for signature in ecdsa_entries { + let party = expected.attribute(recover_address_from_eip712_hash(hash, signature)?)?; + attribution.record(party, Some(SigningSchemeType::Ecdsa256k1))?; + } + Ok(()) +} + +/// The deprecated fields of a node from before `list`. `external_signature` is the +/// EIP-712 signature that meets ECDSA. The internal `signature`, which a decryption +/// response carries, covers the payload alone, so it only confirms the signing party. +/// +/// TODO(0.16): remove together with the fields. +fn verify_legacy_ecdsa( + sigs: &ResponseSignatures, + hash: &B256, + payloads: &SignedPayloads, + expected: &ExpectedSigner, + attribution: &mut Attribution, +) -> anyhow::Result<()> { + if !sigs.external.is_empty() { + let party = expected.attribute(recover_address_from_eip712_hash(hash, sigs.external)?)?; + attribution.record(party, Some(SigningSchemeType::Ecdsa256k1))?; + } + if sigs.internal.is_empty() { + return Ok(()); + } + // The raw signature is not recoverable, so it is checked against the key the + // caller established rather than used to find one. + let ExpectedSigner::Known { + party_id, + address, + verf_key, + } = expected + else { + return Err(anyhow_tracked( + "the response carries a deprecated internal signature, but its signer has to be \ + discovered from its signatures and that field is not recoverable" + .to_string(), + )); + }; + let parsed = k256::ecdsa::Signature::from_slice(sigs.internal).map_err(|e| { + anyhow_tracked(format!( + "could not parse the deprecated internal signature: {e}" + )) + })?; + internal_verify_sig( + payloads.dsep, + payloads.internal_bytes, + &Signature::from_ecdsa(parsed), + verf_key, + ) + .map_err(|e| { + anyhow_tracked(format!( + "the deprecated internal signature of party {party_id} did not verify: {e}" + )) + })?; + attribution.record((*party_id, *address), None) +} + +/// Step 4: every requested non-ECDSA entry, against the keys of the party that signed. +/// +/// That party is the one the caller expects, else the one an ECDSA signature recovered +/// to, else the one whose key the first entry verifies under. The entries are bound to +/// the scheme set `list` presents, not to `requested`, so adding or removing any entry +/// makes them all fail. +fn verify_scheme_entries( + entries: &[(SigningSchemeType, &[u8])], + list: &[TypedSignature], + payloads: &SignedPayloads, + expected: &ExpectedSigner, + keys: &SchemeVerfKeys, + attribution: &mut Attribution, +) -> anyhow::Result<()> +where + T: serde::Serialize + tfhe::Versionize + tfhe::named::Named, +{ + let Some(&first) = entries.first() else { + return Ok(()); + }; + let presented: Vec = list.iter().map(|typed| typed.scheme).collect(); + let preimage = wire_scheme_bound_preimage(&presented, payloads.payload) + .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))?; + let party = match (expected, attribution.signer) { + ( + ExpectedSigner::Known { + party_id, address, .. + }, + _, + ) => (*party_id, *address), + (ExpectedSigner::Discover { .. }, Some(found)) => found, + (ExpectedSigner::Discover { addresses }, None) => { + identify_party(first, keys, addresses, payloads.dsep, &preimage)? + } + }; + let party_id = party.0; + // A missing key is a rejection rather than a skip: accepting an entry nobody can + // check would let a party satisfy a requested scheme without a valid signature. + keys.get(&party_id) + .ok_or(SigningError::NoVerificationKey(first.0)) + .and_then(|party_keys| { + verify_scheme_bound_entries( + entries.iter().copied(), + party_keys, payloads.dsep, - &signed_bytes, - expected, - keys, - signer, - )? - }; - signer = Some(agree(signer, found)?); - push_once(&mut verified, scheme); + &preimage, + ) + }) + .map_err(|e| { + anyhow_tracked(format!( + "a signature of party {party_id} did not verify: {e}" + )) + })?; + for &(scheme, _) in entries { + attribution.record(party, Some(scheme))?; } + Ok(()) +} - // A requested scheme that was never verified is the actionable cause, so it is - // reported before the absence of any signer. - ensure_requested_verified(&verified, requested, signer.map(|(party_id, _)| party_id))?; - signer.ok_or_else(|| { - anyhow_tracked( - "no signature of the response could be checked, so it identified no party".to_string(), - ) - }) +/// The party whose published key `entry` verifies under, for a response whose signer +/// neither the caller nor an ECDSA signature identified. +fn identify_party( + entry: (SigningSchemeType, &[u8]), + keys: &SchemeVerfKeys, + addresses: &HashMap, + dsep: &DomainSep, + preimage: &[u8], +) -> anyhow::Result<(u32, Address)> { + keys.iter() + .find(|(_, party_keys)| { + verify_scheme_bound_entries([entry], party_keys, dsep, preimage).is_ok() + }) + .and_then(|(party_id, _)| addresses.get(party_id).map(|address| (*party_id, *address))) + .ok_or_else(|| { + anyhow_tracked(format!( + "the {} signature of the response verifies under no known party key", + entry.0 + )) + }) } /// Authenticate a single (untrusted) response: look its `party_id` up in @@ -619,14 +626,6 @@ fn authenticate_user_decrypt_and_check_meta_data( )); } - // A response has to carry at least one of the two deprecated fields until 0.16, so - // that a node from a release before `signatures` stays verifiable. - if signature.is_empty() && eip712_params.response_external_signature.is_empty() { - return Err(anyhow_error_and_log( - ERR_VALIDATE_USER_DECRYPTION_MISSING_SIGNATURE, - )); - } - let response_bytes = bc2wrap::serialize(&response)?; verify_response_signatures( &ResponseSignatures { @@ -638,11 +637,11 @@ fn authenticate_user_decrypt_and_check_meta_data( dsep: &DSEP_USER_DECRYPTION, internal_bytes: &response_bytes, payload: &user_dec_payload(&response_bytes, eip712_params.response_extra_data), - eip712_hash: Some(user_decrypt_eip712_hash( + eip712_hash: user_decrypt_eip712_hash( response, trusted_ctx.client_request, eip712_params.trusted_eip712_domain, - )?), + )?, }, trusted_ctx.client_request.signing_schemes(), &ExpectedSigner::Known { @@ -966,7 +965,8 @@ mod tests { use aes_prng::AesRng; use alloy_dyn_abi::Eip712Domain; use kms_grpc::kms::v1::{ - TypedSigncryptedCiphertext, UserDecryptionResponse, UserDecryptionResponsePayload, + TypedSignature, TypedSigncryptedCiphertext, UserDecryptionResponse, + UserDecryptionResponsePayload, }; use rand::SeedableRng; use strum::IntoEnumIterator; @@ -996,34 +996,37 @@ mod tests { }; use super::{ - DSEP_USER_DECRYPTION, ERR_VALIDATE_USER_DECRYPTION_MISSING_SIGNATURE, - ERR_VALIDATE_USER_DECRYPTION_NOT_ENOUGH_RESP, Eip712VerificationParams, ExpectedSigner, - ResponseSignatures, SignedPayloads, UserDecTrustedValidationContext, - UserDecryptionInvariants, user_decrypt_eip712_hash, validate_user_decrypt_responses, - verify_response_signatures, + DSEP_USER_DECRYPTION, ERR_VALIDATE_USER_DECRYPTION_NOT_ENOUGH_RESP, + Eip712VerificationParams, ExpectedSigner, ResponseSignatures, SignedPayloads, + UserDecTrustedValidationContext, UserDecryptionInvariants, user_decrypt_eip712_hash, + validate_user_decrypt_responses, verify_response_signatures, }; - /// Asking for no scheme at all is a rejection, whatever the response verified - /// under. + /// Asking for no scheme at all is a rejection, whatever the response carries. #[test] fn no_requested_scheme_is_a_rejection() { let every_scheme: Vec<_> = SigningSchemeType::iter().collect(); + let full_list: Vec<_> = every_scheme + .iter() + .map(|scheme| TypedSignature { + scheme: scheme.as_wire(), + signature: vec![], + }) + .collect(); - for verified in [&[][..], &[SigningSchemeType::Ecdsa256k1][..], &every_scheme] { - for party_id in [None, Some(1)] { - let err = super::ensure_requested_verified(verified, &[], party_id) - .unwrap_err() - .to_string(); - assert!( - err.contains("no signing scheme at all"), - "the error does not name the cause: {err}" - ); - } + for list in [&[][..], &full_list[..]] { + let err = super::ensure_requested_present(&[], list) + .unwrap_err() + .to_string(); + assert!( + err.contains("no signing scheme at all"), + "the error does not name the cause: {err}" + ); } - // A scheme that was asked for and verified still passes, so the new rejection - // did not swallow the ordinary case. - super::ensure_requested_verified(&every_scheme, &every_scheme, Some(1)).unwrap(); + // A request whose schemes are all present still passes, so the rejection did + // not swallow the ordinary case. + super::ensure_requested_present(&every_scheme, &full_list).unwrap(); } /// Helper method to be removed in 0.16 when the external signature is no longer used in production. @@ -1124,9 +1127,8 @@ mod tests { dsep: &DSEP_USER_DECRYPTION, internal_bytes: &response_bytes, payload: &super::user_dec_payload(&response_bytes, request.extra_data()), - eip712_hash: Some( - user_decrypt_eip712_hash(response, &request, eip712_domain).unwrap(), - ), + eip712_hash: user_decrypt_eip712_hash(response, &request, eip712_domain) + .unwrap(), }, request.signing_schemes(), &ExpectedSigner::Known { @@ -1272,7 +1274,7 @@ mod tests { // done here — they are a single `UserDecryptionInvariants` equality in // `classify_user_decrypt_response`, exercised via `test_validate_user_decrypt_responses`. - // no signatures are provided + // no signatures are provided, so the requested ECDSA is left unverified { let params = Eip712VerificationParams { response_external_signature: &[], @@ -1283,13 +1285,13 @@ mod tests { authenticate_user_decrypt_and_check_meta_data( &trusted_ctx, &pivot_resp, - &[], // the ECDSA signature may be empty, thus we check the external one + &[], &[], ¶ms, ) .unwrap_err() .to_string() - .contains(ERR_VALIDATE_USER_DECRYPTION_MISSING_SIGNATURE) + .contains("carries no verified Ecdsa256k1 signature") ); } @@ -1316,27 +1318,6 @@ mod tests { ); } - // no signatures are provided - { - let params = Eip712VerificationParams { - response_external_signature: &[], - response_extra_data: &extra_data, - trusted_eip712_domain: &dummy_domain, - }; - assert!( - authenticate_user_decrypt_and_check_meta_data( - &trusted_ctx, - &pivot_resp, - &[], // the ECDSA signature may be empty, thus we check the external one - &[], - ¶ms, - ) - .unwrap_err() - .to_string() - .contains(ERR_VALIDATE_USER_DECRYPTION_MISSING_SIGNATURE) - ); - } - // if the ID is changed to something that does not exist, return error { let mut other_resp = pivot_resp.clone(); From 65bf23a0c89e7b63b6740b9790cc7c43b66c7665 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Wed, 30 Sep 2026 18:33:50 +0200 Subject: [PATCH 088/114] test: removed unneeded tests --- core/service/src/client/client_non_wasm.rs | 143 +++++---------- .../client/tests/threshold/crs_gen_tests.rs | 16 +- .../threshold/public_decryption_tests.rs | 14 -- .../service/src/engine/validation_non_wasm.rs | 170 +++++------------- 4 files changed, 100 insertions(+), 243 deletions(-) diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index c19a4666ed..c6a16558f6 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -326,25 +326,6 @@ mod tests { ); } - /// The rolling-upgrade case: a node from a release before `signatures` answers with - /// an empty list and the legacy ECDSA signature alone, and that still authenticates - /// the result for a client asking only for ECDSA. - #[test] - fn an_empty_list_falls_back_to_the_legacy_signature() { - let identity = seeded_identity(12); - let client = client_for(&identity, &[]); - - let (party_id, address) = verify_with_legacy( - &client, - &[], - &legacy_external_signature(&identity), - &payload(), - ) - .unwrap(); - assert_eq!(party_id, PARTY); - assert_eq!(address, identity.verf_key().address()); - } - /// The fallback counts for ECDSA only. An old node cannot produce a post-quantum /// signature, so a request that named one is not satisfied by its legacy signature — /// otherwise any server could drop a requested scheme and still be accepted. @@ -371,70 +352,28 @@ mod tests { ); } - /// The fallback is a real signature check, not a waiver for an empty list. + /// Each non-ECDSA entry verifies for, and is attributed to, the party that signed it, + /// and covers the payload it was signed over and nothing else. #[test] - fn the_legacy_fallback_rejects_a_signature_of_another_party() { - let identity = seeded_identity(14); - let client = client_for(&identity, &[]); - let stranger = legacy_external_signature(&seeded_identity(15)); - - assert!(verify_with_legacy(&client, &[], &stranger, &payload()).is_err()); - assert!(verify_with_legacy(&client, &[], &[0u8; 65], &payload()).is_err()); - } - - /// The legacy signature is only checked for a node from before the list. Once the - /// list carries the ECDSA signature, the legacy field beside it is not checked; without - /// the list, a bad legacy signature is a rejection. - #[test] - fn a_bad_legacy_signature_is_ignored_once_the_list_carries_ecdsa() { - let identity = seeded_identity(16); - let client = client_for(&identity, &[]); - let signatures = signatures_for(&identity, &[SigningSchemeType::Ecdsa256k1], &payload()); - let bad_legacy = [ - vec![0xAA; 65], - legacy_external_signature(&seeded_identity(17)), - ]; - - for legacy in &bad_legacy { - let (party_id, _address) = - verify_with_legacy(&client, &signatures, legacy, &payload()).unwrap(); - assert_eq!(party_id, PARTY); - assert!(verify_with_legacy(&client, &[], legacy, &payload()).is_err()); - } - } - - /// A MlDsa65 signature is attributed to the correct signing party, provided - /// MlDsa65 is what the client asked for. - #[test] - fn a_result_without_an_ecdsa_entry_is_attributed() { + fn each_non_ecdsa_entry_is_attributed_and_bound_to_its_payload() { let identity = seeded_identity(3); - let client = client_requesting(&identity, true, &[SigningSchemeType::MlDsa65]); - let signatures = signatures_for(&identity, &[SigningSchemeType::MlDsa65], &payload()); - - let (party_id, _address) = verify(&client, &signatures, &payload()).unwrap(); - assert_eq!(party_id, PARTY); - } - - /// Each entry covers the payload it was signed over, and nothing else. - #[test] - fn a_tampered_payload_is_rejected() { - let identity = seeded_identity(4); for scheme in SigningSchemeType::iter().filter(|s| *s != SigningSchemeType::Ecdsa256k1) { - // Ask for exactly the scheme under test, so the rejection can only come - // from the signature check and not from a scheme left unverified. + // Ask for exactly the scheme under test, so a rejection can only come from + // the signature check and not from a scheme left unverified. let client = client_requesting(&identity, true, &[scheme]); let signatures = signatures_for(&identity, &[scheme], &payload()); + assert_eq!( + verify(&client, &signatures, &payload()).unwrap().0, + PARTY, + "{scheme}" + ); + let tampered = CrsSignedPayload { + crs_digest: vec![8u8; 32], + ..payload() + }; assert!( - verify( - &client, - &signatures, - &CrsSignedPayload { - crs_digest: vec![8u8; 32], - ..payload() - } - ) - .is_err(), + verify(&client, &signatures, &tampered).is_err(), "the {scheme} entry verified a payload it does not cover" ); } @@ -488,23 +427,6 @@ mod tests { assert!(verify(&client, &signatures[1..], &payload()).is_err()); } - /// Another party's signatures are not accepted as this party's. - #[test] - fn another_partys_signatures_are_rejected() { - let identity = seeded_identity(5); - let client = client_for(&identity, &[]); - let signatures = signatures_for( - &seeded_identity(6), - &[SigningSchemeType::Ecdsa256k1], - &payload(), - ); - - let err = verify(&client, &signatures, &payload()) - .unwrap_err() - .to_string(); - assert!(err.contains("belongs to no known party"), "{err}"); - } - /// A signature that cannot be checked for want of a key must not pass for one /// that was checked. #[test] @@ -597,7 +519,9 @@ mod tests { } } - /// ECDSA signatures must parse and authenticate the expected signer and message. + /// ECDSA signatures must parse and authenticate the expected signer and message, both + /// as the list entry and, for a node from before the list, as the legacy field. A + /// legacy field beside a valid list entry is not checked at all. #[test] fn invalid_ecdsa_signatures_are_rejected() { let identity = seeded_identity(18); @@ -615,20 +539,19 @@ mod tests { ), ("wrong message", wrong_message), ]; + let list = |signature: Vec| { + vec![TypedSignature { + scheme: SigningSchemeType::Ecdsa256k1.as_wire(), + signature, + }] + }; for legacy in [false, true] { let check = |signature: Vec| { if legacy { verify_with_legacy(&client, &[], &signature, &payload()) } else { - verify( - &client, - &[TypedSignature { - scheme: SigningSchemeType::Ecdsa256k1.as_wire(), - signature, - }], - &payload(), - ) + verify(&client, &list(signature), &payload()) } }; assert_eq!( @@ -639,5 +562,21 @@ mod tests { assert!(check(signature.clone()).is_err(), "{case}, legacy={legacy}"); } } + + // Another party's entry is attributed to no known party. + let err = verify(&client, &list(invalid[2].1.clone()), &payload()) + .unwrap_err() + .to_string(); + assert!(err.contains("belongs to no known party"), "{err}"); + + for (case, signature) in &invalid { + assert_eq!( + verify_with_legacy(&client, &list(valid.clone()), signature, &payload()) + .unwrap() + .0, + PARTY, + "a bad {case} legacy field beside a valid list entry" + ); + } } } diff --git a/core/service/src/client/tests/threshold/crs_gen_tests.rs b/core/service/src/client/tests/threshold/crs_gen_tests.rs index 46d5fca68c..b1cae0d32b 100644 --- a/core/service/src/client/tests/threshold/crs_gen_tests.rs +++ b/core/service/src/client/tests/threshold/crs_gen_tests.rs @@ -420,8 +420,10 @@ pub async fn wait_for_crsgen_result( // test that having [THRESHOLD] wrong signatures still works let mut final_responses_with_bad_sig = res_storage.clone(); + // The ECDSA entry of the list is what a result carrying the list is verified + // by; the deprecated `external_signature` beside it is not checked. let bad_sig = { - let mut tmp = res_storage[0].0.external_signature.clone(); + let mut tmp = ecdsa_entry(&mut res_storage[0].0.clone()).clone(); tmp[0] ^= 0xff; tmp }; @@ -502,13 +504,23 @@ pub async fn wait_for_crsgen_result( } results } +/// The ECDSA entry of a result's `signatures` list. +fn ecdsa_entry(crs_gen_result: &mut kms_grpc::kms::v1::CrsGenResult) -> &mut Vec { + let ecdsa = kms_grpc::kms::v1::SigningSchemeType::Ecdsa256k1 as i32; + &mut crs_gen_result + .signatures + .iter_mut() + .find(|typed| typed.scheme == ecdsa) + .expect("the result carries an ECDSA entry") + .signature +} fn set_signatures( crs_res_storage: &mut [(kms_grpc::kms::v1::CrsGenResult, FileStorage)], count: usize, sig: &[u8], ) { for (crs_gen_result, _) in crs_res_storage.iter_mut().take(count) { - crs_gen_result.external_signature = sig.to_vec(); + *ecdsa_entry(crs_gen_result) = sig.to_vec(); } } fn set_digests( diff --git a/core/service/src/client/tests/threshold/public_decryption_tests.rs b/core/service/src/client/tests/threshold/public_decryption_tests.rs index b1c976ebe8..fc3c105e2b 100644 --- a/core/service/src/client/tests/threshold/public_decryption_tests.rs +++ b/core/service/src/client/tests/threshold/public_decryption_tests.rs @@ -457,19 +457,5 @@ pub async fn run_decryption_threshold_optionally_fail( for (i, plaintext) in received_plaintexts.iter().enumerate() { crate::client::tests::common::assert_plaintext(&msgs[i], plaintext); } - - // A response without the deprecated internal signature must be tolerated, as long as - // enough of the remaining responses still carry a valid one. - // TODO(0.16) remove along with the deprecated fields. - if responses.len() > min_count_agree as usize { - let mut responses_wo_internal_sig = responses.clone(); - responses_wo_internal_sig[0].signature = vec![]; - assert_eq!( - internal_client - .process_decryption_resp(&req, min_count_agree, &responses_wo_internal_sig) - .unwrap(), - received_plaintexts - ); - } } } diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 5b518135e3..818e419fbd 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -1206,8 +1206,7 @@ mod tests { cryptography::{ encryption::{Encryption, PkeScheme, PkeSchemeType, UnifiedPublicEncKey}, signatures::{ - NodeSigningIdentity, PrivateSigKey, PublicSigKey, compute_eip712_signature, - gen_sig_keys, internal_sign, + NodeSigningIdentity, PrivateSigKey, PublicSigKey, gen_sig_keys, internal_sign, }, signing::SigningSchemeType, }, @@ -1682,121 +1681,6 @@ mod tests { } } - #[test] - fn test_validate_public_decrypt_meta_response() { - let mut rng = AesRng::seed_from_u64(0); - let (vk0, sk0) = gen_sig_keys(&mut rng); - let (vk1, sk1) = gen_sig_keys(&mut rng); - let (vk2, _sk2) = gen_sig_keys(&mut rng); - - let pks: HashMap = HashMap::from_iter( - [vk0, vk1, vk2] - .into_iter() - .enumerate() - .map(|(i, k)| (i as u32 + 1, k)), - ); - - let request_id = Some( - derive_request_id("test_validate_public_decrypt_meta_response") - .unwrap() - .into(), - ); - let pivot = PublicDecryptionResponsePayload { - verification_key: bc2wrap::serialize(&pks[&1]).unwrap(), - plaintexts: vec![TypedPlaintext { - bytes: vec![1], - fhe_type: tfhe::FheTypes::Uint8 as i32, // Uint8, supported for ABI encoding - }], - request_id: request_id.clone(), - }; - - // `verify_public_decrypt_signatures` checks *authenticity* only (every entry of the - // response's `signatures` list); agreement with the consensus invariants is checked - // separately in the match pass of `partition_public_decrypt_responses` (exercised by - // `test_validate_public_decrypt_responses`). - let domain = dummy_domain(); - let server_pks = pks.clone(); - let scheme_verf_keys = HashMap::new(); - let ctx = PublicDecTrustedValidationContext::new( - &server_pks, - &scheme_verf_keys, - &domain, - &[], - &[], - None, - ) - .unwrap(); - // The ECDSA entry is the recoverable EIP-712 signature over the handles, the plaintexts - // and the extra data. - let ecdsa_entry = |sk: &PrivateSigKey, payload: &PublicDecryptionResponsePayload| { - let message = compute_public_decryption_message(&[], &payload.plaintexts, &[]).unwrap(); - kms_grpc::rpc_types::ecdsa_signatures( - compute_eip712_signature(sk, &message, &domain).unwrap(), - ) - }; - let verify = |payload: &PublicDecryptionResponsePayload, sigs: &[TypedSignature]| { - verify_public_decrypt_signatures(&ctx, payload, 1, &vk_of(payload), &[], &[], sigs, &[]) - }; - - // an empty list and no internal field either, so nothing can be authenticated - assert!(!verify(&pivot, &[])); - - // signed with the wrong private key - assert!(!verify(&pivot, &ecdsa_entry(&sk1, &pivot))); - - // a malformed signature - assert!(!verify( - &pivot, - &kms_grpc::rpc_types::ecdsa_signatures(vec![0u8; 65]) - )); - - // signing the wrong value: the plaintexts differ, and the EIP-712 message covers them. - // - // NOTE: `request_id` is deliberately not part of this message — see - // `PublicDecryptVerification`. The request-id linkage of a response is established by - // `PublicDecryptionInvariants::sanity_check` against the client's own request, not by - // this signature. - { - let other_value = PublicDecryptionResponsePayload { - verification_key: bc2wrap::serialize(&pks[&1]).unwrap(), - plaintexts: vec![TypedPlaintext { - bytes: vec![2], - fhe_type: tfhe::FheTypes::Uint8 as i32, - }], - request_id: request_id.clone(), - }; - assert!(!verify(&pivot, &ecdsa_entry(&sk0, &other_value))); - } - - // a response whose key did not sign it: the payload carries a fresh key, but the - // signature is by `sk0`, so the recovered address is not the one the payload claims - { - let (vk, _sk) = gen_sig_keys(&mut rng); - let bad_value = PublicDecryptionResponsePayload { - verification_key: bc2wrap::serialize(&vk).unwrap(), - plaintexts: vec![TypedPlaintext { - bytes: vec![1], - fhe_type: tfhe::FheTypes::Uint8 as i32, - }], - request_id, - }; - assert!(!verify(&bad_value, &ecdsa_entry(&sk0, &bad_value))); - } - - // an entry of some other scheme does not stand in for the ECDSA one this - // context asks for, whether or not it could have been checked - assert!(!verify( - &pivot, - &[TypedSignature { - scheme: kms_grpc::kms::v1::SigningSchemeType::Mldsa65 as i32, - signature: vec![0u8; 64], - }] - )); - - // happy path - assert!(verify(&pivot, &ecdsa_entry(&sk0, &pivot))); - } - #[test] fn test_validate_public_decrypt_responses() { let mut rng = AesRng::seed_from_u64(0); @@ -2118,14 +2002,6 @@ mod tests { ); } - // bad external signature - { - let mut bad_resp = resp1.clone(); - bad_resp.external_signature[0] ^= 1; - let agg_resp = vec![resp0.clone(), bad_resp]; - validate_public_decrypt_responses(&trusted_ctx, 1, &agg_resp).unwrap(); - } - // request ID { let agg_resp = vec![resp0.clone(), resp1.clone()]; @@ -2268,6 +2144,50 @@ mod tests { // happy path assert!(verify(&[], &[], &signatures)); + // the EIP-712 message covers the plaintexts, so an entry signed over other ones fails + let other_value = PublicDecryptionResponsePayload { + plaintexts: vec![TypedPlaintext { + bytes: vec![2], + fhe_type: tfhe::FheTypes::Uint8 as i32, + }], + ..pivot.clone() + }; + let other_signatures = sign_ecdsa_public_decrypt_result( + &sk0, + other_value, + &ext_handles_bytes, + extra_data.clone(), + &alloy_domain, + ) + .signatures; + assert!(!verify(&[], &[], &other_signatures)); + + // a response whose key did not sign it: the payload carries a fresh key, but the + // signature is by `sk0`, so the recovered address is not the one the payload claims + let (fresh_vk, _) = gen_sig_keys(&mut rng); + let bad_value = PublicDecryptionResponsePayload { + verification_key: bc2wrap::serialize(&fresh_vk).unwrap(), + ..pivot.clone() + }; + let bad_signatures = sign_ecdsa_public_decrypt_result( + &sk0, + bad_value.clone(), + &ext_handles_bytes, + extra_data.clone(), + &alloy_domain, + ) + .signatures; + assert!(!verify_public_decrypt_signatures( + &ctx, + &bad_value, + 1, + &fresh_vk, + &[], + &[], + &bad_signatures, + &extra_data, + )); + // A node from before the list is authenticated by `external_signature`. assert!(verify(&[], &signed.external_signature, &[])); From 76f405b03557307e6537450d106c14e364712d3b Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 1 Oct 2026 10:25:03 +0200 Subject: [PATCH 089/114] fix: linter --- .../client/tests/centralized/public_decryption_tests.rs | 2 +- .../client/tests/threshold/public_decryption_tests.rs | 2 +- core/service/src/cryptography/signing/mod.rs | 9 --------- core/service/src/engine/validation_non_wasm.rs | 6 +++--- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/core/service/src/client/tests/centralized/public_decryption_tests.rs b/core/service/src/client/tests/centralized/public_decryption_tests.rs index 52b4d33d60..b03ef2dadf 100644 --- a/core/service/src/client/tests/centralized/public_decryption_tests.rs +++ b/core/service/src/client/tests/centralized/public_decryption_tests.rs @@ -287,7 +287,7 @@ pub(crate) async fn run_decryption_centralized( assert_eq!(responses.len(), 1); let received_plaintexts = internal_client - .process_decryption_resp(&req, 1, &responses) + .process_decryption_resp(req, 1, &responses) .unwrap(); // we need 1 plaintext for each ciphertext in the batch diff --git a/core/service/src/client/tests/threshold/public_decryption_tests.rs b/core/service/src/client/tests/threshold/public_decryption_tests.rs index fc3c105e2b..3cabb185d3 100644 --- a/core/service/src/client/tests/threshold/public_decryption_tests.rs +++ b/core/service/src/client/tests/threshold/public_decryption_tests.rs @@ -447,7 +447,7 @@ pub async fn run_decryption_threshold_optionally_fail( let threshold = max_threshold(amount_parties); let min_count_agree = (threshold + 1) as u32; let received_plaintexts = internal_client - .process_decryption_resp(&req, min_count_agree, &responses) + .process_decryption_resp(req, min_count_agree, &responses) .unwrap(); // we need 1 plaintext for each ciphertext in the batch diff --git a/core/service/src/cryptography/signing/mod.rs b/core/service/src/cryptography/signing/mod.rs index cbaf2ee679..abeffae485 100644 --- a/core/service/src/cryptography/signing/mod.rs +++ b/core/service/src/cryptography/signing/mod.rs @@ -502,15 +502,6 @@ impl HasSigningScheme for UnifiedPublicSigKey { /// id, the set of keys that party has published. pub type SchemeVerfKeys = HashMap; -/// The verification key `party_id` published for `scheme`, if it published one. -pub fn verf_key_for( - keys: &SchemeVerfKeys, - party_id: u32, - scheme: SigningSchemeType, -) -> Option<&UnifiedPublicSigKey> { - keys.get(&party_id).and_then(|keys| keys.get(scheme)) -} - /// Sign `msg` (domain-separated by `dsep`) under the scheme of `sk`. #[cfg(feature = "non-wasm")] pub fn unified_sign( diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 818e419fbd..778c88fb36 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -1229,9 +1229,9 @@ mod tests { use super::{ ERR_VALIDATE_PUBLIC_DECRYPTION_BAD_FHE_TYPE, ERR_VALIDATE_PUBLIC_DECRYPTION_BAD_LINK, ERR_VALIDATE_PUBLIC_DECRYPTION_EMPTY_CTS, ERR_VALIDATE_USER_DECRYPTION_EMPTY_CTS, - PublicDecTrustedValidationContext, TypedSignature, compute_public_decryption_message, - unpack_public_decrypt_req, unpack_user_decrypt_req, verify_max_num_bits, - verify_public_decrypt_signatures, verify_user_decrypt_eip712, + PublicDecTrustedValidationContext, TypedSignature, unpack_public_decrypt_req, + unpack_user_decrypt_req, verify_max_num_bits, verify_public_decrypt_signatures, + verify_user_decrypt_eip712, }; /// Sign a public decryption result the way the server does, under ECDSA only. From acbc15aefdea3d433042fe2c1a2ede12b749aca2 Mon Sep 17 00:00:00 2001 From: Tore <33345641+jot2re@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:20:02 +0200 Subject: [PATCH 090/114] refactor: enc_key_digest assignment for clarity Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- core/service/src/cryptography/signcryption/composite_v1.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 35f5150870..52d27d6c9b 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -84,10 +84,11 @@ impl CompositeSigncryptionPayload { receiver_id: &[u8], encryption_key: &UnifiedPublicEncKey, ) -> Result { +let enc_key_digest = receiver_enc_key_digest(encryption_key)?; Ok(Self { msg: msg.to_vec(), receiver_id: receiver_id.to_vec(), - enc_key_digest: receiver_enc_key_digest(encryption_key)?, + enc_key_digest, }) } } From 715542c69cc8796f7a7d95793418875f854a0509 Mon Sep 17 00:00:00 2001 From: Tore <33345641+jot2re@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:20:56 +0200 Subject: [PATCH 091/114] chore: Use Zeroizing for CompositeSigncryptionPayload Refactor signcryption process to use Zeroizing for payload. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/cryptography/signcryption/composite_v1.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index 52d27d6c9b..da152586f9 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -106,10 +106,12 @@ pub(super) fn seal( msg: &[u8], ) -> Result { let receiver_enc_key = &signcrypt_key.receiver_enc_key; - let mut signed = - CompositeSigncryptionPayload::new(msg, &signcrypt_key.receiver_id, receiver_enc_key)?; - let signature = sign_composite(&signcrypt_key.identity, schemes, dsep, &signed)?; - signed.zeroize(); +let signed = Zeroizing::new(CompositeSigncryptionPayload::new( + msg, + &signcrypt_key.receiver_id, + receiver_enc_key, + )?); + let signature = sign_composite(&signcrypt_key.identity, schemes, dsep, &*signed)?; let mut envelope = CompositeEnvelope { msg: msg.to_vec(), From ccf613dae54bd6e2dbd265173cfa3206133eb203 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 1 Oct 2026 13:30:38 +0200 Subject: [PATCH 092/114] fix: lint --- core/service/src/cryptography/signcryption/composite_v1.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/service/src/cryptography/signcryption/composite_v1.rs b/core/service/src/cryptography/signcryption/composite_v1.rs index da152586f9..6f62667803 100644 --- a/core/service/src/cryptography/signcryption/composite_v1.rs +++ b/core/service/src/cryptography/signcryption/composite_v1.rs @@ -84,7 +84,7 @@ impl CompositeSigncryptionPayload { receiver_id: &[u8], encryption_key: &UnifiedPublicEncKey, ) -> Result { -let enc_key_digest = receiver_enc_key_digest(encryption_key)?; + let enc_key_digest = receiver_enc_key_digest(encryption_key)?; Ok(Self { msg: msg.to_vec(), receiver_id: receiver_id.to_vec(), @@ -106,7 +106,7 @@ pub(super) fn seal( msg: &[u8], ) -> Result { let receiver_enc_key = &signcrypt_key.receiver_enc_key; -let signed = Zeroizing::new(CompositeSigncryptionPayload::new( + let signed = Zeroizing::new(CompositeSigncryptionPayload::new( msg, &signcrypt_key.receiver_id, receiver_enc_key, From 13907498e671475272b05ab6eb1a47cb314a80cf Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 1 Oct 2026 14:50:57 +0200 Subject: [PATCH 093/114] test: added forward compatibility signing scheme test --- .../service/src/engine/validation_non_wasm.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 4b03fdffef..ea80fe4e91 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -1195,7 +1195,7 @@ fn unpack_new_mpc_epoch_req(req: NewMpcEpochRequest) -> anyhow::Result Date: Thu, 1 Oct 2026 15:10:40 +0200 Subject: [PATCH 094/114] fix: lint --- core/service/src/engine/validation_non_wasm.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index ea80fe4e91..c77115f6d4 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -1195,7 +1195,9 @@ fn unpack_new_mpc_epoch_req(req: NewMpcEpochRequest) -> anyhow::Result Date: Thu, 1 Oct 2026 13:22:47 +0200 Subject: [PATCH 095/114] chore: remove unneeded test --- core/service/src/engine/validation_wasm.rs | 227 +++++---------------- 1 file changed, 53 insertions(+), 174 deletions(-) diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 91b6bfec0e..91077c1599 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -410,6 +410,7 @@ fn ensure_requested_present( /// An entry for a scheme that was not requested is skipped, but does not cause a failure. /// Still, even if it was not requested, it still count towards the scheme set the preimage /// which is signed such that a verifier keeps working when a newer node signs under more schemes. +#[allow(clippy::type_complexity)] fn requested_entries<'a>( list: &'a [TypedSignature], requested: &[SigningSchemeType], @@ -978,8 +979,7 @@ mod tests { cryptography::{ encryption::{Encryption, PkeScheme, PkeSchemeType}, signatures::{ - ERR_EXT_USER_DECRYPTION_SIG_BAD_LENGTH, NodeSigningIdentity, PrivateSigKey, - PublicSigKey, gen_sig_keys, internal_sign, + NodeSigningIdentity, PrivateSigKey, PublicSigKey, gen_sig_keys, internal_sign, }, signing::SigningSchemeType, }, @@ -997,9 +997,8 @@ mod tests { use super::{ DSEP_USER_DECRYPTION, ERR_VALIDATE_USER_DECRYPTION_NOT_ENOUGH_RESP, - Eip712VerificationParams, ExpectedSigner, ResponseSignatures, SignedPayloads, - UserDecTrustedValidationContext, UserDecryptionInvariants, user_decrypt_eip712_hash, - validate_user_decrypt_responses, verify_response_signatures, + Eip712VerificationParams, UserDecTrustedValidationContext, UserDecryptionInvariants, + validate_user_decrypt_responses, }; /// Asking for no scheme at all is a rejection, whatever the response carries. @@ -1049,153 +1048,6 @@ mod tests { .external_signature) } - #[test] - fn test_verify_response_signatures_external_user_decryption() { - let mut rng = AesRng::seed_from_u64(0); - let (vk0, sk0) = gen_sig_keys(&mut rng); - let (vk1, _sk1) = gen_sig_keys(&mut rng); - let (vk2, _sk2) = gen_sig_keys(&mut rng); - let pks: HashMap = HashMap::from_iter( - [vk0, vk1, vk2] - .into_iter() - .enumerate() - .map(|(i, k)| (i as u32 + 1, k)), - ); - let kms_addrs = pks - .iter() - .map(|(i, pk)| (*i, pk.address())) - .collect::>(); - - let mut encryption = Encryption::new(PkeSchemeType::MlKem512, &mut rng); - let (_eph_client_sk, eph_client_pk) = encryption.keygen().unwrap(); - let (client_vk, _client_sk) = gen_sig_keys(&mut rng); - - let ciphertext_handle = vec![5, 6, 7, 8]; - - let mut enc_key_buf = Vec::new(); - tfhe::safe_serialization::safe_serialize( - &eph_client_pk, - &mut enc_key_buf, - crate::consts::SAFE_SER_SIZE_LIMIT, - ) - .unwrap(); - - let domain = dummy_domain(); - let extra_data = vec![1, 2, 3, 4]; - let request = ParsedUserDecryptionRequest::new( - None, // No signature is needed - client_vk.address(), - enc_key_buf, - vec![CiphertextHandle::new(ciphertext_handle.clone())], - domain.verifying_contract.unwrap(), - vec![SigningSchemeType::Ecdsa256k1], - extra_data, - ); - - let payload = UserDecryptionResponsePayload { - verification_key: bc2wrap::serialize(&pks[&1]).unwrap(), - digest: vec![1, 2, 3, 4], - signcrypted_ciphertexts: vec![TypedSigncryptedCiphertext { - fhe_type: tfhe::FheTypes::Uint4 as i32, - signcrypted_ciphertext: vec![1, 2, 3, 4], - external_handle: ciphertext_handle.clone(), - packing_factor: 1, - }], - party_id: 1, - degree: 1, - }; - let external_sig = compute_external_user_decrypt_signature( - &sk0, - &payload, - &domain, - request.enc_key(), - request.extra_data(), - ) - .unwrap(); - - let verify = |external: &[u8], - response: &UserDecryptionResponsePayload, - eip712_domain: &Eip712Domain| { - let response_bytes = bc2wrap::serialize(response).unwrap(); - verify_response_signatures( - &ResponseSignatures { - internal: &[], - external, - list: &[], - }, - &SignedPayloads { - dsep: &DSEP_USER_DECRYPTION, - internal_bytes: &response_bytes, - payload: &super::user_dec_payload(&response_bytes, request.extra_data()), - eip712_hash: user_decrypt_eip712_hash(response, &request, eip712_domain) - .unwrap(), - }, - request.signing_schemes(), - &ExpectedSigner::Known { - party_id: 1, - address: kms_addrs[&1], - verf_key: &pks[&1], - }, - &HashMap::new(), - ) - }; - - // incorrect external signature length - { - assert!( - verify(&external_sig[0..64], &payload, &domain) - .unwrap_err() - .to_string() - .contains(ERR_EXT_USER_DECRYPTION_SIG_BAD_LENGTH) - ); - } - - // bad signature due to bad signing key - { - let (_vk_bad, sk_bad) = gen_sig_keys(&mut rng); - let bad_external_sig = compute_external_user_decrypt_signature( - &sk_bad, - &payload, - &domain, - request.enc_key(), - request.extra_data(), - ) - .unwrap(); - assert!(verify(&bad_external_sig, &payload, &domain).is_err()); - } - - // bad signature due to bad domain - { - let bad_domain = alloy_sol_types::eip712_domain!( - name: "Authorization token", - version: "1", - chain_id: 1234, // incorrect chain ID - verifying_contract: alloy_primitives::address!("66f9664f97F2b50F62D13eA064982f936dE76657"), - ); - assert!(verify(&external_sig, &payload, &bad_domain).is_err()); - } - - // check that we detect the error if payload is modified - { - let mut bad_payload = payload.clone(); - bad_payload.party_id = 2; // modify ID - assert!( - verify(&external_sig, &bad_payload, &domain) - .unwrap_err() - .to_string() - .contains("an ECDSA signature of party 1 recovered to") - ); - } - - // happy path - { - assert_eq!( - verify(&external_sig, &payload, &domain).unwrap(), - (1, kms_addrs[&1]) - ); - } - } - #[test] fn test_validate_user_decrypt_meta_data_and_signature() { let mut rng = AesRng::seed_from_u64(0); @@ -1274,27 +1126,6 @@ mod tests { // done here — they are a single `UserDecryptionInvariants` equality in // `classify_user_decrypt_response`, exercised via `test_validate_user_decrypt_responses`. - // no signatures are provided, so the requested ECDSA is left unverified - { - let params = Eip712VerificationParams { - response_external_signature: &[], - response_extra_data: &extra_data, - trusted_eip712_domain: &dummy_domain, - }; - assert!( - authenticate_user_decrypt_and_check_meta_data( - &trusted_ctx, - &pivot_resp, - &[], - &[], - ¶ms, - ) - .unwrap_err() - .to_string() - .contains("carries no verified Ecdsa256k1 signature") - ); - } - // if the ID is changed to something that does not exist, return error { let mut other_resp = pivot_resp.clone(); @@ -1341,7 +1172,7 @@ mod tests { ); } - // Signature failures are covered by `test_verify_response_signatures_external_user_decryption`. + // The response has to echo the request's extra data. { let pivot_buf = bc2wrap::serialize(&pivot_resp).unwrap(); let signature_buf = internal_sign(&DSEP_USER_DECRYPTION, &pivot_buf, &sk0) @@ -1383,6 +1214,54 @@ mod tests { .unwrap(); } + // The EIP-712 message covers the whole payload and is bound to the domain, so the + // same external signature fails for a changed payload or under another domain. + { + let params = Eip712VerificationParams { + response_external_signature: &external_signature, + response_extra_data: &extra_data, + trusted_eip712_domain: &dummy_domain, + }; + let changed = UserDecryptionResponsePayload { + degree: 2, + ..pivot_resp.clone() + }; + let err = authenticate_user_decrypt_and_check_meta_data( + &trusted_ctx, + &changed, + &[], + &[], + ¶ms, + ) + .unwrap_err() + .to_string(); + assert!( + err.contains("an ECDSA signature of party 1 recovered to"), + "{err}" + ); + + let other_domain = alloy_sol_types::eip712_domain!( + name: "Authorization token", + version: "1", + chain_id: 1234, // incorrect chain ID + verifying_contract: alloy_primitives::address!("66f9664f97F2b50F62D13eA064982f936dE76657"), + ); + let params = Eip712VerificationParams { + trusted_eip712_domain: &other_domain, + ..params + }; + assert!( + authenticate_user_decrypt_and_check_meta_data( + &trusted_ctx, + &pivot_resp, + &[], + &[], + ¶ms, + ) + .is_err() + ); + } + // The internal signature alone is not enough: a domain is always available for user // decryption, and the internal signature covers the payload only, so the requested // ECDSA has to be met by an EIP-712 form. From 0456bcc25057a3af6e4123d5b3f163b3405fa1bc Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 1 Oct 2026 16:23:37 +0200 Subject: [PATCH 096/114] chore: more obsolete code removal --- core/service/src/client/client_non_wasm.rs | 4 +- core/service/src/client/public_decryption.rs | 13 +- .../src/cryptography/signing/composite.rs | 49 ++-- core/service/src/engine/base.rs | 2 +- .../engine/storage_material_verification.rs | 55 ++--- .../service/src/engine/validation_non_wasm.rs | 215 +++++++----------- core/service/src/engine/validation_wasm.rs | 175 ++++++-------- 7 files changed, 210 insertions(+), 303 deletions(-) diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index c6a16558f6..252c67d396 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -274,7 +274,7 @@ mod tests { payload: &CrsSignedPayload, ) -> Vec { let eip712_hash = sol_type().eip712_signing_hash(&dummy_domain()); - sign_result_entries(identity, schemes, DSEP, eip712_hash.as_slice(), payload) + sign_result_entries(identity, schemes, DSEP, &eip712_hash, payload) .unwrap() .iter() .map(TypedSignature::from) @@ -321,7 +321,7 @@ mod tests { let err = verify(&client, &[], &payload()).unwrap_err().to_string(); assert!( - err.contains("carries no signatures"), + err.contains("carries no verified Ecdsa256k1 signature"), "the error does not name the cause: {err}" ); } diff --git a/core/service/src/client/public_decryption.rs b/core/service/src/client/public_decryption.rs index 97d0532878..48a8a4c354 100644 --- a/core/service/src/client/public_decryption.rs +++ b/core/service/src/client/public_decryption.rs @@ -6,7 +6,7 @@ use alloy_sol_types::Eip712Domain; use kms_grpc::identifiers::ContextId; use kms_grpc::kms::v1::TypedPlaintext; use kms_grpc::kms::v1::{PublicDecryptionRequest, PublicDecryptionResponse, TypedCiphertext}; -use kms_grpc::rpc_types::{alloy_to_protobuf_domain, optional_protobuf_to_alloy_domain}; +use kms_grpc::rpc_types::alloy_to_protobuf_domain; use kms_grpc::{EpochId, RequestId}; impl Client { @@ -70,19 +70,10 @@ impl Client { min_agree_count: u32, agg_resp: &[PublicDecryptionResponse], ) -> anyhow::Result> { - let eip712_domain = optional_protobuf_to_alloy_domain(request.domain.as_ref())?; - let ext_handles_bytes: Vec> = request - .ciphertexts - .iter() - .map(|c| c.external_handle.clone()) - .collect(); let trusted_ctx = PublicDecTrustedValidationContext::new( self.get_server_pks()?, &self.scheme_verf_keys, - &eip712_domain, - &ext_handles_bytes, - &request.extra_data, - Some(request), + request, )?; // Partition the untrusted responses and enforce the majority threshold. Partitioning is diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index e3d1cb5cb7..ab7af521d5 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -62,6 +62,8 @@ use super::verf_key_set::VerfKeySet; use super::{Signature, SigningError, SigningSchemeType, unified_verify}; use crate::consts::SAFE_SER_SIZE_LIMIT; use crate::cryptography::zeroizing_writer::ZeroizingWriter; +#[cfg(feature = "non-wasm")] +use alloy_primitives::B256; use hashing::DomainSep; use serde::Serialize; use std::io::Write; @@ -79,6 +81,12 @@ pub const COMPOSITE_PREFIX: &[u8; 32] = b"ZamaKmsCompositeSignature2026_v1"; pub fn canonical_schemes( schemes: &[SigningSchemeType], ) -> Result, SigningError> { + canonical(schemes) +} + +/// [`canonical_schemes`] for schemes given either typed or by their wire +/// discriminants, which sort in the same order. +fn canonical(schemes: &[S]) -> Result, SigningError> { let mut canonical = schemes.to_vec(); canonical.sort_unstable(); canonical.dedup(); @@ -147,13 +155,7 @@ pub fn wire_scheme_bound_preimage( where T: Serialize + Versionize + Named, { - let mut canonical = schemes.to_vec(); - canonical.sort_unstable(); - canonical.dedup(); - if canonical.is_empty() { - return Err(SigningError::EmptySchemeSet); - } - let scheme_bytes = canonical_wire_scheme_bytes(&canonical); + let scheme_bytes = canonical_wire_scheme_bytes(&canonical(schemes)?); let mut out = ZeroizingWriter::new(); let framed = |e: std::io::Error| SigningError::Serialization(e.to_string()); out.write_all(COMPOSITE_PREFIX).map_err(framed)?; @@ -164,7 +166,7 @@ where } /// The schemes `entries` were made under, in the order they are stored. -pub fn entry_schemes(entries: &[StoredTypedSignature]) -> Vec { +fn entry_schemes(entries: &[StoredTypedSignature]) -> Vec { entries.iter().map(|entry| entry.scheme).collect() } @@ -273,7 +275,7 @@ pub fn sign_result_entries( identity: &NodeSigningIdentity, schemes: &[SigningSchemeType], dsep: &DomainSep, - eip712_hash: &[u8], + eip712_hash: &B256, payload: &T, ) -> Result, SigningError> where @@ -288,14 +290,11 @@ where .map(|&scheme| { let signature = match scheme { SigningSchemeType::Ecdsa256k1 => { - let hash = alloy_primitives::B256::try_from(eip712_hash).map_err(|_| { - SigningError::Sign(format!( - "EIP-712 signing hash must be 32 bytes, got {}", - eip712_hash.len() - )) - })?; - crate::cryptography::signing::ecdsa::eip712_sign_hash(identity.ecdsa(), &hash) - .map_err(|e| SigningError::Sign(e.to_string()))? + crate::cryptography::signing::ecdsa::eip712_sign_hash( + identity.ecdsa(), + eip712_hash, + ) + .map_err(|e| SigningError::Sign(e.to_string()))? } _ => identity .unified_sign_with(scheme, dsep, &signed)? @@ -470,7 +469,7 @@ mod tests { SigningSchemeType::Ed25519, SigningSchemeType::MlDsa65, ]; - let eip712_hash = [0x11u8; 32]; + let eip712_hash = B256::repeat_byte(0x11); let payload = msg(); let bound = scheme_bound_preimage(&schemes, &payload).unwrap(); @@ -495,7 +494,7 @@ mod tests { tfhe::safe_serialization::safe_serialize(&payload, &mut bare, SAFE_SER_SIZE_LIMIT) .unwrap(); assert!(unified_verify(DSEP, &bare, &sig, &vk).is_err()); - assert!(unified_verify(DSEP, &eip712_hash, &sig, &vk).is_err()); + assert!(unified_verify(DSEP, eip712_hash.as_slice(), &sig, &vk).is_err()); } } @@ -513,8 +512,14 @@ mod tests { ]; let canonical = canonical_schemes(&requested).unwrap(); - let entries = - sign_result_entries(&identity, &requested, DSEP, &[0x11u8; 32], &msg()).unwrap(); + let entries = sign_result_entries( + &identity, + &requested, + DSEP, + &B256::repeat_byte(0x11), + &msg(), + ) + .unwrap(); assert_eq!( entries.iter().map(|e| e.scheme).collect::>(), @@ -529,7 +534,7 @@ mod tests { let mut rng = AesRng::seed_from_u64(21); let identity = seeded_identity(&mut rng); assert!(matches!( - sign_result_entries(&identity, &[], DSEP, &[0u8; 32], &msg()), + sign_result_entries(&identity, &[], DSEP, &B256::ZERO, &msg()), Err(SigningError::EmptySchemeSet) )); } diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index 003d366add..646b452779 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -511,7 +511,7 @@ where identity, schemes, dsep, - eip712_hash.as_slice(), + &eip712_hash, payload, )?; Ok((external_signature, signatures)) diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index 1a6a463ea9..f3521763c2 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -542,36 +542,21 @@ where )?; } - // Metadata that names no scheme has no per-scheme entry to check, and no - // scheme set to bind a preimage to. - if signatures.is_empty() { - return Ok(()); - } - - let mut keys = BTreeMap::new(); let mut scheme_entries = Vec::new(); for stored in signatures { - let scheme = stored.scheme; - if scheme == SigningSchemeType::Ecdsa256k1 { - if let Some(hash) = eip712_hash { - verify_eip712_metadata_signature( - metadata_kind, - metadata_id, - hash, - &stored.signature, - expected_address, - )?; - } - continue; + if stored.scheme != SigningSchemeType::Ecdsa256k1 { + scheme_entries.push((stored.scheme, stored.signature.as_slice())); + } else if let Some(hash) = eip712_hash { + verify_eip712_metadata_signature( + metadata_kind, + metadata_id, + hash, + &stored.signature, + expected_address, + )?; } - let verf_key = identity.unified_verifying_key(scheme).map_err(|e| { - anyhow::anyhow!( - "Private {metadata_kind} metadata for id={metadata_id} carries a {scheme} signature, but this node cannot derive the {scheme} verification key to check it against: {e}" - ) - })?; - keys.insert(scheme, verf_key); - scheme_entries.push((scheme, stored.signature.as_slice())); } + // Metadata with no entry besides ECDSA has no scheme set to bind a preimage to. if scheme_entries.is_empty() { return Ok(()); } @@ -580,13 +565,21 @@ where // outside: at boot there is no request to measure against. let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); let preimage = scheme_bound_preimage(&stored_schemes, payload)?; - verify_scheme_bound_entries(scheme_entries, &VerfKeySet::new(keys)?, dsep, &preimage).map_err( - |e| { + for (scheme, signature) in scheme_entries { + let keys = VerfKeySet::from_identity(identity, &[scheme]).map_err(|e| { anyhow::anyhow!( - "Invalid signature in private {metadata_kind} metadata for id={metadata_id}: {e}", + "Private {metadata_kind} metadata for id={metadata_id} carries a {scheme} signature, but this node cannot derive the {scheme} verification key to check it against: {e}" ) - }, - ) + })?; + verify_scheme_bound_entries([(scheme, signature)], &keys, dsep, &preimage).map_err( + |e| { + anyhow::anyhow!( + "Invalid {scheme} signature in private {metadata_kind} metadata for id={metadata_id}: {e}" + ) + }, + )?; + } + Ok(()) } fn verify_eip712_metadata_signature( diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index d1df076058..6af0ed7f61 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -53,21 +53,20 @@ pub(crate) struct PublicDecTrustedValidationContext<'a> { /// client built without storage access, such as the browser, supplies an /// empty map and can then only check ECDSA. scheme_verf_keys: &'a SchemeVerfKeys, - /// Every result is signed under an EIP-712 domain, so the verifier always has one. - eip712_domain: &'a Eip712Domain, - ext_handles_bytes: &'a [Vec], - extra_data: &'a [u8], - request: Option<&'a PublicDecryptionRequest>, + /// The request this client sent. The responses are bound to it, and verified + /// against its domain, ciphertext handles, extra data and signing schemes. + request: &'a PublicDecryptionRequest, + /// The EIP-712 domain of `request`. + eip712_domain: Eip712Domain, + /// The external handles of the ciphertexts of `request`. + ext_handles_bytes: Vec>, } impl<'a> PublicDecTrustedValidationContext<'a> { pub fn new( server_pks: &'a HashMap, scheme_verf_keys: &'a SchemeVerfKeys, - eip712_domain: &'a Eip712Domain, - ext_handles_bytes: &'a [Vec], - extra_data: &'a [u8], - request: Option<&'a PublicDecryptionRequest>, + request: &'a PublicDecryptionRequest, ) -> anyhow::Result { // Sanity check uniqueness of server public keys. This is a trusted context, so if the server keys are not unique, it is a configuration error. let unique_keys: HashSet<&PublicSigKey> = server_pks.values().collect(); @@ -75,13 +74,18 @@ impl<'a> PublicDecTrustedValidationContext<'a> { anyhow::bail!("Duplicate server public keys found in trusted validation context"); } + let eip712_domain = optional_protobuf_to_alloy_domain(request.domain.as_ref())?; + let ext_handles_bytes = request + .ciphertexts + .iter() + .map(|ct| ct.external_handle.clone()) + .collect(); Ok(Self { server_pks, scheme_verf_keys, + request, eip712_domain, ext_handles_bytes, - extra_data, - request, }) } } @@ -98,8 +102,6 @@ const ERR_VALIDATE_PUBLIC_DECRYPTION_MISSING_REQ_ID: &str = "Request ID is not set in public decryption response"; const ERR_VALIDATE_PUBLIC_DECRYPTION_BAD_FHE_TYPE: &str = "Plaintext type mismatch in public decryption response"; -const ERR_VALIDATE_PUBLIC_DECRYPTION_EMPTY_REQUEST: &str = - "Public decryption request is None while validating public decryption responses"; const ERR_VALIDATE_USER_DECRYPTION_EMPTY_CTS: &str = "No ciphertexts in user decryption request"; @@ -476,10 +478,7 @@ fn check_public_decrypt_signatures( signatures: &[TypedSignature], response_extra_data: &[u8], ) -> anyhow::Result<()> { - let requested = match trusted_ctx.request { - Some(request) => SigningSchemeType::resolve_requested(&request.signing_schemes)?, - None => vec![SigningSchemeType::Ecdsa256k1], - }; + let requested = SigningSchemeType::resolve_requested(&trusted_ctx.request.signing_schemes)?; // NOTE that we cannot use `BaseKmsStruct::verify_sig` // because `BaseKmsStruct` cannot be compiled for wasm (it has an async mutex). @@ -487,11 +486,11 @@ fn check_public_decrypt_signatures( let payload = public_dec_payload(&response_bytes, response_extra_data); let eip712_hash = compute_public_decryption_message( - trusted_ctx.ext_handles_bytes, + &trusted_ctx.ext_handles_bytes, &response.plaintexts, response_extra_data, )? - .eip712_signing_hash(trusted_ctx.eip712_domain); + .eip712_signing_hash(&trusted_ctx.eip712_domain); verify_response_signatures( &ResponseSignatures { @@ -538,10 +537,7 @@ pub(crate) struct PublicDecryptionInvariants { impl PublicDecryptionInvariants { /// Sanity-check the invariants against the trusted context. fn sanity_check(&self, trusted_ctx: &PublicDecTrustedValidationContext) -> anyhow::Result<()> { - let Some(req) = trusted_ctx.request else { - tracing::warn!(ERR_VALIDATE_PUBLIC_DECRYPTION_EMPTY_REQUEST); - return Ok(()); - }; + let req = trusted_ctx.request; // The consensus plaintext count must match the number of ciphertexts the client asked to // decrypt. This is a property of the (majority-backed) consensus, so a mismatch is a genuine @@ -669,7 +665,7 @@ fn authenticate_public_decrypt_response( tracing::warn!("A request ID must be present!"); return Err(PublicRejectReason::MissingRequestId); } - if cur_resp.extra_data != trusted_ctx.extra_data { + if cur_resp.extra_data != trusted_ctx.request.extra_data { tracing::warn!("Extra data mismatch in public decryption!"); return Err(PublicRejectReason::ExtraDataMismatch); } @@ -747,13 +743,12 @@ fn authenticate_public_decrypt_response( /// get to vote at all. /// 3. Discard every authenticated payload that does not match the consensus invariants. /// -/// In addition, if the original request is provided (via `trusted_ctx.request`) -/// the response matches the original request +/// In addition, the agreed result has to match the original request (`trusted_ctx.request`). /// /// Infallible w.r.t. adversarial per-response content: any malformed/inconsistent response is /// placed in `rejected`, never propagated. Returns `Err` ONLY when the honest invariant set cannot /// be established at all: no responses, no configured servers, no pivot with ≥ `t + 1` agreement, -/// or — when a request is provided — the agreed result does not match the client's own request. +/// or the agreed result does not match the client's own request. /// With ≥ `2t + 1` honest responses present, none of those `Err` cases can be triggered by ≤ `t` /// adversaries. /// @@ -1286,6 +1281,31 @@ mod tests { bc2wrap::deserialize_slice(&payload.verification_key).unwrap() } + /// A request under `domain` for one `Uint8` ciphertext per handle in `handles`, + /// asking for `signing_schemes`. An empty `signing_schemes` means ECDSA. + fn public_decrypt_request( + domain: &Eip712Domain, + handles: &[Vec], + extra_data: &[u8], + signing_schemes: Vec, + ) -> PublicDecryptionRequest { + PublicDecryptionRequest { + signing_schemes, + ciphertexts: handles + .iter() + .map(|handle| TypedCiphertext { + fhe_type: tfhe::FheTypes::Uint8 as i32, + external_handle: handle.clone(), + ciphertext_format: 1, + ..Default::default() + }) + .collect(), + domain: Some(alloy_to_protobuf_domain(domain).unwrap()), + extra_data: extra_data.to_vec(), + ..Default::default() + } + } + #[test] fn test_validate_public_decrypt_req() { // setup data we're going to use in this test @@ -1712,14 +1732,13 @@ mod tests { fhe_type: tfhe::FheTypes::Uint8 as i32, // Uint8, supported for ABI encoding }]; - let trusted_ctx = PublicDecTrustedValidationContext { - server_pks: &pks, - eip712_domain: &alloy_domain, - ext_handles_bytes: &ext_handles_bytes, - extra_data: &extra_data_0, - request: None, - scheme_verf_keys: &HashMap::new(), + let request = PublicDecryptionRequest { + request_id: request_id.clone(), + ..public_decrypt_request(&alloy_domain, &ext_handles_bytes, &extra_data_0, vec![]) }; + let scheme_verf_keys = HashMap::new(); + let trusted_ctx = + PublicDecTrustedValidationContext::new(&pks, &scheme_verf_keys, &request).unwrap(); // NOTE: the pks map uses 1-based index while the others use 0-based index like sk0 let resp0 = signed_public_decrypt_response( @@ -1935,14 +1954,9 @@ mod tests { &alloy_domain, ); - let trusted_ctx = PublicDecTrustedValidationContext { - server_pks: &pks, - eip712_domain: &alloy_domain, - ext_handles_bytes: &ext_handles_bytes, - extra_data: &extra_data, - request: Some(&request), - scheme_verf_keys: &HashMap::new(), - }; + let scheme_verf_keys = HashMap::new(); + let trusted_ctx = + PublicDecTrustedValidationContext::new(&pks, &scheme_verf_keys, &request).unwrap(); // invalid aggregate response, e.g., when there are none { @@ -1988,14 +2002,9 @@ mod tests { context_id: None, epoch_id: None, }; - let bad_ctx = PublicDecTrustedValidationContext { - server_pks: &pks, - eip712_domain: &alloy_domain, - ext_handles_bytes: &ext_handles_bytes, - extra_data: &extra_data, - request: Some(&bad_request), - scheme_verf_keys: &HashMap::new(), - }; + let bad_ctx = + PublicDecTrustedValidationContext::new(&pks, &scheme_verf_keys, &bad_request) + .unwrap(); assert!( validate_public_decrypt_responses(&bad_ctx, 2, &agg_resp) .unwrap_err() @@ -2031,14 +2040,9 @@ mod tests { context_id: None, epoch_id: None, }; - let bad_ctx = PublicDecTrustedValidationContext { - server_pks: &pks, - eip712_domain: &alloy_domain, - ext_handles_bytes: &ext_handles_bytes, - extra_data: &extra_data, - request: Some(&bad_request), - scheme_verf_keys: &HashMap::new(), - }; + let bad_ctx = + PublicDecTrustedValidationContext::new(&pks, &scheme_verf_keys, &bad_request) + .unwrap(); assert!( validate_public_decrypt_responses(&bad_ctx, 2, &agg_resp) .unwrap_err() @@ -2047,21 +2051,6 @@ mod tests { ); } - // Without the request the responses are not bound to it, but are still - // verified against the domain, the handles and the extra data it would carry. - { - let agg_resp = vec![resp0.clone(), resp1.clone()]; - let external_ctx = PublicDecTrustedValidationContext { - server_pks: &pks, - eip712_domain: &alloy_domain, - ext_handles_bytes: &ext_handles_bytes, - extra_data: &extra_data, - request: None, - scheme_verf_keys: &HashMap::new(), - }; - validate_public_decrypt_responses(&external_ctx, 2, &agg_resp).unwrap(); - } - // happy path { let agg_resp = vec![resp0.clone(), resp1.clone()]; @@ -2115,15 +2104,10 @@ mod tests { let server_pks = HashMap::from([(1u32, vk_of(&pivot))]); let scheme_verf_keys = HashMap::new(); - let ctx = PublicDecTrustedValidationContext::new( - &server_pks, - &scheme_verf_keys, - &alloy_domain, - &ext_handles_bytes, - &extra_data, - None, - ) - .unwrap(); + let request = + public_decrypt_request(&alloy_domain, &ext_handles_bytes, &extra_data, vec![]); + let ctx = PublicDecTrustedValidationContext::new(&server_pks, &scheme_verf_keys, &request) + .unwrap(); let verify = |internal: &[u8], external: &[u8], list: &[TypedSignature]| { verify_public_decrypt_signatures( &ctx, @@ -2242,7 +2226,7 @@ mod tests { &identity, &[scheme], &DSEP_PUBLIC_DECRYPTION, - &[0u8; 32], + &alloy_primitives::B256::ZERO, &signed, ) .unwrap() @@ -2256,21 +2240,11 @@ mod tests { 1u32, VerfKeySet::from_identity(&identity, &[scheme]).unwrap(), )]); - let request_for = |schemes: Vec| PublicDecryptionRequest { - signing_schemes: schemes, - ..Default::default() - }; + let request_for = + |schemes: Vec| public_decrypt_request(&domain, &[], &extra_data, schemes); // What differs between the contexts is only what was asked for. let ctx_for = |request| { - PublicDecTrustedValidationContext::new( - &server_pks, - &scheme_verf_keys, - &domain, - &[], - &extra_data, - request, - ) - .unwrap() + PublicDecTrustedValidationContext::new(&server_pks, &scheme_verf_keys, request).unwrap() }; // This response carries no deprecated internal field, so the post-quantum // entry of `signatures` is the only thing that can authenticate it. @@ -2288,7 +2262,7 @@ mod tests { }; let pq_only = request_for(vec![kms_grpc::kms::v1::SigningSchemeType::Mldsa65 as i32]); - let pq_ctx = ctx_for(Some(&pq_only)); + let pq_ctx = ctx_for(&pq_only); // The post-quantum entry the request asked for verifies on its own. assert!(verify(&pq_ctx, &extra_data)); @@ -2302,13 +2276,14 @@ mod tests { kms_grpc::kms::v1::SigningSchemeType::Mldsa65 as i32, kms_grpc::kms::v1::SigningSchemeType::Ecdsa256k1 as i32, ]); - let composite_ctx = ctx_for(Some(&composite)); + let composite_ctx = ctx_for(&composite); assert!(!verify(&composite_ctx, &extra_data)); - // Neither is an absent request, which names nothing and so means ECDSA, for - // which the list has no entry. - let no_request_ctx = ctx_for(None); - assert!(!verify(&no_request_ctx, &extra_data)); + // Neither is a request that names no scheme, which means ECDSA, for which + // the list has no entry. + let no_scheme = request_for(vec![]); + let no_scheme_ctx = ctx_for(&no_scheme); + assert!(!verify(&no_scheme_ctx, &extra_data)); // A response signed under a superset of the request is accepted: the // post-quantum entry is checked against the set the response presents, @@ -2318,7 +2293,7 @@ mod tests { &identity, &superset, &DSEP_PUBLIC_DECRYPTION, - &[0u8; 32], + &alloy_primitives::B256::ZERO, &signed, ) .unwrap() @@ -2340,15 +2315,9 @@ mod tests { kms_grpc::kms::v1::SigningSchemeType::Ed25519 as i32, kms_grpc::kms::v1::SigningSchemeType::Mldsa65 as i32, ]); - let superset_ctx = PublicDecTrustedValidationContext::new( - &server_pks, - &superset_keys, - &domain, - &[], - &extra_data, - Some(&superset_request), - ) - .unwrap(); + let superset_ctx = + PublicDecTrustedValidationContext::new(&server_pks, &superset_keys, &superset_request) + .unwrap(); assert!(verify_list(&superset_ctx, &superset_signatures)); // Dropping the unrequested entry changes the presented set, which the @@ -2578,30 +2547,14 @@ mod tests { let (vk0, _sk0) = gen_sig_keys(&mut rng); let (vk1, _sk1) = gen_sig_keys(&mut rng); let server_pks = HashMap::from([(1, vk0.clone()), (2, vk1.clone())]); - let domain = dummy_domain(); + let request = public_decrypt_request(&dummy_domain(), &[], &[], vec![]); - PublicDecTrustedValidationContext::new( - &server_pks, - &HashMap::new(), - &domain, - &[], - &[], - None, - ) - .unwrap(); + PublicDecTrustedValidationContext::new(&server_pks, &HashMap::new(), &request).unwrap(); // Error if the server_pks has duplicate keys let server_pks = HashMap::from([(1, vk1.clone()), (2, vk1)]); assert!( - PublicDecTrustedValidationContext::new( - &server_pks, - &HashMap::new(), - &domain, - &[], - &[], - None - ) - .is_err() + PublicDecTrustedValidationContext::new(&server_pks, &HashMap::new(), &request).is_err() ); } diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 91077c1599..1c4ccf7273 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -17,7 +17,7 @@ use alloy_primitives::{Address, B256}; use alloy_sol_types::SolStruct; use hashing::DomainSep; use kms_grpc::kms::v1::{TypedSignature, UserDecryptionResponse, UserDecryptionResponsePayload}; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use tfhe::FheTypes; use threshold_types::role::Role; @@ -261,60 +261,15 @@ impl ExpectedSigner<'_> { } } -/// What the signatures of a response checked so far establish: who signed it, and -/// under which schemes. -#[derive(Default)] -struct Attribution { - signer: Option<(u32, Address)>, - verified: BTreeSet, -} - -impl Attribution { - /// Record a verified signature of `party`, which meets `scheme` if one is given. - /// Every signature of one response has to belong to the same party. - fn record( - &mut self, - party: (u32, Address), - scheme: Option, - ) -> anyhow::Result<()> { - if let Some((known, _)) = self.signer - && known != party.0 - { - return Err(anyhow_tracked(format!( - "the response mixes signatures of party {known} and party {}", - party.0 - ))); - } - self.signer = Some(party); - if let Some(scheme) = scheme { - self.verified.insert(scheme); - } - Ok(()) - } - - /// The party that signed, once every requested scheme was verified. - fn finish(self, requested: &[SigningSchemeType]) -> anyhow::Result<(u32, Address)> { - // A requested scheme that was never verified is the actionable cause, so it is - // reported before the absence of any signer. - if let Some(missing) = requested - .iter() - .find(|scheme| !self.verified.contains(scheme)) - { - let response = match self.signer { - Some((party_id, _)) => format!("the response of party {party_id}"), - None => "the response".to_string(), - }; - return Err(anyhow_tracked(format!( - "{response} carries no verified {missing} signature, but {missing} was requested" - ))); - } - self.signer.ok_or_else(|| { - anyhow_tracked( - "no signature of the response could be checked, so it identified no party" - .to_string(), - ) - }) +/// Hold two signatures of one response to the same party. +fn agree(signer: (u32, Address), found: (u32, Address)) -> anyhow::Result<()> { + if signer.0 != found.0 { + return Err(anyhow_tracked(format!( + "the response mixes signatures of party {} and party {}", + signer.0, found.0 + ))); } + Ok(()) } /// Verify the signatures of a server response that meet the request, and return the @@ -333,8 +288,10 @@ impl Attribution { /// 4. Every other requested entry of `list`, against the keys of the party that /// signed. These entries are bound to the scheme set `list` presents, which may be /// a superset of `requested` and may name schemes this release does not know. -/// 5. Every requested scheme ended up verified, and every signature agreed on one -/// party. +/// 5. Every signature agreed on one party. +/// +/// Steps 2 to 4 leave no requested scheme unverified: each one is either verified or +/// the response is rejected. /// /// A signature nobody requested carries no weight either way: it is not checked, /// whether it is an entry of `list` or one of the deprecated fields. @@ -356,22 +313,21 @@ where // 1–2. Something was requested, and the list carries all of it. ensure_requested_present(requested, sigs.list)?; let (ecdsa_entries, scheme_entries) = requested_entries(sigs.list, requested); - let mut attribution = Attribution::default(); // 3. ECDSA in each form it is carried in, when it was requested. - if requested.contains(&SigningSchemeType::Ecdsa256k1) { - verify_ecdsa(sigs, &ecdsa_entries, payloads, expected, &mut attribution)?; - } - // 4. Every other requested scheme, against the signing party's keys. - verify_scheme_entries( - &scheme_entries, - sigs.list, - payloads, - expected, - keys, - &mut attribution, - )?; - // 5. Every requested scheme verified, all by one party. - attribution.finish(requested) + let signer = if requested.contains(&SigningSchemeType::Ecdsa256k1) { + Some(verify_ecdsa(sigs, &ecdsa_entries, payloads, expected)?) + } else { + None + }; + // 4. Every other requested scheme, against the keys of the party that signed. + let signer = + verify_scheme_entries(&scheme_entries, sigs.list, payloads, expected, keys, signer)?; + // `requested` is not empty, so step 3 or step 4 identified the signer. + signer.ok_or_else(|| { + anyhow_tracked( + "no signature of the response could be checked, so it identified no party".to_string(), + ) + }) } /// Steps 1 and 2: `requested` names at least one scheme, and `list` carries an entry @@ -438,50 +394,58 @@ fn requested_entries<'a>( } /// Step 3: ECDSA, as the ECDSA entry of `list` or, for a node from before `list`, the -/// deprecated fields. +/// deprecated fields. Returns the party that signed. fn verify_ecdsa( sigs: &ResponseSignatures, ecdsa_entries: &[&[u8]], payloads: &SignedPayloads, expected: &ExpectedSigner, - attribution: &mut Attribution, -) -> anyhow::Result<()> { - let hash = &payloads.eip712_hash; - if ecdsa_entries.is_empty() { - return verify_legacy_ecdsa(sigs, hash, payloads, expected, attribution); - } - for signature in ecdsa_entries { - let party = expected.attribute(recover_address_from_eip712_hash(hash, signature)?)?; - attribution.record(party, Some(SigningSchemeType::Ecdsa256k1))?; +) -> anyhow::Result<(u32, Address)> { + let Some((&first, rest)) = ecdsa_entries.split_first() else { + return verify_legacy_ecdsa(sigs, payloads, expected); + }; + let recover = |signature: &[u8]| -> anyhow::Result<(u32, Address)> { + expected.attribute(recover_address_from_eip712_hash( + &payloads.eip712_hash, + signature, + )?) + }; + let signer = recover(first)?; + for &signature in rest { + agree(signer, recover(signature)?)?; } - Ok(()) + Ok(signer) } /// The deprecated fields of a node from before `list`. `external_signature` is the -/// EIP-712 signature that meets ECDSA. The internal `signature`, which a decryption -/// response carries, covers the payload alone, so it only confirms the signing party. +/// EIP-712 signature that meets ECDSA, so it is required. The internal `signature`, +/// which a decryption response carries, covers the payload alone, so it is checked +/// when present but cannot meet ECDSA on its own. /// /// TODO(0.16): remove together with the fields. fn verify_legacy_ecdsa( sigs: &ResponseSignatures, - hash: &B256, payloads: &SignedPayloads, expected: &ExpectedSigner, - attribution: &mut Attribution, -) -> anyhow::Result<()> { - if !sigs.external.is_empty() { - let party = expected.attribute(recover_address_from_eip712_hash(hash, sigs.external)?)?; - attribution.record(party, Some(SigningSchemeType::Ecdsa256k1))?; +) -> anyhow::Result<(u32, Address)> { + if sigs.external.is_empty() { + let ecdsa = SigningSchemeType::Ecdsa256k1; + return Err(anyhow_tracked(format!( + "the response carries no verified {ecdsa} signature, but {ecdsa} was requested" + ))); } + let signer = expected.attribute(recover_address_from_eip712_hash( + &payloads.eip712_hash, + sigs.external, + )?)?; if sigs.internal.is_empty() { - return Ok(()); + return Ok(signer); } // The raw signature is not recoverable, so it is checked against the key the - // caller established rather than used to find one. + // caller established rather than used to find one. `attribute` held + // `external_signature` to that same party. let ExpectedSigner::Known { - party_id, - address, - verf_key, + party_id, verf_key, .. } = expected else { return Err(anyhow_tracked( @@ -506,10 +470,11 @@ fn verify_legacy_ecdsa( "the deprecated internal signature of party {party_id} did not verify: {e}" )) })?; - attribution.record((*party_id, *address), None) + Ok(signer) } -/// Step 4: every requested non-ECDSA entry, against the keys of the party that signed. +/// Step 4: every requested non-ECDSA entry, against the keys of the party that signed, +/// which is returned. `signer` is the party step 3 identified, if any. /// /// That party is the one the caller expects, else the one an ECDSA signature recovered /// to, else the one whose key the first entry verifies under. The entries are bound to @@ -521,18 +486,21 @@ fn verify_scheme_entries( payloads: &SignedPayloads, expected: &ExpectedSigner, keys: &SchemeVerfKeys, - attribution: &mut Attribution, -) -> anyhow::Result<()> + signer: Option<(u32, Address)>, +) -> anyhow::Result> where T: serde::Serialize + tfhe::Versionize + tfhe::named::Named, { let Some(&first) = entries.first() else { - return Ok(()); + return Ok(signer); }; let presented: Vec = list.iter().map(|typed| typed.scheme).collect(); let preimage = wire_scheme_bound_preimage(&presented, payloads.payload) .map_err(|e| anyhow_tracked(format!("could not build the signed payload: {e}")))?; - let party = match (expected, attribution.signer) { + // Under `Known`, `attribute` already held any ECDSA signer to the expected party, + // and under `Discover` an ECDSA signer fixes the keys checked below, so `party` + // cannot differ from `signer`. + let party = match (expected, signer) { ( ExpectedSigner::Known { party_id, address, .. @@ -562,10 +530,7 @@ where "a signature of party {party_id} did not verify: {e}" )) })?; - for &(scheme, _) in entries { - attribution.record(party, Some(scheme))?; - } - Ok(()) + Ok(Some(party)) } /// The party whose published key `entry` verifies under, for a response whose signer From bb2fde279ce5cc1802ae8ac164f5bbbbe5865b00 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 1 Oct 2026 16:41:23 +0200 Subject: [PATCH 097/114] test: consolidation --- core/service/src/client/client_non_wasm.rs | 23 +-- .../src/cryptography/signing/composite.rs | 43 ++--- core/service/src/engine/base.rs | 153 +++++------------- .../service/src/engine/validation_non_wasm.rs | 86 +++++----- 4 files changed, 100 insertions(+), 205 deletions(-) diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index 252c67d396..2f83f6579a 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -321,7 +321,7 @@ mod tests { let err = verify(&client, &[], &payload()).unwrap_err().to_string(); assert!( - err.contains("carries no verified Ecdsa256k1 signature"), + err.contains("carries no signatures"), "the error does not name the cause: {err}" ); } @@ -406,27 +406,6 @@ mod tests { assert_eq!(client.signing_schemes(), &composite); } - /// An entry of a scheme this release does not know is passed over, so a newer node - /// can add a scheme without breaking a verifier still on this release. - #[test] - fn an_entry_of_an_unknown_scheme_is_skipped() { - let identity = seeded_identity(20); - let client = client_for(&identity, &[]); - let mut signatures = - signatures_for(&identity, &[SigningSchemeType::Ecdsa256k1], &payload()); - signatures.push(TypedSignature { - scheme: i32::MAX, - signature: vec![0xEE; 64], - }); - - assert_eq!( - verify(&client, &signatures, &payload()).unwrap(), - (PARTY, identity.verf_key().address()) - ); - // On its own the unknown entry authenticates nothing. - assert!(verify(&client, &signatures[1..], &payload()).is_err()); - } - /// A signature that cannot be checked for want of a key must not pass for one /// that was checked. #[test] diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index ab7af521d5..92a50217db 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -464,18 +464,22 @@ mod tests { fn result_entries_split_ecdsa_from_the_rest() { let mut rng = AesRng::seed_from_u64(20); let identity = seeded_identity(&mut rng); - let schemes = [ + let requested = [ + SigningSchemeType::MlDsa65, SigningSchemeType::Ecdsa256k1, - SigningSchemeType::Ed25519, SigningSchemeType::MlDsa65, + SigningSchemeType::Ed25519, ]; let eip712_hash = B256::repeat_byte(0x11); let payload = msg(); - let bound = scheme_bound_preimage(&schemes, &payload).unwrap(); + let bound = scheme_bound_preimage(&requested, &payload).unwrap(); let entries = - sign_result_entries(&identity, &schemes, DSEP, &eip712_hash, &payload).unwrap(); - assert_eq!(entries.len(), 3); + sign_result_entries(&identity, &requested, DSEP, &eip712_hash, &payload).unwrap(); + assert_eq!( + entry_schemes(&entries), + canonical_schemes(&requested).unwrap() + ); for entry in &entries { if entry.scheme == SigningSchemeType::Ecdsa256k1 { @@ -498,35 +502,6 @@ mod tests { } } - /// Result entries use the same ordering convention as [`sign_composite`]: - /// by scheme, duplicate-free, whatever order the request arrived in. - #[test] - fn result_entries_are_ordered_by_scheme() { - let mut rng = AesRng::seed_from_u64(21); - let identity = seeded_identity(&mut rng); - let requested = [ - SigningSchemeType::MlDsa65, - SigningSchemeType::Ecdsa256k1, - SigningSchemeType::MlDsa65, - SigningSchemeType::Ed25519, - ]; - let canonical = canonical_schemes(&requested).unwrap(); - - let entries = sign_result_entries( - &identity, - &requested, - DSEP, - &B256::repeat_byte(0x11), - &msg(), - ) - .unwrap(); - - assert_eq!( - entries.iter().map(|e| e.scheme).collect::>(), - canonical - ); - } - /// An empty set is a caller mistake, not a request for no signatures: a /// result nothing signed is one the verifier refuses. #[test] diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index 646b452779..46986a660f 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -1832,8 +1832,7 @@ pub(crate) mod tests { use crate::cryptography::signing::identity::NodeSigningIdentity; use crate::cryptography::signing::seed::RootSigningSeed; use crate::cryptography::signing::{ - Signature, SigningSchemeType, canonical_schemes, composite::scheme_bound_preimage, - unified_verify, + Signature, SigningSchemeType, composite::scheme_bound_preimage, unified_verify, }; use crate::engine::base::DSEP_PUBLIC_DECRYPTION; use crate::{ @@ -1902,10 +1901,12 @@ pub(crate) mod tests { /// - the deprecated internal `signature` is the raw signature over the payload, /// - `external_signature` is the EIP-712 signature the fhevm contracts verify, /// - the ECDSA entry of `signatures` is byte-identical to `external_signature`, - /// - every other scheme signs the raw payload. + /// - every other scheme signs the scheme-bound preimage of the payload and the + /// extra data. /// /// The split is deliberate — EIP-712 is an EVM/secp256k1 construction, so - /// post-quantum schemes are not bound to it. + /// post-quantum schemes are not bound to it. The `composite` module tests the + /// generic properties of the scheme-bound preimage. /// TODO(0.16): remove the deprecated fields and unify the ECDSA entry of `signatures` with `external_signature`. #[test] fn decryption_scheme_signatures_round_trip() { @@ -1922,6 +1923,8 @@ pub(crate) mod tests { request_id: Some(RequestId::new_random(&mut rng).into()), }; let payload_bytes = bc2wrap::serialize(&payload).unwrap(); + let payload_signed = super::public_dec_payload(&payload_bytes, extra_data); + let other_extra = super::public_dec_payload(&payload_bytes, b"other extra"); let sol_type = compute_public_decryption_message(&handles, &payload.plaintexts, extra_data).unwrap(); @@ -1932,8 +1935,10 @@ pub(crate) mod tests { &domain, ) .unwrap(); + let legacy = internal_sign(&DSEP_PUBLIC_DECRYPTION, &payload_bytes, sk.ecdsa()).unwrap(); // Several choices of schemes, including a classic + post-quantum composite. + // Each choice is in canonical order, which is the order of the entries. let choices: Vec> = vec![ vec![SigningSchemeType::Ecdsa256k1], vec![SigningSchemeType::Ed25519], @@ -1956,97 +1961,51 @@ pub(crate) mod tests { &domain, ) .unwrap(); - assert_eq!(sigs.signatures.len(), schemes.len()); // The payload is carried through untouched, so what was signed is what is returned. assert_eq!(sigs.payload, payload); assert_eq!(sigs.extra_data, extra_data); // The deprecated internal field is the raw signature over the payload. - let legacy = - internal_sign(&DSEP_PUBLIC_DECRYPTION, &payload_bytes, sk.ecdsa()).unwrap(); assert_eq!(sigs.signature, legacy.as_bytes()); - // Populated for every choice of schemes, the ed25519-only one included. - assert!( - !sigs.external_signature.is_empty(), - "external_signature must stay populated until 0.16" - ); + // `external_signature` is populated for every choice of schemes, the + // ed25519-only one included, and recovers to the signer on-chain. assert_eq!(sigs.external_signature, expected); - - // `external_signature` recovers to the signer on-chain. let recovered = recover_address_from_ext_signature(&sol_type, &domain, &sigs.external_signature) .unwrap(); assert_eq!(recovered, sk.verf_key().address()); - // Entries come back ordered by scheme, whatever order was asked for. - let ordered = canonical_schemes(&schemes).unwrap(); - assert_eq!( - sigs.signatures.len(), - ordered.len(), - "expected one entry per requested scheme" - ); - for (scheme, scheme_sig) in ordered.iter().zip(&sigs.signatures) { - // The wire tag matches the scheme, in canonical order. - assert_eq!( - SigningSchemeType::try_from(scheme_sig.scheme).unwrap(), - *scheme - ); - - if *scheme == SigningSchemeType::Ecdsa256k1 { + // One entry per requested scheme, each with the wire tag of its scheme. + let entry_schemes: Vec<_> = sigs + .signatures + .iter() + .map(|entry| SigningSchemeType::try_from(entry.scheme).unwrap()) + .collect(); + assert_eq!(entry_schemes, schemes); + + let signed = scheme_bound_preimage(&schemes, &payload_signed).unwrap(); + let signed_other_extra = scheme_bound_preimage(&schemes, &other_extra).unwrap(); + for (scheme, entry) in entry_schemes.into_iter().zip(&sigs.signatures) { + if scheme == SigningSchemeType::Ecdsa256k1 { // The ECDSA entry is the on-chain-verifiable EIP-712 // signature, not a raw signature over the payload. - assert_eq!(scheme_sig.signature, sigs.external_signature); - assert_ne!(scheme_sig.signature, sigs.signature); - } else { - // Every other scheme signs the versioned payload — the response - // bytes together with the extra data — inside a preimage naming - // the scheme set, so the entry commits to the set it was - // produced under. - let payload_signed = super::public_dec_payload(&payload_bytes, extra_data); - let signed = scheme_bound_preimage(&ordered, &payload_signed).unwrap(); - let vk = sk.unified_verifying_key(*scheme).unwrap(); - let sig = Signature::new(*scheme, scheme_sig.signature.clone()); - unified_verify(&DSEP_PUBLIC_DECRYPTION, &signed, &sig, &vk) - .unwrap_or_else(|e| panic!("{scheme:?} signature should verify: {e}")); - - // The payload on its own is specifically not what was signed. - let mut bare = Vec::new(); - safe_serialize(&payload_signed, &mut bare, SAFE_SER_SIZE_LIMIT).unwrap(); - assert!( - unified_verify(&DSEP_PUBLIC_DECRYPTION, &bare, &sig, &vk).is_err(), - "{scheme:?} signature must be bound to the scheme set" - ); - - // Nor is the same payload under any other scheme set, which is - // what stops an entry being lifted out of a larger response. - for other_set in [ - vec![*scheme], - vec![SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa44], - ] { - if other_set == ordered { - continue; - } - let rebound = scheme_bound_preimage(&other_set, &payload_signed).unwrap(); - assert!( - unified_verify(&DSEP_PUBLIC_DECRYPTION, &rebound, &sig, &vk).is_err(), - "{scheme:?} signature verified under {other_set:?}" - ); - } - - // The extra data is part of what that entry covers. - let other_extra = super::public_dec_payload(&payload_bytes, b"other extra"); - let other = scheme_bound_preimage(&ordered, &other_extra).unwrap(); - assert!( - unified_verify(&DSEP_PUBLIC_DECRYPTION, &other, &sig, &vk).is_err(), - "{scheme:?} signature did not cover the extra data" - ); - - // A tampered message must fail. - assert!( - unified_verify(&DSEP_PUBLIC_DECRYPTION, b"tampered", &sig, &vk).is_err() - ); + assert_eq!(entry.signature, sigs.external_signature); + assert_ne!(entry.signature, sigs.signature); + continue; } + // Every other scheme signs the versioned payload — the response + // bytes together with the extra data — inside a preimage naming + // the scheme set. + let vk = sk.unified_verifying_key(scheme).unwrap(); + let sig = Signature::new(scheme, entry.signature.clone()); + unified_verify(&DSEP_PUBLIC_DECRYPTION, &signed, &sig, &vk) + .unwrap_or_else(|e| panic!("{scheme:?} signature should verify: {e}")); + assert!( + unified_verify(&DSEP_PUBLIC_DECRYPTION, &signed_other_extra, &sig, &vk) + .is_err(), + "{scheme:?} signature did not cover the extra data" + ); } } } @@ -2054,7 +2013,8 @@ pub(crate) mod tests { /// `external_signature` is always the EIP-712 signature, independent of the /// requested schemes, while `signatures` holds exactly the schemes requested: /// ECDSA carries the EIP-712 signature verbatim and every other scheme signs - /// the serialized CRS payload — the same split decryption uses. + /// the scheme-bound preimage of the CRS payload — the same split decryption + /// uses. #[test] fn crs_result_signatures_multi_scheme() { let mut rng = AesRng::seed_from_u64(0x5C15); @@ -2073,7 +2033,6 @@ pub(crate) mod tests { extra_data.clone(), ); let expected_external = compute_eip712_signature(sk.ecdsa(), &sol_type, &domain).unwrap(); - let eip712_hash = sol_type.eip712_signing_hash(&domain); let payload = super::CrsSignedPayload { crs_id, max_num_bits: max_num_bits as u32, @@ -2101,13 +2060,6 @@ pub(crate) mod tests { }) }; - // Requesting no scheme is refused rather than answered with an empty - // list. - assert!( - signatures_for(&[]).is_err(), - "an empty scheme set must be refused" - ); - // Requesting a classic + two post-quantum schemes: `signatures` reflects // exactly the request. let schemes = [ @@ -2118,6 +2070,7 @@ pub(crate) mod tests { let (external_signature, sigs) = signatures_for(&schemes).unwrap(); assert_eq!(external_signature, expected_external); assert_eq!(sigs.len(), schemes.len()); + let signed_payload = scheme_bound_preimage(&schemes, &payload).unwrap(); for stored in &sigs { match stored.scheme { @@ -2128,29 +2081,18 @@ pub(crate) mod tests { // Every other scheme signs the serialized CRS payload, prefixed // by the scheme set the response was produced under. scheme => { - let signed_payload = scheme_bound_preimage(&schemes, &payload).unwrap(); let vk = sk.unified_verifying_key(scheme).unwrap(); let sig = Signature::new(scheme, stored.signature.clone()); unified_verify(&DSEP_PUBDATA_CRS, &signed_payload, &sig, &vk) .unwrap_or_else(|e| panic!("{scheme:?} CRS signature should verify: {e}")); - // Specifically not the EIP-712 hash any more... - assert!( - unified_verify(&DSEP_PUBDATA_CRS, eip712_hash.as_slice(), &sig, &vk) - .is_err(), - "{scheme:?} must sign the payload, not the EIP-712 hash" - ); - assert!( - unified_verify(&DSEP_PUBDATA_CRS, b"tampered", &sig, &vk).is_err(), - "{scheme:?} verified a tampered payload" - ); } } } } /// The keygen counterpart of [`crs_result_signatures_multi_scheme`]: the - /// non-ECDSA entries sign the serialized keygen payload, whose `key_digests` - /// is what distinguishes the keygen shapes. + /// non-ECDSA entries sign the scheme-bound preimage of the keygen payload, + /// whose `key_digests` is what distinguishes the keygen shapes. #[test] fn keygen_result_signatures_sign_the_payload() { let mut rng = AesRng::seed_from_u64(0x4E67); @@ -2192,25 +2134,18 @@ pub(crate) mod tests { ); assert_eq!(inner.signatures.len(), schemes.len()); + let signed = scheme_bound_preimage(&schemes, &expected_payload).unwrap(); for stored in &inner.signatures { match stored.scheme { SigningSchemeType::Ecdsa256k1 => { assert_eq!(stored.signature, inner.external_signature); } scheme => { - let signed = scheme_bound_preimage(&schemes, &expected_payload).unwrap(); let vk = sk.unified_verifying_key(scheme).unwrap(); let sig = Signature::new(scheme, stored.signature.clone()); unified_verify(&DSEP_PUBDATA_KEY, &signed, &sig, &vk).unwrap_or_else(|e| { panic!("{scheme:?} keygen signature should verify: {e}") }); - // The scheme set in the preimage is load-bearing - let mut bare = Vec::new(); - safe_serialize(&expected_payload, &mut bare, SAFE_SER_SIZE_LIMIT).unwrap(); - assert!( - unified_verify(&DSEP_PUBDATA_KEY, &bare, &sig, &vk).is_err(), - "{scheme:?} keygen signature must be bound to the scheme set" - ); } } } diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 6af0ed7f61..484572dff5 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -2320,39 +2320,8 @@ mod tests { .unwrap(); assert!(verify_list(&superset_ctx, &superset_signatures)); - // Dropping the unrequested entry changes the presented set, which the - // requested entry is bound to, so what is left no longer verifies. - let stripped: Vec<_> = superset_signatures - .iter() - .filter(|typed| typed.scheme == scheme.as_wire()) - .cloned() - .collect(); - assert!(!verify_list(&pq_ctx, &stripped)); - - // Appending an entry the server did not sign changes the presented set in - // the same way, so it breaks a response that was otherwise complete. - let mut with_junk = signatures.clone(); - with_junk.push(TypedSignature { - scheme: kms_grpc::kms::v1::SigningSchemeType::Ed25519 as i32, - signature: vec![0u8; 64], - }); - assert!(!verify_list(&pq_ctx, &with_junk)); - let junk_only = vec![with_junk.pop().unwrap()]; - assert!(!verify_list(&pq_ctx, &junk_only)); - - // An entry of a scheme this release does not know is bound into the preimage - // like any other, so appending one the server did not sign is rejected too. - let mut with_unknown = signatures.clone(); - with_unknown.push(TypedSignature { - scheme: i32::MAX, - signature: vec![0u8; 64], - }); - assert!(!verify_list(&pq_ctx, &with_unknown)); - // A newer node that signs under a scheme this release does not know binds - // that scheme into the preimage of its other entries. Its response verifies - // here on the requested entry, wherever the unknown entry sits in the list, - // and the unknown entry itself is not checked. + // that scheme into the preimage of its other entries. let unknown_scheme = i32::MAX; let newer_preimage = wire_scheme_bound_preimage(&[scheme.as_wire(), unknown_scheme], &signed).unwrap(); @@ -2367,14 +2336,51 @@ mod tests { scheme: unknown_scheme, signature: vec![0u8; 64], }; - let newer = vec![newer_entry.clone(), unknown_entry.clone()]; - assert!(verify_list(&pq_ctx, &newer)); - let newer_reordered = vec![unknown_entry, newer_entry.clone()]; - assert!(verify_list(&pq_ctx, &newer_reordered)); - - // Dropping the unknown entry changes the presented set, so the requested - // entry no longer verifies. - assert!(!verify_list(&pq_ctx, &[newer_entry])); + let unsigned_ed25519 = TypedSignature { + scheme: kms_grpc::kms::v1::SigningSchemeType::Ed25519 as i32, + signature: vec![0u8; 64], + }; + let stripped: Vec<_> = superset_signatures + .iter() + .filter(|typed| typed.scheme == scheme.as_wire()) + .cloned() + .collect(); + + // The requested entry is bound to the set of entries the response + // presents, so an added or a dropped entry, known or unknown, breaks it. + // An entry nobody asked for is not checked, so the response of the newer + // node verifies wherever its unknown entry sits. + for (case, list, accepted) in [ + ( + "newer node, unknown entry last", + vec![newer_entry.clone(), unknown_entry.clone()], + true, + ), + ( + "newer node, unknown entry first", + vec![unknown_entry.clone(), newer_entry.clone()], + true, + ), + ( + "newer node without its unknown entry", + vec![newer_entry], + false, + ), + ("superset without its unrequested entry", stripped, false), + ( + "an unsigned known entry appended", + [signatures.clone(), vec![unsigned_ed25519]].concat(), + false, + ), + ( + "an unsigned unknown entry appended", + [signatures.clone(), vec![unknown_entry.clone()]].concat(), + false, + ), + ("only an unrequested entry", vec![unknown_entry], false), + ] { + assert_eq!(verify_list(&pq_ctx, &list), accepted, "{case}"); + } } #[test] From 623cdeca625b1a557d61d7ced1b4e47d4dcb1a73 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 1 Oct 2026 16:54:01 +0200 Subject: [PATCH 098/114] test: fix new mpc epocg request test --- .../service/src/engine/validation_non_wasm.rs | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 484572dff5..22e62a7258 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -2385,31 +2385,55 @@ mod tests { #[test] fn test_validate_new_mpc_epoch_request() { - // When previous_epoch is set but domain is None, optional_protobuf_to_alloy_domain - // should fail, and validate_new_mpc_epoch_request should surface InvalidArgument. + let epoch_id = derive_request_id("test_validate_new_mpc_epoch_request").unwrap(); + // Every case but the first carries a valid epoch ID, so that each can only be + // rejected for the reason it names. + let resharing_req = || NewMpcEpochRequest { + signing_schemes: vec![kms_grpc::kms::v1::SigningSchemeType::Ecdsa256k1 as i32], + epoch_id: Some(epoch_id.into()), + previous_epoch: Some(PreviousEpochInfo::default()), + domain: Some(alloy_to_protobuf_domain(&dummy_domain()).unwrap()), + ..Default::default() + }; + + // A request without an epoch ID is rejected. { let req = NewMpcEpochRequest { - signing_schemes: vec![kms_grpc::kms::v1::SigningSchemeType::Ecdsa256k1 as i32], - previous_epoch: Some(PreviousEpochInfo::default()), - domain: None, - ..Default::default() + epoch_id: None, + ..resharing_req() }; let err = validate_new_mpc_epoch_request(req) - .expect_err("request without domain must be rejected"); + .expect_err("request without epoch ID must be rejected"); assert_eq!(err.code(), tonic::Code::InvalidArgument); } - // Happy path + // A resharing request has to carry the EIP-712 domain its results are signed under. { let req = NewMpcEpochRequest { - signing_schemes: vec![kms_grpc::kms::v1::SigningSchemeType::Ecdsa256k1 as i32], - previous_epoch: Some(PreviousEpochInfo::default()), - domain: Some(alloy_to_protobuf_domain(&dummy_domain()).unwrap()), - ..Default::default() + domain: None, + ..resharing_req() }; let err = validate_new_mpc_epoch_request(req) - .expect_err("request without domain must be rejected"); + .expect_err("resharing request without domain must be rejected"); assert_eq!(err.code(), tonic::Code::InvalidArgument); } + // A resharing request with a domain is accepted, and keeps that domain. + { + let verified = validate_new_mpc_epoch_request(resharing_req()).unwrap(); + let resharing = verified + .resharing + .expect("a request with a previous epoch is a resharing"); + assert_eq!(resharing.signing_domain, dummy_domain()); + } + // Without a previous epoch nothing is reshared, so no domain is needed. + { + let req = NewMpcEpochRequest { + previous_epoch: None, + domain: None, + ..resharing_req() + }; + let verified = validate_new_mpc_epoch_request(req).unwrap(); + assert!(verified.resharing.is_none()); + } } #[test] From 57c9ba5d0d05bec691d5e7c748d797a6dba068c5 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 12:09:23 +0200 Subject: [PATCH 099/114] fix: lint --- core/service/src/engine/base.rs | 3 +-- core/service/src/engine/storage_material_verification.rs | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index 24894dce76..cd271cd579 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -1831,7 +1831,7 @@ pub(crate) mod tests { use crate::cryptography::signatures::internal_sign; use crate::cryptography::signing::identity::NodeSigningIdentity; use crate::cryptography::signing::{ - Signature, SigningSchemeType, canonical_schemes, composite::scheme_bound_preimage, + Signature, SigningSchemeType, composite::scheme_bound_preimage, test_support::seeded_identity, unified_verify, }; use crate::engine::base::DSEP_PUBLIC_DECRYPTION; @@ -2069,7 +2069,6 @@ pub(crate) mod tests { let (external_signature, sigs) = signatures_for(&schemes).unwrap(); assert_eq!(external_signature, expected_external); assert_eq!(sigs.len(), schemes.len()); - let signed_payload = scheme_bound_preimage(&schemes, &payload).unwrap(); for stored in &sigs { match stored.scheme { diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index 6ed478fe2e..e813c4afc0 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -41,9 +41,7 @@ use crate::cryptography::signing::ecdsa::{ PrivateSigKey, PublicSigKey, recover_address_from_eip712_hash, }; use crate::cryptography::signing::identity::NodeSigningIdentity; -use crate::cryptography::signing::{ - Signature, SigningSchemeType, StoredTypedSignature, VerfKeySet, unified_verify, -}; +use crate::cryptography::signing::{SigningSchemeType, StoredTypedSignature, VerfKeySet}; use crate::engine::base::{ CrsGenMetadata, CrsGenMetadataInner, CurrentPublicMaterialLayout, DSEP_PUBDATA_CRS, DSEP_PUBDATA_KEY, KeyGenMetadata, KeyGenMetadataInner, classify_current_public_material, From 4a0dd1f5699d170f7e8d03dcfc885b2a272cfa65 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 13:19:15 +0200 Subject: [PATCH 100/114] fix: merge issue --- core/service/src/engine/base.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/core/service/src/engine/base.rs b/core/service/src/engine/base.rs index cd271cd579..672807bef8 100644 --- a/core/service/src/engine/base.rs +++ b/core/service/src/engine/base.rs @@ -2132,7 +2132,6 @@ pub(crate) mod tests { ); assert_eq!(inner.signatures.len(), schemes.len()); - let signed = scheme_bound_preimage(&schemes, &expected_payload).unwrap(); for stored in &inner.signatures { match stored.scheme { SigningSchemeType::Ecdsa256k1 => { From 987598c9fcf5c47fae24969447c8485bd23b655c Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 13:49:51 +0200 Subject: [PATCH 101/114] fix: small inconsistency with storage boot validation --- .../engine/storage_material_verification.rs | 100 +++++++++++++++++- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index e813c4afc0..a9be94bcc1 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -547,6 +547,28 @@ where )?; } + // Distinct from a record that carries only an ECDSA entry, which is the + // ordinary shape. Every record this release writes or upgrades carries at + // least that entry. + if signatures.is_empty() { + tracing::warn!( + "Current {metadata_kind} metadata for id={metadata_id} carries no signatures at all; \ + nothing but its EIP-712 signature stands behind it" + ); + return Ok(()); + } + + // The set is derived from the stored entries rather than requested from + // outside: at boot there is no request to measure against. + let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); + let canonical = crate::cryptography::signing::composite::canonical_schemes(&stored_schemes)?; + if stored_schemes != canonical { + anyhow::bail!( + "Private {metadata_kind} metadata for id={metadata_id} lists its signatures out of \ + order or repeats a scheme: has {stored_schemes:?}, expected {canonical:?}" + ); + } + let mut scheme_entries = Vec::new(); for stored in signatures { if stored.scheme != SigningSchemeType::Ecdsa256k1 { @@ -566,9 +588,6 @@ where return Ok(()); } - // The set is derived from the stored entries rather than requested from - // outside: at boot there is no request to measure against. - let stored_schemes: Vec<_> = signatures.iter().map(|stored| stored.scheme).collect(); let preimage = scheme_bound_preimage(&stored_schemes, payload)?; for (scheme, signature) in scheme_entries { let keys = VerfKeySet::from_identity(identity, &[scheme]).map_err(|e| { @@ -2179,6 +2198,81 @@ mod tests { .expect("a seedless node without recovery material must still boot"); } + /// Removing one entry from a multi-scheme record is caught, because the entries + /// that survive attest to the set the removed one belonged to. Reordering or + /// repeating entries is caught too, so the list a record shows is the list its + /// signatures were made over. + #[test] + fn private_keygen_metadata_rejects_a_tampered_scheme_list() { + let mut rng = AesRng::seed_from_u64(177); + let identity = seeded_identity(&mut rng); + let domain = crate::dummy_domain(); + let prep_id = RequestId::new_random(&mut rng); + let key_id = RequestId::new_random(&mut rng); + let schemes = [SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa65]; + + let metadata = crate::engine::base::compute_info_standard_keygen_from_digests( + &identity, + &schemes, + &prep_id, + &key_id, + vec![0x11; 32], + vec![0x22; 32], + &domain, + vec![0x03], + ) + .expect("standard keygen metadata construction must succeed"); + let KeyGenMetadata::Current(inner) = &metadata else { + panic!("metadata construction must produce current metadata"); + }; + verify_keygen_metadata_signature(&key_id, inner, &identity) + .expect("the record must verify as written"); + assert_eq!(inner.signatures.len(), 2, "both entries must be present"); + + // Dropping the post-quantum entry leaves only the ECDSA one, which signs + // the EIP-712 hash and therefore binds no scheme set. This is a residual + // gap on `verify_metadata_signatures`, and it still verifies. + let mut stripped = inner.clone(); + stripped + .signatures + .retain(|entry| entry.scheme == SigningSchemeType::Ecdsa256k1); + verify_keygen_metadata_signature(&key_id, &stripped, &identity) + .expect("known gap: a record reduced to its ECDSA entry alone still verifies"); + + // The strip the binding does catch: the entry that survives is a bound one, + // and it attests to a set that no longer matches what the record lists. + let mut ecdsa_dropped = inner.clone(); + ecdsa_dropped + .signatures + .retain(|entry| entry.scheme != SigningSchemeType::Ecdsa256k1); + let err = verify_keygen_metadata_signature(&key_id, &ecdsa_dropped, &identity) + .expect_err("a record stripped of its ECDSA entry must not verify as complete"); + assert!( + err.to_string().contains("has been removed"), + "the error should name a removed entry as a cause, got: {err}" + ); + + // Repeating an entry changes the list without changing any signature. + let mut repeated = inner.clone(); + repeated.signatures.push(inner.signatures[0].clone()); + let err = verify_keygen_metadata_signature(&key_id, &repeated, &identity) + .expect_err("a repeated scheme must be rejected"); + assert!( + err.to_string().contains("repeats a scheme"), + "the error should name the repeated scheme, got: {err}" + ); + + // So does listing them out of canonical order. + let mut reordered = inner.clone(); + reordered.signatures.reverse(); + let err = verify_keygen_metadata_signature(&key_id, &reordered, &identity) + .expect_err("an out-of-order scheme list must be rejected"); + assert!( + err.to_string().contains("out of order"), + "the error should name the ordering, got: {err}" + ); + } + #[test] fn private_standard_keygen_metadata_signature_verifies_with_stored_domain() { let mut rng = AesRng::seed_from_u64(176); From b527e7b6908509b5fd67e0bbf1897e19953f0d7a Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 13:52:55 +0200 Subject: [PATCH 102/114] refactor: removed dead helper --- .../src/cryptography/signing/verf_key_set.rs | 63 +------------------ 1 file changed, 1 insertion(+), 62 deletions(-) diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 20f48c2433..3a0240963b 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -2,18 +2,12 @@ use super::ecdsa::PublicSigKey; use super::identity::NodeSigningIdentity; -use super::{ - HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey, canonical_schemes, -}; -use hashing::{DomainSep, hash_element}; +use super::{HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey}; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; use tfhe::named::Named; use tfhe_versionable::{Versionize, VersionsDispatch}; -/// Domain separator for the digest that identifies the keys a signature is made with. -const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; - /// One party's verification keys, keyed by the scheme each belongs to. /// /// The counterpart of [`NodeSigningIdentity`] on the verifying side: an identity @@ -181,30 +175,6 @@ impl VerfKeySet { self.get(scheme) .ok_or(SigningError::NoVerificationKey(scheme)) } - - /// The identifier of the keys a signature under `schemes` is made with. - pub fn id(&self, schemes: &[SigningSchemeType]) -> Result, SigningError> { - let schemes = canonical_schemes(schemes)?; - Ok(hash_element( - &DSEP_VERF_KEY_SET, - &self.canonical_bytes(&schemes)?, - )) - } - - /// The unambiguous byte encoding of the keys for the canonical `schemes`, - /// for use inside a digest. - fn canonical_bytes(&self, schemes: &[SigningSchemeType]) -> Result, SigningError> { - let mut out = Vec::new(); - // Bounded by the number of known schemes, so the cast cannot truncate. - out.extend_from_slice(&(schemes.len() as u32).to_le_bytes()); - for &scheme in schemes { - out.extend_from_slice(&scheme.tag()); - let bytes = self.require(scheme)?.digest(); - out.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); - out.extend_from_slice(&bytes); - } - Ok(out) - } } #[cfg(test)] @@ -322,35 +292,4 @@ mod tests { )); } } - - /// The id names the selected keys: swapping a selected key, or changing the - /// selection, changes it, while a key outside the selection does not. - #[test] - fn id_names_exactly_the_selected_keys() { - let mut rng = AesRng::seed_from_u64(4); - let identity = seeded_identity(&mut rng); - let other_identity = seeded_identity(&mut rng); - let every_scheme: Vec<_> = SigningSchemeType::iter().collect(); - let selected = [SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]; - - let base = VerfKeySet::from_identity(&identity, &every_scheme).unwrap(); - let base_id = base.id(&selected).unwrap(); - - for &scheme in &every_scheme { - let mut swapped = base.keys.clone(); - swapped.insert( - scheme, - other_identity.unified_verifying_key(scheme).unwrap(), - ); - let swapped_id = VerfKeySet::new(swapped).unwrap().id(&selected).unwrap(); - assert_eq!( - base_id == swapped_id, - !selected.contains(&scheme), - "swapping the {scheme} key had the wrong effect on the id" - ); - } - - assert_ne!(base_id, base.id(&[SigningSchemeType::Ecdsa256k1]).unwrap()); - assert_ne!(base_id, base.id(&every_scheme).unwrap()); - } } From d0ba62a145ac4d847190d4304d62747796b28de3 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 14:15:02 +0200 Subject: [PATCH 103/114] refactor: streamline flow with using nodesigning identity everywhere --- core/service/src/engine/centralized/central_kms.rs | 10 +++++----- core/service/src/engine/traits.rs | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/core/service/src/engine/centralized/central_kms.rs b/core/service/src/engine/centralized/central_kms.rs index ed9a2d1ec7..69a7118805 100644 --- a/core/service/src/engine/centralized/central_kms.rs +++ b/core/service/src/engine/centralized/central_kms.rs @@ -565,7 +565,7 @@ pub async fn async_user_decrypt< let external_handle = typed_ciphertext.external_handle.clone(); let signcrypted_ciphertext = RealCentralizedKms::::user_decrypt( keys, - identity.ecdsa(), + identity, rng, high_level_ct, fhe_type, @@ -878,7 +878,7 @@ impl< fn user_decrypt( keys: &KmsFheKeyHandles, - sig_key: &PrivateSigKey, + identity: &NodeSigningIdentity, rng: &mut (impl CryptoRng + RngCore), ct: &[u8], fhe_type: FheTypes, @@ -887,8 +887,8 @@ impl< client_enc_key: &UnifiedPublicEncKey, client_id: &[u8], ) -> anyhow::Result> { - let signcryption_key = UnifiedSigncryptionKey::from_signing_key( - sig_key.clone(), + let signcryption_key = UnifiedSigncryptionKey::new( + Arc::new(identity.clone()), client_enc_key.clone(), client_id.to_vec(), ); @@ -1909,7 +1909,7 @@ pub(crate) mod tests { .read_centralized_fhe_keys(key_id, epoch_id) .await .unwrap(), - kms.base_kms.signing_identity().unwrap().ecdsa(), + &kms.base_kms.signing_identity().unwrap(), &mut rng, &ct, fhe_type, diff --git a/core/service/src/engine/traits.rs b/core/service/src/engine/traits.rs index 21952d6ad1..c5b94c8be9 100644 --- a/core/service/src/engine/traits.rs +++ b/core/service/src/engine/traits.rs @@ -9,7 +9,7 @@ use tonic::Request; use tonic::Response; use crate::cryptography::encryption::UnifiedPublicEncKey; -use crate::cryptography::signatures::{PrivateSigKey, Signature}; +use crate::cryptography::signatures::{NodeSigningIdentity, Signature}; use crate::engine::base::KeyGenMetadata; use crate::engine::utils::MetricedError; @@ -34,10 +34,11 @@ pub trait Kms: BaseKms { fhe_type: FheTypes, ct_format: CiphertextFormat, ) -> anyhow::Result; + #[expect(clippy::too_many_arguments)] fn user_decrypt( keys: &KmsFheKeyHandles, - sig_key: &PrivateSigKey, + identity: &NodeSigningIdentity, rng: &mut (impl CryptoRng + RngCore), ct: &[u8], ct_type: FheTypes, From e221204bd378a2f422e7c1428be344e2565f14e6 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 14:39:34 +0200 Subject: [PATCH 104/114] fix: ensure old schemes are honored during resigning --- .../engine/threshold/service/key_generator.rs | 3 +- .../crypto_material/tests/migration.rs | 106 ++++++++++++++++-- .../storage/crypto_material/threshold.rs | 51 ++++----- 3 files changed, 123 insertions(+), 37 deletions(-) diff --git a/core/service/src/engine/threshold/service/key_generator.rs b/core/service/src/engine/threshold/service/key_generator.rs index d583ac8c22..f6611ab51b 100644 --- a/core/service/src/engine/threshold/service/key_generator.rs +++ b/core/service/src/engine/threshold/service/key_generator.rs @@ -1727,7 +1727,8 @@ impl< epoch_id, &old_key_id, epoch_id, - sk.ecdsa(), + &sk, + &signing_schemes, &eip712_domain, Arc::clone(&meta_store), ) diff --git a/core/service/src/vault/storage/crypto_material/tests/migration.rs b/core/service/src/vault/storage/crypto_material/tests/migration.rs index 4d2bfea6fd..f793b3cc65 100644 --- a/core/service/src/vault/storage/crypto_material/tests/migration.rs +++ b/core/service/src/vault/storage/crypto_material/tests/migration.rs @@ -1,5 +1,6 @@ use super::*; use crate::{ + cryptography::signatures::{RootSigningSeed, SigningSchemeType}, cryptography::signing::identity::NodeSigningIdentity, engine::base::{ DSEP_PUBDATA_KEY, compute_info_compressed_keygen, compute_info_uncompressed_keygen, @@ -349,7 +350,8 @@ async fn test_copy_compressed_key_to_original_success() { &epoch_id, &old_key_id, &epoch_id, - &sk, + &NodeSigningIdentity::ecdsa_only(sk.clone()), + &[SigningSchemeType::Ecdsa256k1], &domain, meta_store.clone(), ) @@ -404,6 +406,90 @@ async fn test_copy_compressed_key_to_original_success() { } } +/// The migration re-signs, so it must honour the schemes the request asked for. +#[tokio::test] +async fn copy_compressed_key_to_original_signs_under_every_requested_scheme() { + let old_key_id = derive_request_id("copy_compressed_pq_old_key").unwrap(); + let new_key_id = derive_request_id("copy_compressed_pq_new_key").unwrap(); + let prep_id = derive_request_id("copy_compressed_pq_prep").unwrap(); + let epoch_id: EpochId = derive_request_id("copy_compressed_pq_epoch") + .unwrap() + .into(); + + let (sk, domain, compressed_keyset, _generated_pk, _) = + generate_compressed_keys(&new_key_id, &prep_id, 201); + let crypto_storage = ram_threshold_storage(None); + + let (old_fhe_keys, compact_pk) = setup_pre_migration_uncompressed( + &crypto_storage, + &old_key_id, + &epoch_id, + &sk, + &prep_id, + &domain, + ) + .await; + let new_fhe_keys = threshold_fhe_keys_for_compressed_keyset( + &new_key_id, + &prep_id, + old_fhe_keys.private_keys.clone(), + &compact_pk, + &sk, + &domain, + &compressed_keyset, + Vec::new(), + ); + store_migrated_compressed_material( + &crypto_storage, + &new_key_id, + &epoch_id, + &compressed_keyset, + &compact_pk, + &new_fhe_keys, + ) + .await; + let meta_store = seeded_meta_store(&old_key_id, &old_fhe_keys.meta_data); + + // Same ECDSA key as the fixtures, so the EIP-712 signature still recovers to + // the expected address, plus a seed so the post-quantum key can be derived. + let identity = NodeSigningIdentity::new( + sk.clone(), + RootSigningSeed::random(&mut AesRng::seed_from_u64(201)), + ); + let requested = [SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa65]; + + crypto_storage + .copy_compressed_key_to_original( + &new_key_id, + &epoch_id, + &old_key_id, + &epoch_id, + &identity, + &requested, + &domain, + meta_store.clone(), + ) + .await + .expect("the migration must succeed when every requested scheme can be signed"); + + let guarded = crypto_storage + .read_guarded_fhe_keys(&old_key_id, &epoch_id) + .await + .unwrap(); + let KeyGenMetadata::Current(inner) = &guarded.meta_data else { + panic!("the migrated record must carry current metadata"); + }; + assert_eq!( + inner + .signatures + .iter() + .map(|entry| entry.scheme) + .collect::>(), + requested.to_vec(), + "the re-signed record must name every requested scheme, in canonical order" + ); +} + #[tokio::test] async fn test_copy_compressed_key_overwrite() { let old_key_id = derive_request_id("copy_overwrite_old").unwrap(); @@ -455,7 +541,8 @@ async fn test_copy_compressed_key_overwrite() { &epoch_id, &old_key_id, &epoch_id, - &sk, + &NodeSigningIdentity::ecdsa_only(sk.clone()), + &[SigningSchemeType::Ecdsa256k1], &domain, meta_store.clone(), ) @@ -501,7 +588,8 @@ async fn test_copy_compressed_key_overwrite() { &epoch_id, &old_key_id, &epoch_id, - &sk, + &NodeSigningIdentity::ecdsa_only(sk.clone()), + &[SigningSchemeType::Ecdsa256k1], &domain, meta_store.clone(), ) @@ -560,7 +648,8 @@ async fn test_copy_compressed_key_missing_source() { &epoch_id, &old_key_id, &epoch_id, - &sk, + &NodeSigningIdentity::ecdsa_only(sk.clone()), + &[SigningSchemeType::Ecdsa256k1], &domain, meta_store, ) @@ -618,7 +707,8 @@ async fn test_copy_compressed_key_legacy_metadata_fails() { &epoch_id, &old_key_id, &epoch_id, - &sk, + &NodeSigningIdentity::ecdsa_only(sk.clone()), + &[SigningSchemeType::Ecdsa256k1], &domain, meta_store, ) @@ -691,7 +781,8 @@ async fn test_copy_compressed_key_validation_failure_is_atomic() { &epoch_id, &old_key_id, &epoch_id, - &sk, + &NodeSigningIdentity::ecdsa_only(sk.clone()), + &[SigningSchemeType::Ecdsa256k1], &domain, meta_store.clone(), ) @@ -831,7 +922,8 @@ async fn test_copy_compressed_key_updates_backup_vault() { &epoch_id, &old_key_id, &epoch_id, - &sk, + &NodeSigningIdentity::ecdsa_only(sk.clone()), + &[SigningSchemeType::Ecdsa256k1], &domain, meta_store, ) diff --git a/core/service/src/vault/storage/crypto_material/threshold.rs b/core/service/src/vault/storage/crypto_material/threshold.rs index 882857ee7d..e9832b910a 100644 --- a/core/service/src/vault/storage/crypto_material/threshold.rs +++ b/core/service/src/vault/storage/crypto_material/threshold.rs @@ -9,9 +9,12 @@ use tokio::sync::{Mutex, OwnedRwLockReadGuard, RwLock}; use super::base::CryptoMaterialStorage; use crate::{ - cryptography::signatures::{PrivateSigKey, StoredTypedSignature, compute_eip712_signature}, + cryptography::signatures::{NodeSigningIdentity, SigningSchemeType}, engine::{ - base::{CrsGenMetadata, KeyGenMetadata}, + base::{ + CrsGenMetadata, CurrentPublicMaterialLayout, KeyGenMetadata, + compute_info_keygen_from_digests, + }, material_integrity::verify_public_key_digest_from_bytes, threshold::service::{ThresholdFheKeys, epoch_manager::EpochData}, }, @@ -30,7 +33,6 @@ use crate::{ }, }, }; -use kms_grpc::solidity_types::KeygenVerification; use kms_grpc::{ RequestId, identifiers::EpochId, @@ -236,7 +238,8 @@ impl>>, ) -> anyhow::Result<()> { @@ -299,15 +302,14 @@ impl Date: Tue, 6 Oct 2026 15:30:58 +0200 Subject: [PATCH 105/114] fix: ensure we can also validate signatures from preprocessing in core client --- core-client/src/keygen.rs | 31 +++++++++++++ core-client/src/lib.rs | 45 ++++++++++++++++++- .../tests/integration/integration_test.rs | 3 ++ core/service/src/backup/custodian.rs | 2 +- core/service/src/backup/seed_phrase.rs | 2 +- core/service/src/engine/context_manager.rs | 2 +- docs/guides/core_client.md | 14 +++++- 7 files changed, 92 insertions(+), 7 deletions(-) diff --git a/core-client/src/keygen.rs b/core-client/src/keygen.rs index 807c26be37..4db9faea4d 100644 --- a/core-client/src/keygen.rs +++ b/core-client/src/keygen.rs @@ -841,6 +841,37 @@ pub(crate) async fn do_partial_preproc( Ok(req_id) } +/// Check every signature each preprocessing response carries, under every scheme +/// the client requested, and that each was produced by a known KMS party. +/// +/// The result-only command cannot see the request that produced these responses, +/// so the context and epoch it was made under come from the caller and have to +/// match — the same contract a keygen or CRS result carries. `None` skips the +/// check, which is what `--no-verify` asks for. +pub(crate) fn check_preproc_responses( + internal_client: &Client, + request_id: &RequestId, + verify: Option<&SigVerificationMaterial>, + responses: &[KeyGenPreprocResult], +) -> anyhow::Result<()> { + let Some(material) = verify else { + return Ok(()); + }; + for response in responses { + internal_client.process_preproc_response( + request_id, + &material.domain, + response, + material.extra_data.clone(), + )?; + } + tracing::info!( + "Verified the signatures on {} preprocessing responses for {request_id}", + responses.len() + ); + Ok(()) +} + pub(crate) async fn get_preproc_keygen_responses( core_endpoints: &HashMap>, request_id: RequestId, diff --git a/core-client/src/lib.rs b/core-client/src/lib.rs index 36e42b50c8..3d3aad3e65 100644 --- a/core-client/src/lib.rs +++ b/core-client/src/lib.rs @@ -941,6 +941,19 @@ pub struct NewTestingMpcContextFileParameters { pub struct ResultParameters { #[clap(long, short = 'i')] pub request_id: RequestId, + /// Context ID the original request was made with, used to derive the `extra_data` the + /// signatures are bound to. Defaults to the built-in default context when omitted; + /// must match the context of the original request or verification fails. + #[clap(long)] + pub context_id: Option, + /// Epoch ID the original request was made with, used to derive the `extra_data` the + /// signatures are bound to. Defaults to the built-in default epoch when omitted; + /// must match the epoch of the original request or verification fails. + #[clap(long)] + pub epoch_id: Option, + /// Skip verification of the signatures and just report the result. + #[clap(long, default_value_t = false)] + pub no_verify: bool, } #[derive(Debug, Parser, Clone)] @@ -2555,15 +2568,43 @@ pub async fn execute_cmd( } CCCommand::PreprocKeyGenResult(result_parameters) => { let req_id: RequestId = result_parameters.request_id; - let _ = + let responses = get_preproc_keygen_responses(&core_endpoints_req, req_id, max_iter, false).await?; + let verify = keygen_crs_verify_ctx( + &cc_conf, + result_parameters.no_verify, + result_parameters.context_id, + result_parameters.epoch_id, + )?; + keygen::check_preproc_responses( + internal_client + .as_ref() + .ok_or_else(|| anyhow::anyhow!("a preproc result needs a KMS client"))?, + &req_id, + verify.as_ref(), + &responses, + )?; vec![(Some(req_id), "preproc result queried".to_string())] } #[cfg(feature = "insecure")] CCCommand::InsecurePreprocKeyGenResult(result_parameters) => { let req_id: RequestId = result_parameters.request_id; - let _ = + let responses = get_preproc_keygen_responses(&core_endpoints_req, req_id, max_iter, true).await?; + let verify = keygen_crs_verify_ctx( + &cc_conf, + result_parameters.no_verify, + result_parameters.context_id, + result_parameters.epoch_id, + )?; + keygen::check_preproc_responses( + internal_client + .as_ref() + .ok_or_else(|| anyhow::anyhow!("a preproc result needs a KMS client"))?, + &req_id, + verify.as_ref(), + &responses, + )?; vec![(Some(req_id), "insecure preproc result queried".to_string())] } CCCommand::KeyGenResult(result_parameters) => { diff --git a/core-client/tests/integration/integration_test.rs b/core-client/tests/integration/integration_test.rs index 417b51cd96..01f3724eb2 100644 --- a/core-client/tests/integration/integration_test.rs +++ b/core-client/tests/integration/integration_test.rs @@ -1363,6 +1363,9 @@ async fn integration_test_commands( let get_res_command = match command { CCCommand::PreprocKeyGen(_) => CCCommand::PreprocKeyGenResult(ResultParameters { request_id: req_id.unwrap(), + context_id: None, + epoch_id: None, + no_verify: false, }), CCCommand::KeyGen(ref key_gen_parameters) => { CCCommand::KeyGenResult(KeyGenResultParameters { diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index f4004794eb..213e6c8d9e 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -538,7 +538,7 @@ impl Custodian { } } - pub fn public_dec_key(&self) -> &UnifiedPrivateEncKey { + pub fn private_dec_key(&self) -> &UnifiedPrivateEncKey { &self.dec_key } diff --git a/core/service/src/backup/seed_phrase.rs b/core/service/src/backup/seed_phrase.rs index 985b122cb1..09fd38504f 100644 --- a/core/service/src/backup/seed_phrase.rs +++ b/core/service/src/backup/seed_phrase.rs @@ -217,7 +217,7 @@ mod tests { BACKUP_PKE_SCHEME ); assert_eq!( - custodian.public_dec_key().encryption_scheme_type(), + custodian.private_dec_key().encryption_scheme_type(), BACKUP_PKE_SCHEME ); } diff --git a/core/service/src/engine/context_manager.rs b/core/service/src/engine/context_manager.rs index 555670a1d0..93ab4189bc 100644 --- a/core/service/src/engine/context_manager.rs +++ b/core/service/src/engine/context_manager.rs @@ -2800,7 +2800,7 @@ mod tests { .id(BACKUP_SIGNING_SCHEMES) .unwrap(); let unsign_key = UnifiedUnsigncryptionKey::new_multi( - std::sync::Arc::new(custodian1.public_dec_key().clone()), + std::sync::Arc::new(custodian1.private_dec_key().clone()), custodian1.public_enc_key().clone(), server_verf_keys.clone(), custodian_id, diff --git a/docs/guides/core_client.md b/docs/guides/core_client.md index b69fc94d0a..84cd01144c 100644 --- a/docs/guides/core_client.md +++ b/docs/guides/core_client.md @@ -423,9 +423,14 @@ Note that in the threshold setting an insecure preprocessing entry can only be c It is also possible to fetch the status of an insecure preprocessing through its `REQUEST_ID` using the following command: ```{bash} -$ cargo run --bin kms-core-client -F insecure -- -f insecure-preproc-key-gen-result --request-id +$ cargo run --bin kms-core-client -F insecure -- -f insecure-preproc-key-gen-result --request-id [--context-id ] [--epoch-id ] [--no-verify] ``` +Optional arguments: + - `--context-id `: Context ID the original request was made with, used to derive the `extra_data` the signatures are bound to. Defaults to the built-in default context when omitted; must match the context of the original request or verification fails. + - `--epoch-id `: Epoch ID the original request was made with, used to derive the `extra_data` the signatures are bound to. Defaults to the built-in default epoch when omitted; must match the epoch of the original request or verification fails. + - `--no-verify`: Skip verification of the signatures and just report the result. + #### Preprocessing for Secure Key-Generation #### Secure Preprocessing @@ -443,9 +448,14 @@ Note that this will generate large amounts of preprocessing data, which is expen It is also possible to fetch the status of a preprocessing for key generation through its `REQUEST_ID` using the following command: ```{bash} -$ cargo run --bin kms-core-client f-- -f preproc-key-gen-result --request-id +$ cargo run --bin kms-core-client f-- -f preproc-key-gen-result --request-id [--context-id ] [--epoch-id ] [--no-verify] ``` +Optional arguments: + - `--context-id `: Context ID the original request was made with, used to derive the `extra_data` the signatures are bound to. Defaults to the built-in default context when omitted; must match the context of the original request or verification fails. + - `--epoch-id `: Epoch ID the original request was made with, used to derive the `extra_data` the signatures are bound to. Defaults to the built-in default epoch when omitted; must match the epoch of the original request or verification fails. + - `--no-verify`: Skip verification of the signatures and just report the result. + Upon success, both the command to request to generate preprocessing material _and_ the command to fetch the result, will print the following: `preproc done - `. #### Partial (Insecure) Preprocessing From a2f97bcd9db4d5ba767cbe01d538004bc77d0a3c Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 15:31:44 +0200 Subject: [PATCH 106/114] refactor: to avoid wrapper method for verify_reencrypt_inner --- core/service/src/backup/custodian.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 213e6c8d9e..0622b32941 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -393,30 +393,12 @@ impl Custodian { }) } - /// Obtain the operator ephemeral public key for reencryption, - /// unsigncrypt the signcryption encrypted under the custodian's public key - /// and then signcrypt it it under the operator's public key pub fn verify_reencrypt( &self, rng: &mut R, backup: &InnerOperatorBackupOutput, operator_verification_key: &VerfKeySet, operator_ephem_enc_key: &UnifiedPublicEncKey, - ) -> Result { - self.verify_reencrypt_inner( - rng, - backup, - operator_verification_key, - operator_ephem_enc_key, - ) - } - - fn verify_reencrypt_inner( - &self, - rng: &mut R, - backup: &InnerOperatorBackupOutput, - operator_verification_key: &VerfKeySet, - operator_ephem_enc_key: &UnifiedPublicEncKey, ) -> Result { let operator_id = operator_verification_key .id(BACKUP_SIGNING_SCHEMES) From bf102fc8047e4da7816f6e6e01e3d55ae6a517e1 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 15:32:57 +0200 Subject: [PATCH 107/114] fix: removed dead method --- core/service/src/backup/custodian.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 0622b32941..16c70cf782 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -400,11 +400,6 @@ impl Custodian { operator_verification_key: &VerfKeySet, operator_ephem_enc_key: &UnifiedPublicEncKey, ) -> Result { - let operator_id = operator_verification_key - .id(BACKUP_SIGNING_SCHEMES) - .map_err(|e| { - BackupError::SetupError(format!("could not compute the operator key set id: {e}")) - })?; for fingerprint in operator_verification_key.all_fingerprints() { tracing::info!("Verifying and re-encrypting backup for operator {fingerprint}"); } From 8d87ee59349f6b29b64a118a71f905545baf5398 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 15:36:33 +0200 Subject: [PATCH 108/114] test: fix failure by wrong error message in an_entry_with_no_known_key_is_rejected and private_keygen_metadata_rejects_a_tampered_scheme_list --- core/service/src/client/client_non_wasm.rs | 4 ++-- .../src/engine/storage_material_verification.rs | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index 2f83f6579a..7457523eab 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -426,8 +426,8 @@ mod tests { .unwrap_err() .to_string(); assert!( - err.contains("party 1") - && err.contains("no party published a Ed25519 verification key"), + err.contains("party 1 published no verification keys") + && err.contains("Ed25519 signature cannot be checked"), "the error does not name the missing key: {err}" ); } diff --git a/core/service/src/engine/storage_material_verification.rs b/core/service/src/engine/storage_material_verification.rs index a9be94bcc1..dfc1f95dd0 100644 --- a/core/service/src/engine/storage_material_verification.rs +++ b/core/service/src/engine/storage_material_verification.rs @@ -2198,10 +2198,11 @@ mod tests { .expect("a seedless node without recovery material must still boot"); } - /// Removing one entry from a multi-scheme record is caught, because the entries - /// that survive attest to the set the removed one belonged to. Reordering or - /// repeating entries is caught too, so the list a record shows is the list its - /// signatures were made over. + /// Removing an entry from a multi-scheme record is caught whenever a scheme-bound + /// entry survives, because it attests to the set the removed one belonged to. + /// Reordering or repeating entries is caught too, so the list a record shows is + /// the list its signatures were made over. A record reduced to its ECDSA entry + /// alone is the known exception, since that entry binds no scheme set. #[test] fn private_keygen_metadata_rejects_a_tampered_scheme_list() { let mut rng = AesRng::seed_from_u64(177); @@ -2248,8 +2249,8 @@ mod tests { let err = verify_keygen_metadata_signature(&key_id, &ecdsa_dropped, &identity) .expect_err("a record stripped of its ECDSA entry must not verify as complete"); assert!( - err.to_string().contains("has been removed"), - "the error should name a removed entry as a cause, got: {err}" + err.to_string().contains("Invalid MlDsa65 signature"), + "the surviving MlDsa65 entry should fail against the reduced set, got: {err}" ); // Repeating an entry changes the list without changing any signature. From 564ac3781662a58266dc51c8c5715b91c5e4451d Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 15:54:41 +0200 Subject: [PATCH 109/114] refactor: changed to arc --- core/service/src/engine/centralized/central_kms.rs | 10 +++++----- core/service/src/engine/traits.rs | 3 ++- core/service/src/engine/validation_non_wasm.rs | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/core/service/src/engine/centralized/central_kms.rs b/core/service/src/engine/centralized/central_kms.rs index 69a7118805..f57428e81d 100644 --- a/core/service/src/engine/centralized/central_kms.rs +++ b/core/service/src/engine/centralized/central_kms.rs @@ -532,7 +532,7 @@ pub async fn async_user_decrypt< PrivS: StorageExt + Sync + Send + 'static, >( keys: &KmsFheKeyHandles, - identity: &NodeSigningIdentity, + identity: &Arc, rng: &mut (impl CryptoRng + RngCore), typed_ciphertexts: &[TypedCiphertext], req_digest: &[u8], @@ -565,7 +565,7 @@ pub async fn async_user_decrypt< let external_handle = typed_ciphertext.external_handle.clone(); let signcrypted_ciphertext = RealCentralizedKms::::user_decrypt( keys, - identity, + Arc::clone(identity), rng, high_level_ct, fhe_type, @@ -878,7 +878,7 @@ impl< fn user_decrypt( keys: &KmsFheKeyHandles, - identity: &NodeSigningIdentity, + identity: Arc, rng: &mut (impl CryptoRng + RngCore), ct: &[u8], fhe_type: FheTypes, @@ -888,7 +888,7 @@ impl< client_id: &[u8], ) -> anyhow::Result> { let signcryption_key = UnifiedSigncryptionKey::new( - Arc::new(identity.clone()), + identity, client_enc_key.clone(), client_id.to_vec(), ); @@ -1909,7 +1909,7 @@ pub(crate) mod tests { .read_centralized_fhe_keys(key_id, epoch_id) .await .unwrap(), - &kms.base_kms.signing_identity().unwrap(), + kms.base_kms.signing_identity().unwrap(), &mut rng, &ct, fhe_type, diff --git a/core/service/src/engine/traits.rs b/core/service/src/engine/traits.rs index c5b94c8be9..b43a0c43fc 100644 --- a/core/service/src/engine/traits.rs +++ b/core/service/src/engine/traits.rs @@ -4,6 +4,7 @@ use kms_grpc::{ContextId, EpochId}; use rand::CryptoRng; use rand::RngCore; use serde::Serialize; +use std::sync::Arc; use tfhe::FheTypes; use tonic::Request; use tonic::Response; @@ -38,7 +39,7 @@ pub trait Kms: BaseKms { #[expect(clippy::too_many_arguments)] fn user_decrypt( keys: &KmsFheKeyHandles, - identity: &NodeSigningIdentity, + identity: Arc, rng: &mut (impl CryptoRng + RngCore), ct: &[u8], ct_type: FheTypes, diff --git a/core/service/src/engine/validation_non_wasm.rs b/core/service/src/engine/validation_non_wasm.rs index 23c761671d..a7effa54f2 100644 --- a/core/service/src/engine/validation_non_wasm.rs +++ b/core/service/src/engine/validation_non_wasm.rs @@ -2196,7 +2196,7 @@ mod tests { /// of the list, a request that also asked for ECDSA needs the ECDSA entry too, and /// the list may be a superset of what was asked for. #[test] - fn test_public_decrypt_signatures_without_an_eip712_domain() { + fn test_public_decrypt_signatures_follow_the_requested_schemes() { use crate::cryptography::signatures::test_support::seeded_identity; let mut rng = AesRng::seed_from_u64(77); From e028c4b53117df83d006e5d82dd73f67d33dd6af Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 15:56:13 +0200 Subject: [PATCH 110/114] docs: typo --- core/service/src/engine/validation_wasm.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/service/src/engine/validation_wasm.rs b/core/service/src/engine/validation_wasm.rs index 1c4ccf7273..b6fb6ee08c 100644 --- a/core/service/src/engine/validation_wasm.rs +++ b/core/service/src/engine/validation_wasm.rs @@ -477,7 +477,7 @@ fn verify_legacy_ecdsa( /// which is returned. `signer` is the party step 3 identified, if any. /// /// That party is the one the caller expects, else the one an ECDSA signature recovered -/// to, else the one whose key the first entry verifies under. The entries are bound to +/// to, else the one whose keys every entry verifies under. The entries are bound to /// the scheme set `list` presents, not to `requested`, so adding or removing any entry /// makes them all fail. fn verify_scheme_entries( From 97ece8ae459892f54dabf7d645a133612b56f5ac Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 16:14:45 +0200 Subject: [PATCH 111/114] refactor: made helper method and consolidated helper methods for canonical schemes --- core/service/src/backup/custodian.rs | 5 +- core/service/src/backup/mod.rs | 6 +- core/service/src/backup/operator.rs | 31 +++++++++-- .../src/cryptography/signing/composite.rs | 19 ++----- core/service/src/engine/backup_operator.rs | 55 ++++--------------- 5 files changed, 46 insertions(+), 70 deletions(-) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 16c70cf782..363ba957bb 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -74,10 +74,7 @@ impl TryFrom for InternalCustodianRecoveryOutput { anyhow::anyhow!("backup output not part of the custodian recovery output") })?; Ok(InternalCustodianRecoveryOutput { - signcryption: UnifiedSigncryption::new( - backup_output.signcryption.clone(), - backup_output.pke_type.try_into()?, - ), + signcryption: backup_output.try_into()?, custodian_role: Role::indexed_from_one(value.custodian_role as usize), }) } diff --git a/core/service/src/backup/mod.rs b/core/service/src/backup/mod.rs index 8e81f4b2ea..f7710a31f4 100644 --- a/core/service/src/backup/mod.rs +++ b/core/service/src/backup/mod.rs @@ -99,10 +99,6 @@ impl TryFrom<&OperatorBackupOutput> for UnifiedSigncryption { type Error = anyhow::Error; fn try_from(value: &OperatorBackupOutput) -> Result { - let pke_type = value.pke_type.try_into()?; - Ok(UnifiedSigncryption::new( - value.signcryption.clone(), - pke_type, - )) + Self::try_from(value.clone()) } } diff --git a/core/service/src/backup/operator.rs b/core/service/src/backup/operator.rs index 23d5d29787..f167d6f662 100644 --- a/core/service/src/backup/operator.rs +++ b/core/service/src/backup/operator.rs @@ -188,7 +188,7 @@ impl TryFrom for InnerOperatorBackupOutput { fn try_from(value: OperatorBackupOutput) -> Result { Ok(Self { - signcryption: UnifiedSigncryption::new(value.signcryption, value.pke_type.try_into()?), + signcryption: value.try_into()?, }) } } @@ -677,7 +677,7 @@ impl Operator { /// `RecoverySkipReason` on the first failure. /// /// Returns decrypted key shares in a [`Zeroizing`] guard. - pub(crate) fn validate_one_recovery_output( + fn validate_one_recovery_output( &self, output: &InternalCustodianRecoveryOutput, recovery_material: &RecoveryValidationMaterial, @@ -796,8 +796,31 @@ impl Operator { ephm_dec_key: &UnifiedPrivateEncKey, ephm_enc_key: &UnifiedPublicEncKey, ) -> Result>, BackupError> { + let validated = self.validate_outputs( + custodian_recovery_output, + Vec::new(), + recovery_material, + ephm_dec_key, + ephm_enc_key, + )?; + self.recover_from_validated(&validated) + } + + /// Validate every signcrypted custodian recovery output and keep the first valid one of + /// each role. + /// + /// `skip_reasons` holds the reasons outputs were dropped before reaching this point, so + /// that a recovery that falls short reports every reason. Fails unless more than the + /// custodian threshold of roles validated. + pub(crate) fn validate_outputs( + &self, + custodian_recovery_output: &[InternalCustodianRecoveryOutput], + mut skip_reasons: Vec, + recovery_material: &RecoveryValidationMaterial, + ephm_dec_key: &UnifiedPrivateEncKey, + ephm_enc_key: &UnifiedPublicEncKey, + ) -> Result>, BackupError> { let mut validated: HashMap> = HashMap::new(); - let mut skip_reasons: Vec = Vec::new(); let ephm_dec_key = Arc::new(ephm_dec_key.clone()); for output in custodian_recovery_output { match self.validate_one_recovery_output( @@ -838,7 +861,7 @@ impl Operator { skipped: skip_reasons, }); } - self.recover_from_validated(&validated) + Ok(validated) } /// Reconstruct the operator's secret from already-validated per-role `BackupMaterial`s. diff --git a/core/service/src/cryptography/signing/composite.rs b/core/service/src/cryptography/signing/composite.rs index 7cdef9c9de..d190b0fd5a 100644 --- a/core/service/src/cryptography/signing/composite.rs +++ b/core/service/src/cryptography/signing/composite.rs @@ -80,18 +80,11 @@ use zeroize::Zeroizing; /// The marker every composite preimage starts with. pub const COMPOSITE_PREFIX: &[u8; 32] = b"ZamaKmsCompositeSignature2026_v1"; -/// Sort `schemes` into canonical order and drop duplicates. +/// Sort `schemes` into canonical order and drop duplicates. The schemes may be +/// given typed or by their wire discriminants, which sort in the same order. /// /// Errors when `schemes` is empty. -pub fn canonical_schemes( - schemes: &[SigningSchemeType], -) -> Result, SigningError> { - canonical(schemes) -} - -/// [`canonical_schemes`] for schemes given either typed or by their wire -/// discriminants, which sort in the same order. -fn canonical(schemes: &[S]) -> Result, SigningError> { +pub fn canonical_schemes(schemes: &[S]) -> Result, SigningError> { let mut canonical = schemes.to_vec(); canonical.sort_unstable(); canonical.dedup(); @@ -160,7 +153,7 @@ pub fn wire_scheme_bound_preimage( where T: Serialize + Versionize + Named, { - let scheme_bytes = canonical_wire_scheme_bytes(&canonical(schemes)?); + let scheme_bytes = canonical_wire_scheme_bytes(&canonical_schemes(schemes)?); let mut out = ZeroizingWriter::new(); let framed = |e: std::io::Error| SigningError::Serialization(e.to_string()); out.write_all(COMPOSITE_PREFIX).map_err(framed)?; @@ -171,7 +164,7 @@ where } /// The schemes `entries` were made under, in the order they are stored. -fn entry_schemes(entries: &[StoredTypedSignature]) -> Vec { +pub(crate) fn entry_schemes(entries: &[StoredTypedSignature]) -> Vec { entries.iter().map(|entry| entry.scheme).collect() } @@ -597,7 +590,7 @@ mod tests { ); assert!(matches!( - canonical_schemes(&[]), + canonical_schemes::(&[]), Err(SigningError::EmptySchemeSet) )); } diff --git a/core/service/src/engine/backup_operator.rs b/core/service/src/engine/backup_operator.rs index 560f5b4894..231830f26b 100644 --- a/core/service/src/engine/backup_operator.rs +++ b/core/service/src/engine/backup_operator.rs @@ -1,5 +1,5 @@ use crate::backup::custodian::InternalCustodianRecoveryOutput; -use crate::backup::error::{BackupError, RecoverySkipReason}; +use crate::backup::error::RecoverySkipReason; use crate::backup::operator::BackupMaterial; use crate::backup::{BACKUP_PKE_SCHEME, BACKUP_SIGNING_SCHEMES, DSEP_ATTESTED_BACKUP_PK}; use crate::consts::{DEFAULT_EPOCH_ID, SIGNING_KEY_ID}; @@ -903,9 +903,8 @@ async fn filter_custodian_data( ) -> anyhow::Result>> { // Use the number of custodian nodes that was part of the context, not the amount we have received from let outputs_len = recovery_material.custodian_context().custodian_nodes.len(); - let mut parsed_custodian_rec: HashMap> = HashMap::new(); + let mut internal_outputs = Vec::with_capacity(custodian_recovery_outputs.len()); let mut skip_reasons: Vec = Vec::new(); - let ephemeral_dec_key = Arc::new(ephemeral_dec_key.clone()); for cur_recovery_output in &custodian_recovery_outputs { if cur_recovery_output.custodian_role == 0 @@ -943,50 +942,18 @@ async fn filter_custodian_data( continue; } }; - let internal = InternalCustodianRecoveryOutput { + internal_outputs.push(InternalCustodianRecoveryOutput { signcryption: cur_signcryption, custodian_role: role, - }; - - match operator.validate_one_recovery_output( - &internal, - recovery_material, - &ephemeral_dec_key, - ephemeral_enc_key, - ) { - Ok(backup_material) => match parsed_custodian_rec.entry(role) { - std::collections::hash_map::Entry::Occupied(_) => { - tracing::warn!( - "Received multiple recovery outputs for custodian role {role}. Only the first one will be used." - ); - skip_reasons.push(RecoverySkipReason::DuplicateRole); - } - std::collections::hash_map::Entry::Vacant(vacant_entry) => { - vacant_entry.insert(backup_material); - } - }, - Err(reason) => skip_reasons.push(reason), - } - } - let threshold = recovery_material.custodian_context().threshold as usize; - let required_min = threshold + 1; - if parsed_custodian_rec.len() < required_min { - let received = parsed_custodian_rec.len(); - tracing::error!( - received, - threshold, - ?skip_reasons, - "Cannot recover the backup decryption key: not enough valid recovery outputs" - ); - return Err(BackupError::RecoveryThresholdNotMet { - required_min, - received, - threshold, - skipped: skip_reasons, - } - .into()); + }); } - Ok(parsed_custodian_rec) + Ok(operator.validate_outputs( + &internal_outputs, + skip_reasons, + recovery_material, + ephemeral_dec_key, + ephemeral_enc_key, + )?) } /// Why no custodian context could be selected for recovery. From 561a92d996d7d29bafc9802549c21c21eb28395e Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 16:26:42 +0200 Subject: [PATCH 112/114] chore: bring back VerfKeySet::id --- .../src/cryptography/signing/verf_key_set.rs | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/core/service/src/cryptography/signing/verf_key_set.rs b/core/service/src/cryptography/signing/verf_key_set.rs index 3a0240963b..20f48c2433 100644 --- a/core/service/src/cryptography/signing/verf_key_set.rs +++ b/core/service/src/cryptography/signing/verf_key_set.rs @@ -2,12 +2,18 @@ use super::ecdsa::PublicSigKey; use super::identity::NodeSigningIdentity; -use super::{HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey}; +use super::{ + HasSigningScheme, SigningError, SigningSchemeType, UnifiedPublicSigKey, canonical_schemes, +}; +use hashing::{DomainSep, hash_element}; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; use tfhe::named::Named; use tfhe_versionable::{Versionize, VersionsDispatch}; +/// Domain separator for the digest that identifies the keys a signature is made with. +const DSEP_VERF_KEY_SET: DomainSep = *b"VKEYSET_"; + /// One party's verification keys, keyed by the scheme each belongs to. /// /// The counterpart of [`NodeSigningIdentity`] on the verifying side: an identity @@ -175,6 +181,30 @@ impl VerfKeySet { self.get(scheme) .ok_or(SigningError::NoVerificationKey(scheme)) } + + /// The identifier of the keys a signature under `schemes` is made with. + pub fn id(&self, schemes: &[SigningSchemeType]) -> Result, SigningError> { + let schemes = canonical_schemes(schemes)?; + Ok(hash_element( + &DSEP_VERF_KEY_SET, + &self.canonical_bytes(&schemes)?, + )) + } + + /// The unambiguous byte encoding of the keys for the canonical `schemes`, + /// for use inside a digest. + fn canonical_bytes(&self, schemes: &[SigningSchemeType]) -> Result, SigningError> { + let mut out = Vec::new(); + // Bounded by the number of known schemes, so the cast cannot truncate. + out.extend_from_slice(&(schemes.len() as u32).to_le_bytes()); + for &scheme in schemes { + out.extend_from_slice(&scheme.tag()); + let bytes = self.require(scheme)?.digest(); + out.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); + out.extend_from_slice(&bytes); + } + Ok(out) + } } #[cfg(test)] @@ -292,4 +322,35 @@ mod tests { )); } } + + /// The id names the selected keys: swapping a selected key, or changing the + /// selection, changes it, while a key outside the selection does not. + #[test] + fn id_names_exactly_the_selected_keys() { + let mut rng = AesRng::seed_from_u64(4); + let identity = seeded_identity(&mut rng); + let other_identity = seeded_identity(&mut rng); + let every_scheme: Vec<_> = SigningSchemeType::iter().collect(); + let selected = [SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa87]; + + let base = VerfKeySet::from_identity(&identity, &every_scheme).unwrap(); + let base_id = base.id(&selected).unwrap(); + + for &scheme in &every_scheme { + let mut swapped = base.keys.clone(); + swapped.insert( + scheme, + other_identity.unified_verifying_key(scheme).unwrap(), + ); + let swapped_id = VerfKeySet::new(swapped).unwrap().id(&selected).unwrap(); + assert_eq!( + base_id == swapped_id, + !selected.contains(&scheme), + "swapping the {scheme} key had the wrong effect on the id" + ); + } + + assert_ne!(base_id, base.id(&[SigningSchemeType::Ecdsa256k1]).unwrap()); + assert_ne!(base_id, base.id(&every_scheme).unwrap()); + } } From 5b45b88f93de068531e8e6b6afb86cd7632afbc5 Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 16:34:58 +0200 Subject: [PATCH 113/114] chore: recover missing id computation --- core/service/src/backup/custodian.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/service/src/backup/custodian.rs b/core/service/src/backup/custodian.rs index 363ba957bb..ee53c65de2 100644 --- a/core/service/src/backup/custodian.rs +++ b/core/service/src/backup/custodian.rs @@ -397,6 +397,11 @@ impl Custodian { operator_verification_key: &VerfKeySet, operator_ephem_enc_key: &UnifiedPublicEncKey, ) -> Result { + let operator_id = operator_verification_key + .id(BACKUP_SIGNING_SCHEMES) + .map_err(|e| { + BackupError::SetupError(format!("could not compute the operator key set id: {e}")) + })?; for fingerprint in operator_verification_key.all_fingerprints() { tracing::info!("Verifying and re-encrypting backup for operator {fingerprint}"); } From f749747e11c6ec7d18c1fac76e13028e928a8cee Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Tue, 6 Oct 2026 16:35:31 +0200 Subject: [PATCH 114/114] test: remove unneeded test code --- core/service/src/backup/tests.rs | 19 ------------------- core/service/src/client/client_non_wasm.rs | 17 ----------------- .../client/tests/threshold/mpc_epoch_tests.rs | 4 ---- .../src/engine/centralized/central_kms.rs | 7 ++----- 4 files changed, 2 insertions(+), 45 deletions(-) diff --git a/core/service/src/backup/tests.rs b/core/service/src/backup/tests.rs index bb25aee0a3..29f732bc3b 100644 --- a/core/service/src/backup/tests.rs +++ b/core/service/src/backup/tests.rs @@ -181,25 +181,6 @@ fn custodian_reencrypt() { assert!(matches!(err, BackupError::CustodianRecoveryError)); } - // tweak the signature, so that signature verification also fails - { - let operator_role = Role::indexed_from_zero(0); - let mut bad_results = signcrypt_results.clone(); - if let Some(z) = bad_results[0].ct_shares.get_mut(&operator_role) { - z.signcryption.payload[0] ^= 1; - } - - let err = custodians[0] - .verify_reencrypt( - &mut rng, - bad_results[0].ct_shares.get(&operator_role).unwrap(), - verification_key, - &ephemeral_enc_key, - ) - .unwrap_err(); - assert!(matches!(err, BackupError::CustodianRecoveryError)); - } - // no tweaks, all should pass { let operator_role = Role::indexed_from_zero(0); diff --git a/core/service/src/client/client_non_wasm.rs b/core/service/src/client/client_non_wasm.rs index 7457523eab..147320fb0e 100644 --- a/core/service/src/client/client_non_wasm.rs +++ b/core/service/src/client/client_non_wasm.rs @@ -432,23 +432,6 @@ mod tests { ); } - /// Entries have to agree on one signing party. - #[test] - fn mixed_party_entries_are_rejected() { - let identity = seeded_identity(8); - let other = seeded_identity(9); - let requested = [SigningSchemeType::Ecdsa256k1, SigningSchemeType::MlDsa65]; - let client = client_requesting(&identity, true, &requested); - - // Both lists are signed under the whole requested set, so what is left - // for the verifier to object to is the two parties - let mine = signatures_for(&identity, &requested, &payload()); - let theirs = signatures_for(&other, &requested, &payload()); - let signatures = vec![mine[0].clone(), theirs[1].clone()]; - - assert!(verify(&client, &signatures, &payload()).is_err()); - } - /// Every requested scheme must have an entry in the list, even when the other /// entries and the legacy signature verify. #[test] diff --git a/core/service/src/client/tests/threshold/mpc_epoch_tests.rs b/core/service/src/client/tests/threshold/mpc_epoch_tests.rs index 11c9881c36..05da45b7d5 100644 --- a/core/service/src/client/tests/threshold/mpc_epoch_tests.rs +++ b/core/service/src/client/tests/threshold/mpc_epoch_tests.rs @@ -421,10 +421,6 @@ async fn run_new_epoch( resharing.as_ref().map(|r| &r.signing_domain), ) .unwrap(); - assert_eq!( - reshare_request.signing_schemes, requested_schemes, - "the request must carry the schemes the client asks for" - ); let extra_data = reshare_request.extra_data.clone(); // Execute reshare diff --git a/core/service/src/engine/centralized/central_kms.rs b/core/service/src/engine/centralized/central_kms.rs index f57428e81d..761f40baf2 100644 --- a/core/service/src/engine/centralized/central_kms.rs +++ b/core/service/src/engine/centralized/central_kms.rs @@ -887,11 +887,8 @@ impl< client_enc_key: &UnifiedPublicEncKey, client_id: &[u8], ) -> anyhow::Result> { - let signcryption_key = UnifiedSigncryptionKey::new( - identity, - client_enc_key.clone(), - client_id.to_vec(), - ); + let signcryption_key = + UnifiedSigncryptionKey::new(identity, client_enc_key.clone(), client_id.to_vec()); // Observe that we encrypt the plaintext itself, this is different from the threshold case // where it is first mapped to a Vec element // Keep the cleartext behind a zeroizing guard during signcryption.