Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
305606d
feat: initial scafolding to support multiple signature schemes in sig…
jot2re Sep 22, 2026
d81115e
fix: prevent custodian downgrade attacks in backup
jot2re Sep 22, 2026
c04a0b4
refactor: split into different signcryption modules
jot2re Sep 22, 2026
dc91fe7
fix: versioning issue
jot2re Sep 22, 2026
c9a7233
fix: more fixes
jot2re Sep 22, 2026
85bdd6b
fix: imports
jot2re Sep 22, 2026
8cac7f5
fix: lint
jot2re Sep 22, 2026
d57b28d
fix: merge conflict
jot2re Sep 22, 2026
64063f5
fix: bad merge
jot2re Sep 22, 2026
2f51473
fix: backward tests
jot2re Sep 22, 2026
b176712
refactor: simpified constructors
jot2re Sep 22, 2026
e19a6a1
refactor: removing redundant code
jot2re Sep 22, 2026
fa268e9
fix: lint
jot2re Sep 22, 2026
b548392
refactor: simplified structure
jot2re Sep 23, 2026
9b5402e
chore: tightening
jot2re Sep 23, 2026
0e83399
refactor: removed unneeded code
jot2re Sep 23, 2026
a3a21dc
refactor: incremental refactor commit
jot2re Sep 23, 2026
23c06ba
refactor: more pruning
jot2re Sep 23, 2026
6477d22
chore: more simplification
jot2re Sep 23, 2026
90ae047
fix: missing diff
jot2re Sep 23, 2026
a30a666
fix: lint
jot2re Sep 23, 2026
25fcb24
fix: lint
jot2re Sep 23, 2026
7935c01
chore: incremental versionize
jot2re Sep 23, 2026
13726ff
fix: compile issues
jot2re Sep 23, 2026
445bdc8
fix: handled preliminary claude review
jot2re Sep 23, 2026
b28290b
fix: unneeded restriction
jot2re Sep 23, 2026
4185577
fix: backwards comp tests
jot2re Sep 23, 2026
223fb98
fix: lint
jot2re Sep 23, 2026
ce4c545
fix: minor issues
jot2re Sep 23, 2026
1719d8a
chore: handled some more claude review findings
jot2re Sep 23, 2026
a7cd04e
refactor: limit format tests
jot2re Sep 23, 2026
3359709
chore: remove weak key usage
jot2re Sep 24, 2026
075f872
chore: updated backward
jot2re Sep 24, 2026
a9cd27b
refactor: pruning
jot2re Sep 24, 2026
b609064
chore: pruning
jot2re Sep 24, 2026
0fd135a
chore: changed composite to use safe serialization
jot2re Sep 24, 2026
9a8749f
refactor: pruning
jot2re Sep 24, 2026
63e917f
fix: handled copilot comment about client sign verification
jot2re Sep 24, 2026
7ecab3d
fix: other test issue from copilot
jot2re Sep 24, 2026
388766f
fix: add zeroize on payloads
jot2re Sep 24, 2026
6fc66f0
test: removed unneeded tests
jot2re Sep 24, 2026
d104d62
fix: allow empty meta data in case of rolling upgrade issue
jot2re Sep 24, 2026
534c1e6
chore: prune
jot2re Sep 24, 2026
970ddda
fix: malicious payload subtraction
jot2re Sep 24, 2026
df1f7e9
fix: lint
jot2re Sep 24, 2026
bce6ee2
refactor: reverted refactor
jot2re Sep 24, 2026
d735005
chore: updated backwards
jot2re Sep 24, 2026
9e9a02c
fix: added versioning
jot2re Sep 24, 2026
04662ba
Merge branch 'main' into tore/feat/support-multiple-signcryption
jot2re Sep 24, 2026
c4ab2b9
fix: wasm visibility
jot2re Sep 24, 2026
c111547
chore: remove compositeSignature
jot2re Sep 24, 2026
dddf827
feat: versioning of VerfKeySet
jot2re Sep 24, 2026
2ff0f2d
chore: ietf comp incremental
jot2re Sep 25, 2026
ec3dad3
refactor: serialization of signcryption payload
jot2re Sep 25, 2026
54138ea
refactor: ensured compatibility with ietf
jot2re Sep 25, 2026
512aa94
fix: imports
jot2re Sep 25, 2026
2b959ed
refactor: simplified verfkeyset serialization
jot2re Sep 25, 2026
e1f79f9
chore: consistent naming
jot2re Sep 25, 2026
d4bdd4f
refactor: use unsigncrypt key
jot2re Sep 25, 2026
7d04efc
refactor: upgrade to unified signcryption format
jot2re Sep 25, 2026
315b5c9
test: improved composite
jot2re Sep 25, 2026
c1f87dd
chore: removed versioning from unified signcryption keys since they a…
jot2re Sep 25, 2026
100be1a
chore: missing update
jot2re Sep 25, 2026
41fbd67
chore: streamlining of unified singccryption keys
jot2re Sep 25, 2026
1f15ee8
refactor: move signcryption
jot2re Sep 25, 2026
f573edf
fix: lint
jot2re Sep 25, 2026
635c1b8
test: refactor
jot2re Sep 25, 2026
17c5877
fix: compile issues
jot2re Sep 25, 2026
782ba5c
refactor: moved hybryid en/decrypt
jot2re Sep 25, 2026
7984c2f
refactor: further simpification
jot2re Sep 25, 2026
4a657e1
test: consolidation
jot2re Sep 25, 2026
473f246
fix: fmt
jot2re Sep 25, 2026
9774e1c
fix: renaming of verify_uniform
jot2re Sep 25, 2026
57c2f8f
docs: removed incorrect doc
jot2re Sep 25, 2026
1aaa2c9
Merge branch 'main' into tore/feat/support-multiple-signcryption
jot2re Sep 25, 2026
b9d699d
chore: minor typos
jot2re Sep 25, 2026
73f8244
chore: pruning
jot2re Sep 25, 2026
7b83db7
refactor: renaming
jot2re Sep 25, 2026
efb0760
fix: backward tests
jot2re Sep 25, 2026
f8e764b
fix: backward take 2
jot2re Sep 25, 2026
184f980
chore: fixed an unclarity
jot2re Sep 25, 2026
e255c65
fix: dylint
jot2re Sep 26, 2026
1c0f641
test: fix
jot2re Sep 26, 2026
0a63eca
fix: review VerfKeySet
jot2re Sep 27, 2026
d8c67a0
Merge branch 'main' into tore/feat/support-multiple-signcryption
jot2re Sep 28, 2026
a0c8b05
docs: updated docs according to Daniel's review
jot2re Sep 29, 2026
dfc34a2
chore: accept signed super sets per review request
jot2re Sep 29, 2026
b8538d6
fix: redundant test
jot2re Sep 29, 2026
d9c6786
test: fix
jot2re Sep 30, 2026
acf5743
fix: compiler
jot2re Sep 30, 2026
d0f3b54
chore: simplification of logic
jot2re Sep 30, 2026
65bf23a
test: removed unneeded tests
jot2re Sep 30, 2026
76f405b
fix: linter
jot2re Oct 1, 2026
acbc15a
refactor: enc_key_digest assignment for clarity
jot2re Oct 1, 2026
715542c
chore: Use Zeroizing for CompositeSigncryptionPayload
jot2re Oct 1, 2026
ccf613d
fix: lint
jot2re Oct 1, 2026
1390749
test: added forward compatibility signing scheme test
jot2re Oct 1, 2026
1316bff
fix: lint
jot2re Oct 1, 2026
9e73b8b
Merge branch 'tore/feat/support-multiple-signcryption' into tore/chor…
jot2re Oct 1, 2026
902f33f
chore: remove unneeded test
jot2re Oct 1, 2026
0456bcc
chore: more obsolete code removal
jot2re Oct 1, 2026
bb2fde2
test: consolidation
jot2re Oct 1, 2026
623cdec
test: fix new mpc epocg request test
jot2re Oct 1, 2026
ace7545
fix: merge conflict
jot2re Oct 2, 2026
780e23e
fix: merge conflict
jot2re Oct 6, 2026
57c9ba5
fix: lint
jot2re Oct 6, 2026
4a0dd1f
fix: merge issue
jot2re Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 13 additions & 20 deletions core-client/src/decrypt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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.
Expand All @@ -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,
Expand Down
183 changes: 45 additions & 138 deletions core/service/src/client/client_non_wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -274,7 +274,7 @@ mod tests {
payload: &CrsSignedPayload,
) -> Vec<TypedSignature> {
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)
Expand Down Expand Up @@ -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.
Expand All @@ -371,81 +352,28 @@ mod tests {
);
}

/// The fallback is a real signature check, not a waiver for an empty list.
#[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 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.
/// 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 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());

// 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()
);
}

/// 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"
);
}
Expand Down Expand Up @@ -478,44 +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());
}

/// 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]
Expand All @@ -536,8 +426,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}"
);
}
Expand Down Expand Up @@ -608,7 +498,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);
Expand All @@ -626,20 +518,19 @@ mod tests {
),
("wrong message", wrong_message),
];
let list = |signature: Vec<u8>| {
vec![TypedSignature {
scheme: SigningSchemeType::Ecdsa256k1.as_wire(),
signature,
}]
};

for legacy in [false, true] {
let check = |signature: Vec<u8>| {
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!(
Expand All @@ -650,5 +541,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"
);
}
}
}
40 changes: 7 additions & 33 deletions core/service/src/client/public_decryption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
///
Expand All @@ -76,30 +66,14 @@ impl Client {
/// (signatures, digest matching, majority agreement) before use.
pub fn process_decryption_resp(
&self,
request: Option<PublicDecryptionRequest>,
request: &PublicDecryptionRequest,
min_agree_count: u32,
agg_resp: &[PublicDecryptionResponse],
) -> anyhow::Result<Vec<TypedPlaintext>> {
let eip712_domain = match &request {
Some(req) => Some(optional_protobuf_to_alloy_domain(req.domain.as_ref())?),
None => None,
};
let ext_handles_bytes: Vec<Vec<u8>> = 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 trusted_ctx = PublicDecTrustedValidationContext::new(
self.get_server_pks()?,
&self.scheme_verf_keys,
eip712_domain.as_ref(),
&ext_handles_bytes,
extra_data,
request.as_ref(),
request,
)?;

// Partition the untrusted responses and enforce the majority threshold. Partitioning is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading