From c44a00ac7e593eaea05767c1f0a3c5edb9a054e6 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 13:17:08 +0000 Subject: [PATCH 1/6] feat(crypto): support Ed25519/EdDSA in COSE parser for WebAuthn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The COSE parser only accepted ECDSA-P256 (kty=EC2, alg=ES256) and RSA-PKCS1-SHA256 (kty=RSA, alg=RS256). WebAuthn authenticators that produce kty=OKP, alg=EdDSA, crv=Ed25519 keys (e.g. NitroKey 3A) were rejected with the misleading error "Algorithm Unspecified not supported" - the wrapper hardcodes AlgorithmId::Unspecified for any unsupported algorithm, so the actual Ed25519 alg never surfaces. Add an Ed25519/EdDSA branch to CosePublicKey::from_cbor and a parse_eddsa_ed25519 helper that produces an RFC 8410 SPKI DER. Wire Ed25519PublicKeyDerWrappedCose through the standalone sig verifier and the ingress validator's WebAuthn signature path. Per WebAuthn §6.5.6, EdDSA WebAuthn signatures are 64 raw bytes (no DER wrapping), which basic_sig_from_webauthn_sig now passes through unchanged. Tests cover: parsing the canonical RFC 8032 TEST 1 keypair as a COSE key, end-to-end signature verification through the parsed DER, parsing a captured NitroKey 3A WebAuthn registration, and rejection of Ed448 (crv=7) and short-x malformed keys. The validator's webauthn module gets matching Ed25519 fixtures. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 1 + .../crypto_lib/basic_sig/cose/BUILD.bazel | 1 + .../crypto_lib/basic_sig/cose/Cargo.toml | 1 + .../crypto_lib/basic_sig/cose/src/lib.rs | 52 +++++++++ .../crypto_lib/basic_sig/cose/tests/tests.rs | 81 ++++++++++++++ .../standalone-sig-verifier/src/sign_utils.rs | 2 + rs/validator/src/ingress_validation.rs | 2 + rs/validator/src/webauthn.rs | 102 +++++++++++++++++- 8 files changed, 237 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a9d60613361a..c8cff015bf64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8343,6 +8343,7 @@ dependencies = [ "hex", "ic-crypto-internal-basic-sig-rsa-pkcs1", "ic-crypto-internal-test-vectors", + "ic-ed25519 0.6.0", "ic-secp256r1", "ic-types", "serde", diff --git a/rs/crypto/internal/crypto_lib/basic_sig/cose/BUILD.bazel b/rs/crypto/internal/crypto_lib/basic_sig/cose/BUILD.bazel index b8a823eb9846..317e9dffae14 100644 --- a/rs/crypto/internal/crypto_lib/basic_sig/cose/BUILD.bazel +++ b/rs/crypto/internal/crypto_lib/basic_sig/cose/BUILD.bazel @@ -4,6 +4,7 @@ package(default_visibility = ["//rs/crypto:__subpackages__"]) DEPENDENCIES = [ # Keep sorted. + "//packages/ic-ed25519", "//packages/ic-secp256r1", "//rs/crypto/internal/crypto_lib/basic_sig/rsa_pkcs1", "//rs/types/types", diff --git a/rs/crypto/internal/crypto_lib/basic_sig/cose/Cargo.toml b/rs/crypto/internal/crypto_lib/basic_sig/cose/Cargo.toml index b331279163a8..cce672362bd1 100644 --- a/rs/crypto/internal/crypto_lib/basic_sig/cose/Cargo.toml +++ b/rs/crypto/internal/crypto_lib/basic_sig/cose/Cargo.toml @@ -7,6 +7,7 @@ description.workspace = true documentation.workspace = true [dependencies] +ic-ed25519 = { path = "../../../../../../packages/ic-ed25519" } ic-secp256r1 = { path = "../../../../../../packages/ic-secp256r1" } ic-crypto-internal-basic-sig-rsa-pkcs1 = { path = "../rsa_pkcs1" } ic-types = { path = "../../../../../types/types" } diff --git a/rs/crypto/internal/crypto_lib/basic_sig/cose/src/lib.rs b/rs/crypto/internal/crypto_lib/basic_sig/cose/src/lib.rs index 2e2625b8402d..418d1076009d 100644 --- a/rs/crypto/internal/crypto_lib/basic_sig/cose/src/lib.rs +++ b/rs/crypto/internal/crypto_lib/basic_sig/cose/src/lib.rs @@ -11,6 +11,7 @@ type CborMap = std::collections::BTreeMap; enum CosePublicKey { EcdsaP256Sha256(Vec), RsaPkcs1v15Sha256(Vec), + Ed25519(Vec), } // see https://tools.ietf.org/html/rfc8152 section 8.1 @@ -37,6 +38,15 @@ const COSE_KTY_RSA: serde_cbor::Value = serde_cbor::Value::Integer(3); const COSE_PARAM_RSA_N: serde_cbor::Value = serde_cbor::Value::Integer(-1); const COSE_PARAM_RSA_E: serde_cbor::Value = serde_cbor::Value::Integer(-2); +// see https://datatracker.ietf.org/doc/html/rfc8152#section-13.2 +const COSE_KTY_OKP: serde_cbor::Value = serde_cbor::Value::Integer(1); +const COSE_PARAM_OKP_CRV: serde_cbor::Value = serde_cbor::Value::Integer(-1); +const COSE_PARAM_OKP_X: serde_cbor::Value = serde_cbor::Value::Integer(-2); + +// see https://datatracker.ietf.org/doc/html/rfc8152#section-8.2 +const COSE_ALG_EDDSA: serde_cbor::Value = serde_cbor::Value::Integer(-8); +const COSE_OKP_CRV_ED25519: serde_cbor::Value = serde_cbor::Value::Integer(6); + #[derive(Copy, Clone, Eq, PartialEq, Debug)] /// An error that occurred while parsing the COSE key enum CosePublicKeyParseError { @@ -78,6 +88,8 @@ impl CosePublicKey { Self::parse_ecdsa_p256(&fields) } else if *kty == COSE_KTY_RSA && *alg == COSE_ALG_RS256 { Self::parse_rsa_pkcs1_sha256(&fields) + } else if *kty == COSE_KTY_OKP && *alg == COSE_ALG_EDDSA { + Self::parse_eddsa_ed25519(&fields) } else { // Some other algorithm Err(CosePublicKeyParseError::AlgorithmNotSupported) @@ -150,6 +162,44 @@ impl CosePublicKey { } } + /// Parse a COSE EdDSA / Ed25519 key + fn parse_eddsa_ed25519(fields: &CborMap) -> Result { + Self::verify_key_ops(fields)?; + + let crv = + fields + .get(&COSE_PARAM_OKP_CRV) + .ok_or(CosePublicKeyParseError::MalformedPublicKey( + AlgorithmId::Ed25519, + ))?; + + if *crv != COSE_OKP_CRV_ED25519 { + // EdDSA over a curve we don't support (e.g. Ed448) + return Err(CosePublicKeyParseError::AlgorithmNotSupported); + } + + let x = + fields + .get(&COSE_PARAM_OKP_X) + .ok_or(CosePublicKeyParseError::MalformedPublicKey( + AlgorithmId::Ed25519, + ))?; + + match x { + serde_cbor::Value::Bytes(x) => { + let pk = ic_ed25519::PublicKey::deserialize_raw(x).map_err(|_| { + CosePublicKeyParseError::MalformedPublicKey(AlgorithmId::Ed25519) + })?; + + let der = pk.serialize_rfc8410_der(); + Ok(Self::Ed25519(der)) + } + _ => Err(CosePublicKeyParseError::MalformedPublicKey( + AlgorithmId::Ed25519, + )), + } + } + fn parse_rsa_pkcs1_sha256(fields: &CborMap) -> Result { Self::verify_key_ops(fields)?; @@ -185,6 +235,7 @@ impl CosePublicKey { match self { Self::EcdsaP256Sha256(_) => AlgorithmId::EcdsaP256, Self::RsaPkcs1v15Sha256(_) => AlgorithmId::RsaSha256, + Self::Ed25519(_) => AlgorithmId::Ed25519, } } @@ -193,6 +244,7 @@ impl CosePublicKey { match self { Self::EcdsaP256Sha256(der) => der.to_vec(), Self::RsaPkcs1v15Sha256(der) => der.to_vec(), + Self::Ed25519(der) => der.to_vec(), } } } diff --git a/rs/crypto/internal/crypto_lib/basic_sig/cose/tests/tests.rs b/rs/crypto/internal/crypto_lib/basic_sig/cose/tests/tests.rs index c164d5179fcc..82939514e29c 100644 --- a/rs/crypto/internal/crypto_lib/basic_sig/cose/tests/tests.rs +++ b/rs/crypto/internal/crypto_lib/basic_sig/cose/tests/tests.rs @@ -144,3 +144,84 @@ fn get_der_cose_verification_result(sig_der_hex: &str, pk_cose_hex: &str, msg_he false } } + +// Constructed COSE-encoded EdDSA / Ed25519 public key for the RFC 8032 TEST 1 +// keypair (sk = 9d61...7f60, pk = d75a...511a). The CBOR map is: +// { 1: 1 (kty=OKP), 3: -8 (alg=EdDSA), -1: 6 (crv=Ed25519), -2: } +const ED25519_PK_COSE_HEX: &str = + "a4010103272006215820d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + +// The RFC 8410 SPKI DER encoding of the same Ed25519 public key. +const ED25519_PK_RFC8410_DER_HEX: &str = + "302a300506032b6570032100d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + +// Signature, with the RFC 8032 TEST 1 secret key, over a synthetic WebAuthn +// signed-bytes payload (authenticator_data || sha256(client_data_json)). +const ED25519_SIG_HEX: &str = "c75d28322702e0b762a5a6e2b2ebbe35575effa6df89132826ffa9e7ff7db3ab98017f1f162acfcffaca2e10fbec029b9578088607912200627742e82bd78b0d"; +const ED25519_MSG_HEX: &str = "88e87051ccf36e3be572ef2b097e80abcb1cbc8332a6af5f9512661f3fece38e05000000005f335d5a02031b45335306e396d3ceb1a9a1802805367bc77216b80d4082ae28"; + +#[test] +fn should_correctly_parse_cose_encoded_ed25519_pk() { + let pk_cose = hex::decode(ED25519_PK_COSE_HEX).unwrap(); + let (alg_id, pk_der) = parse_cose_public_key(&pk_cose).unwrap(); + assert_eq!(alg_id, AlgorithmId::Ed25519); + assert_eq!(hex::encode(pk_der), ED25519_PK_RFC8410_DER_HEX); +} + +#[test] +fn should_correctly_verify_signature_for_ed25519_cose_pk() { + let pk_cose = hex::decode(ED25519_PK_COSE_HEX).unwrap(); + let (_alg_id, pk_der) = parse_cose_public_key(&pk_cose).unwrap(); + + let pk = ic_ed25519::PublicKey::deserialize_rfc8410_der(&pk_der) + .expect("Ed25519 public key parses from RFC 8410 DER"); + + let sig = hex::decode(ED25519_SIG_HEX).unwrap(); + let msg = hex::decode(ED25519_MSG_HEX).unwrap(); + pk.verify_signature(&msg, &sig) + .expect("signature verifies against parsed COSE public key"); +} + +#[test] +fn should_parse_nitrokey_3a_cose_encoded_ed25519_pk() { + // COSE key captured from a NitroKey 3A WebAuthn registration (II self-service test). + // Verifies that authenticators producing kty=OKP/alg=EdDSA/crv=Ed25519 are accepted. + let pk_cose = hex::decode( + "a40101032720062158205ae0e5d3bc439630f2a07660dc6d29c0129e545bc24380970722fcbf5c8f8b30", + ) + .unwrap(); + let (alg_id, _pk_der) = parse_cose_public_key(&pk_cose).unwrap(); + assert_eq!(alg_id, AlgorithmId::Ed25519); +} + +#[test] +fn should_reject_cose_encoded_eddsa_with_unsupported_curve() { + // Same as a valid Ed25519 key but crv = 7 (Ed448), which is not supported. + let pk_cose = hex::decode( + "a40101032720072158205ae0e5d3bc439630f2a07660dc6d29c0129e545bc24380970722fcbf5c8f8b30", + ) + .unwrap(); + let result = parse_cose_public_key(&pk_cose); + assert_eq!( + result, + Err(CryptoError::AlgorithmNotSupported { + algorithm: AlgorithmId::Unspecified, + reason: "Algorithm not supported in COSE parser".to_string(), + }) + ); +} + +#[test] +fn should_reject_cose_encoded_ed25519_with_short_x() { + // x is only 16 bytes instead of the required 32. + // CBOR: a4 01 01 03 27 20 06 21 50 <16 bytes> + let pk_cose = hex::decode("a4010103272006215000112233445566778899aabbccddeeff").unwrap(); + let result = parse_cose_public_key(&pk_cose); + assert!(matches!( + result, + Err(CryptoError::MalformedPublicKey { + algorithm: AlgorithmId::Ed25519, + .. + }) + )); +} diff --git a/rs/crypto/standalone-sig-verifier/src/sign_utils.rs b/rs/crypto/standalone-sig-verifier/src/sign_utils.rs index 7a86d07cc56f..0127b2a77d5a 100644 --- a/rs/crypto/standalone-sig-verifier/src/sign_utils.rs +++ b/rs/crypto/standalone-sig-verifier/src/sign_utils.rs @@ -18,6 +18,7 @@ pub enum KeyBytesContentType { EcdsaSecp256k1PublicKeyDer, RsaSha256PublicKeyDer, EcdsaP256PublicKeyDerWrappedCose, + Ed25519PublicKeyDerWrappedCose, RsaSha256PublicKeyDerWrappedCose, IcCanisterSignatureAlgPublicKeyDer, } @@ -25,6 +26,7 @@ pub enum KeyBytesContentType { fn cose_key_bytes_content_type(alg_id: AlgorithmId) -> Option { match alg_id { AlgorithmId::EcdsaP256 => Some(KeyBytesContentType::EcdsaP256PublicKeyDerWrappedCose), + AlgorithmId::Ed25519 => Some(KeyBytesContentType::Ed25519PublicKeyDerWrappedCose), AlgorithmId::RsaSha256 => Some(KeyBytesContentType::RsaSha256PublicKeyDerWrappedCose), _ => None, } diff --git a/rs/validator/src/ingress_validation.rs b/rs/validator/src/ingress_validation.rs index dbcf25283bac..337c1672be1b 100644 --- a/rs/validator/src/ingress_validation.rs +++ b/rs/validator/src/ingress_validation.rs @@ -658,6 +658,7 @@ where match pk_type { KeyBytesContentType::EcdsaP256PublicKeyDerWrappedCose + | KeyBytesContentType::Ed25519PublicKeyDerWrappedCose | KeyBytesContentType::RsaSha256PublicKeyDerWrappedCose => { let webauthn_sig = WebAuthnSignature::try_from(signature.signature.as_slice()) .map_err(WebAuthnError) @@ -800,6 +801,7 @@ where match pk_type { KeyBytesContentType::EcdsaP256PublicKeyDerWrappedCose + | KeyBytesContentType::Ed25519PublicKeyDerWrappedCose | KeyBytesContentType::RsaSha256PublicKeyDerWrappedCose => { let webauthn_sig = WebAuthnSignature::try_from(signature).map_err(WebAuthnError)?; validate_webauthn_sig(validator, &webauthn_sig, delegation, &pk) diff --git a/rs/validator/src/webauthn.rs b/rs/validator/src/webauthn.rs index 37ae35da8d38..ef36212933d9 100644 --- a/rs/validator/src/webauthn.rs +++ b/rs/validator/src/webauthn.rs @@ -57,12 +57,17 @@ fn basic_sig_from_webauthn_sig( ecdsa_p256_signature_from_der_bytes(&webauthn_sig.signature().0) .map_err(|e| format!("Failed to parse EcdsaP256 signature: {e}")) } + AlgorithmId::Ed25519 => { + // EdDSA signatures are 64 raw bytes (not DER wrapped). + // See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types + Ok(BasicSig(webauthn_sig.signature().0.clone())) + } AlgorithmId::RsaSha256 => { // RSA signatures are not DER wrapped, see https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types Ok(rsa_signature_from_bytes(&webauthn_sig.signature())) } _ => Err(format!( - "Only ECDSA on curve P-256 and RSA PKCS #1 v1.5 are supported for WebAuthn, given: {algorithm_id:?}" + "Only ECDSA on curve P-256, EdDSA on curve Ed25519, and RSA PKCS #1 v1.5 are supported for WebAuthn, given: {algorithm_id:?}" )), } } @@ -284,6 +289,93 @@ mod tests { } } + mod ed25519 { + use super::*; + + /// An Ed25519 public key in COSE format, DER wrapped, derived from the + /// RFC 8032 TEST 1 keypair. The COSE map is + /// `{1: 1 (kty=OKP), 3: -8 (alg=EdDSA), -1: 6 (crv=Ed25519), -2: }`, + /// wrapped with the IC's COSE OID 1.3.6.1.4.1.56387.1.1. + pub const ED25519_PK_COSE_DER_WRAPPED_HEX: &str = "303b300c060a2b0601040183b8430101032b00a4010103272006215820d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + + /// A WebAuthn signature CBOR envelope (with the self-describing tag + /// `d9d9f7`) signing a 32-byte challenge with the RFC 8032 TEST 1 + /// secret key. The challenge in `clientDataJSON` (base64url-encoded) + /// matches the bytes + /// `9bc7b89a00c2aa9105a648bf57d85b5b3c669fd1e4b9ebafcdf525b35ea5a645`. + pub const ED25519_WEBAUTHN_SIG_HEX: &str = "d9d9f7a3697369676e61747572655840c75d28322702e0b762a5a6e2b2ebbe35575effa6df89132826ffa9e7ff7db3ab98017f1f162acfcffaca2e10fbec029b9578088607912200627742e82bd78b0d70636c69656e745f646174615f6a736f6e58757b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a226d3865346d67444371704546706b695f56396862577a786d6e39486b756575767a66556c7331366c706b55222c226f726967696e223a2268747470733a2f2f6964656e746974792e6963302e617070227d7261757468656e74696361746f725f64617461582588e87051ccf36e3be572ef2b097e80abcb1cbc8332a6af5f9512661f3fece38e0500000000"; + + const CHALLENGE_HEX: &str = + "9bc7b89a00c2aa9105a648bf57d85b5b3c669fd1e4b9ebafcdf525b35ea5a645"; + + #[test] + fn should_verify_valid_ed25519_webauthn_signature() { + let verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); + let (pk, sig) = load_pk_and_sig( + ED25519_PK_COSE_DER_WRAPPED_HEX.as_ref(), + ED25519_WEBAUTHN_SIG_HEX.as_bytes(), + ); + assert_eq!(pk.algorithm_id, AlgorithmId::Ed25519); + + let challenge = hex::decode(CHALLENGE_HEX).unwrap(); + let message = SignableMock { + domain: vec![], + signed_bytes_without_domain: challenge, + }; + + assert_eq!( + validate_webauthn_sig(&verifier, &sig, &message, &pk), + Ok(()) + ); + } + + #[test] + fn should_return_error_on_valid_ed25519_signature_but_wrong_message() { + let verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); + let (pk, sig) = load_pk_and_sig( + ED25519_PK_COSE_DER_WRAPPED_HEX.as_ref(), + ED25519_WEBAUTHN_SIG_HEX.as_bytes(), + ); + let wrong_message = SignableMock { + domain: vec![], + signed_bytes_without_domain: vec![1, 2, 3], + }; + + let result = validate_webauthn_sig(&verifier, &sig, &wrong_message, &pk); + + assert!( + result + .err() + .unwrap() + .starts_with("Challenge in webauthn is") + ); + } + + #[test] + fn should_return_error_on_malformed_ed25519_signature() { + let verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); + let (pk, sig) = load_pk_and_sig( + ED25519_PK_COSE_DER_WRAPPED_HEX.as_ref(), + ED25519_WEBAUTHN_SIG_HEX.as_bytes(), + ); + // Replace the 64-byte signature with garbage of the wrong length. + let bad_sig = WebAuthnSignature::new( + Blob(sig.authenticator_data().0), + Blob(sig.client_data_json().0), + Blob(b"too short".to_vec()), + ); + + let challenge = hex::decode(CHALLENGE_HEX).unwrap(); + let message = SignableMock { + domain: vec![], + signed_bytes_without_domain: challenge, + }; + + let result = validate_webauthn_sig(&verifier, &bad_sig, &message, &pk); + assert!(result.is_err()); + } + } + mod rsa { use super::*; @@ -389,14 +481,14 @@ mod tests { ECDSA_P256_PK_COSE_DER_WRAPPED_HEX.as_ref(), ECDSA_WEBAUTHN_SIG_HELLO_HEX.as_ref(), ); - let unsupported_algorithm_id = AlgorithmId::Ed25519; + let unsupported_algorithm_id = AlgorithmId::EcdsaSecp256k1; pk.algorithm_id = unsupported_algorithm_id; let result = validate_webauthn_sig(&verifier, &sig, &delegation, &pk); - assert!( - result.err().unwrap().contains("Only ECDSA on curve P-256 and RSA PKCS #1 v1.5 are supported for WebAuthn, given: Ed25519") - ); + assert!(result.err().unwrap().contains( + "Only ECDSA on curve P-256, EdDSA on curve Ed25519, and RSA PKCS #1 v1.5 are supported for WebAuthn, given: EcdsaSecp256k1" + )); } #[test] From 99ee48060fe88d7a717e36bb40892a184fb43fb2 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 14:48:02 +0000 Subject: [PATCH 2/6] fix(crypto): early Ed25519 signature length check, drop double-clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on PR #10081: - `webauthn_sig.signature()` already returns an owned cloned `Blob`, so `.0.clone()` was an extra Vec clone. Move the Vec out instead with `webauthn_sig.signature().0`. - WebAuthn requires Ed25519 signatures to be exactly 64 bytes (W3C WebAuthn-2 §6.5.6). Reject other lengths early in the validator with a clear error rather than relying on the crypto verifier's generic "Invalid length" message. Tests: tighten the malformed-signature test to assert the new error message text instead of just `is_err()`. Co-Authored-By: Claude Opus 4.7 --- rs/validator/src/webauthn.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/rs/validator/src/webauthn.rs b/rs/validator/src/webauthn.rs index ef36212933d9..35f5794fe127 100644 --- a/rs/validator/src/webauthn.rs +++ b/rs/validator/src/webauthn.rs @@ -58,9 +58,17 @@ fn basic_sig_from_webauthn_sig( .map_err(|e| format!("Failed to parse EcdsaP256 signature: {e}")) } AlgorithmId::Ed25519 => { - // EdDSA signatures are 64 raw bytes (not DER wrapped). + // EdDSA signatures are exactly 64 raw bytes (not DER wrapped). // See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types - Ok(BasicSig(webauthn_sig.signature().0.clone())) + const ED25519_SIGNATURE_LEN: usize = 64; + let sig = webauthn_sig.signature().0; + if sig.len() != ED25519_SIGNATURE_LEN { + return Err(format!( + "Invalid Ed25519 signature length: expected {ED25519_SIGNATURE_LEN} bytes, got {}", + sig.len() + )); + } + Ok(BasicSig(sig)) } AlgorithmId::RsaSha256 => { // RSA signatures are not DER wrapped, see https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types @@ -372,7 +380,13 @@ mod tests { }; let result = validate_webauthn_sig(&verifier, &bad_sig, &message, &pk); - assert!(result.is_err()); + assert!( + result + .as_ref() + .err() + .is_some_and(|e| e.contains("Invalid Ed25519 signature length")), + "expected length error, got: {result:?}" + ); } } From edaadbc1eb2b47d088541d1d34badaf37518d4fe Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 11 May 2026 14:33:38 +0000 Subject: [PATCH 3/6] test(crypto): broaden Ed25519/EdDSA WebAuthn test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from @eichhorl on dfinity/ic#10081 by adding five tests across the affected layers: - rs/crypto/tests/request_id_signatures.rs: parse a COSE-DER-wrapped Ed25519 public key and assert both `AlgorithmId::Ed25519` and `KeyBytesContentType::Ed25519PublicKeyDerWrappedCose`. - rs/validator/src/ingress_validation/tests.rs: validate_signature_webauthn_ed25519 — end-to-end validator check on `message_test_id(13)` signed by the RFC 8032 TEST 1 keypair. - rs/validator/src/webauthn.rs (Ed25519 module): * incorrect public key (RFC 8032 TEST 2 pk) rejected with "Verifying signature failed.", * incorrect signature of correct length (64 zero bytes) rejected after the length check. - rs/tests/crypto/ingress_verification_test.rs: extend the system test with a WebAuthnEd25519 identity type, including the COSE wrapping and signing helpers that mirror the existing ECDSA/RSA equivalents. --- rs/crypto/tests/request_id_signatures.rs | 15 +++++ rs/tests/crypto/ingress_verification_test.rs | 53 ++++++++++++++++- rs/validator/src/ingress_validation/tests.rs | 33 +++++++++++ rs/validator/src/webauthn.rs | 60 ++++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) diff --git a/rs/crypto/tests/request_id_signatures.rs b/rs/crypto/tests/request_id_signatures.rs index 8d3ea1e2e95e..90058095b24c 100644 --- a/rs/crypto/tests/request_id_signatures.rs +++ b/rs/crypto/tests/request_id_signatures.rs @@ -194,6 +194,21 @@ fn should_correctly_parse_der_encoded_openssl_ecdsa_p256_pk() { assert_eq!(bytes_type, KeyBytesContentType::EcdsaP256PublicKeyDer); } +#[test] +fn should_correctly_parse_cose_encoded_der_wrapped_ed25519_pk() { + // Ed25519 public key (RFC 8032 TEST 1) encoded as a COSE_Key + // {kty=OKP, alg=EdDSA, crv=Ed25519, x=pk} and DER-wrapped with the IC's + // SubjectPublicKeyInfo OID 1.3.6.1.4.1.56387.1.1. The COSE parser must + // recognize the OKP/EdDSA/Ed25519 combination and surface it via + // `Ed25519PublicKeyDerWrappedCose`. + const ED25519_PK_COSE_DER_WRAPPED_HEX: &str = "303b300c060a2b0601040183b8430101032b00a4010103272006215820d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + + let pk_cose_der = hex::decode(ED25519_PK_COSE_DER_WRAPPED_HEX).unwrap(); + let (pk, bytes_type) = user_public_key_from_bytes(&pk_cose_der).unwrap(); + assert_eq!(pk.algorithm_id, AlgorithmId::Ed25519); + assert_eq!(bytes_type, KeyBytesContentType::Ed25519PublicKeyDerWrappedCose); +} + #[test] fn should_correctly_parse_cose_encoded_der_wrapped_ecdsa_p256_pk() { for pk_cose_der_hex in &[ diff --git a/rs/tests/crypto/ingress_verification_test.rs b/rs/tests/crypto/ingress_verification_test.rs index 4e9a5fa3a494..c35c0701c7a0 100644 --- a/rs/tests/crypto/ingress_verification_test.rs +++ b/rs/tests/crypto/ingress_verification_test.rs @@ -76,6 +76,7 @@ enum GenericIdentityType<'a> { Canister(&'a UniversalCanister<'a>), WebAuthnEcdsaSecp256r1, WebAuthnRsaPkcs1, + WebAuthnEd25519, } impl<'a> GenericIdentityType<'a> { @@ -83,22 +84,24 @@ impl<'a> GenericIdentityType<'a> { canister: &'a UniversalCanister<'a>, rng: &mut R, ) -> Self { - match rng.r#gen::() % 6 { + match rng.r#gen::() % 7 { 0 => Self::EcdsaSecp256k1, 1 => Self::EcdsaSecp256r1, 2 => Self::Canister(canister), 3 => Self::WebAuthnEcdsaSecp256r1, 4 => Self::WebAuthnRsaPkcs1, + 5 => Self::WebAuthnEd25519, _ => Self::Ed25519, } } fn random(rng: &mut R) -> Self { - match rng.r#gen::() % 5 { + match rng.r#gen::() % 6 { 0 => Self::EcdsaSecp256k1, 1 => Self::EcdsaSecp256r1, 2 => Self::WebAuthnEcdsaSecp256r1, 3 => Self::WebAuthnRsaPkcs1, + 4 => Self::WebAuthnEd25519, _ => Self::Ed25519, } } @@ -112,6 +115,7 @@ enum GenericIdentityInner<'a> { Canister(CanisterSigner<'a>), WebAuthnEcdsaSecp256r1(ic_secp256r1::PrivateKey), WebAuthnRsaPkcs1(rsa::RsaPrivateKey), + WebAuthnEd25519(ic_ed25519::PrivateKey), } #[derive(Clone)] @@ -155,6 +159,11 @@ impl<'a> GenericIdentity<'a> { let pk = webauthn_cose_wrap_rsa_pkcs1_key(&rsa::RsaPublicKey::from(&sk)); (GenericIdentityInner::WebAuthnRsaPkcs1(sk), pk) } + GenericIdentityType::WebAuthnEd25519 => { + let sk = ic_ed25519::PrivateKey::generate_using_rng(rng); + let pk = webauthn_cose_wrap_ed25519_key(&sk.public_key()); + (GenericIdentityInner::WebAuthnEd25519(sk), pk) + } }; let principal = Principal::self_authenticating(&public_key_der); @@ -214,6 +223,7 @@ impl<'a> GenericIdentity<'a> { webauthn_sign_ecdsa_secp256r1(sk, bytes) } GenericIdentityInner::WebAuthnRsaPkcs1(sk) => webauthn_sign_rsa_pkcs1(sk, bytes), + GenericIdentityInner::WebAuthnEd25519(sk) => webauthn_sign_ed25519(sk, bytes), GenericIdentityInner::Canister(canister_signer) => { let sign_future = canister_signer.sign(bytes); // We are in a sync method and need to call the async `CanisterSigner::sign`, @@ -2469,6 +2479,38 @@ fn webauthn_cose_wrap_ecdsa_secp256r1_key(pk: &ic_secp256r1::PublicKey) -> Vec Vec { + let mut map = std::collections::BTreeMap::new(); + + use serde_cbor::Value; + + /* + See: + - RFC 8152 ("CBOR Object Signing and Encryption (COSE)"), section 13.2 + for OKP key-type parameters. + - RFC 8812 ("CBOR Object Signing and Encryption (COSE) and JSON Object + Signing and Encryption (JOSE) Registrations for Web Authentication + (WebAuthn) Algorithms"), section 3.1 for the EdDSA algorithm code. + */ + const COSE_PARAM_KTY: serde_cbor::Value = serde_cbor::Value::Integer(1); + const COSE_PARAM_KTY_OKP: serde_cbor::Value = serde_cbor::Value::Integer(1); + + const COSE_PARAM_ALG: serde_cbor::Value = serde_cbor::Value::Integer(3); + const COSE_PARAM_ALG_EDDSA: serde_cbor::Value = serde_cbor::Value::Integer(-8); + + const COSE_PARAM_OKP_CRV: serde_cbor::Value = serde_cbor::Value::Integer(-1); + const COSE_PARAM_OKP_CRV_ED25519: serde_cbor::Value = serde_cbor::Value::Integer(6); + + const COSE_PARAM_OKP_X: serde_cbor::Value = serde_cbor::Value::Integer(-2); + + map.insert(COSE_PARAM_KTY, COSE_PARAM_KTY_OKP); + map.insert(COSE_PARAM_ALG, COSE_PARAM_ALG_EDDSA); + map.insert(COSE_PARAM_OKP_CRV, COSE_PARAM_OKP_CRV_ED25519); + map.insert(COSE_PARAM_OKP_X, Value::Bytes(pk.serialize_raw().to_vec())); + + wrap_cose_key_in_der_spki(&Value::Map(map)) +} + fn webauthn_sign_message Vec>(msg: &[u8], sign_fn: F) -> Vec { use serde::Serialize; @@ -2521,3 +2563,10 @@ fn webauthn_sign_rsa_pkcs1(sk: &rsa::RsaPrivateKey, msg: &[u8]) -> Vec { }; webauthn_sign_message(msg, sign_fn) } + +fn webauthn_sign_ed25519(sk: &ic_ed25519::PrivateKey, msg: &[u8]) -> Vec { + // WebAuthn EdDSA signatures are the raw 64-byte R || s encoding from + // RFC 8032 §5.1.6 — no DER wrapping (cf. WebAuthn §6.5.6). + let sign_fn = |to_sign: &[u8]| -> Vec { sk.sign_message(to_sign).to_vec() }; + webauthn_sign_message(msg, sign_fn) +} diff --git a/rs/validator/src/ingress_validation/tests.rs b/rs/validator/src/ingress_validation/tests.rs index 434b19d81f06..20371401d30d 100644 --- a/rs/validator/src/ingress_validation/tests.rs +++ b/rs/validator/src/ingress_validation/tests.rs @@ -446,6 +446,39 @@ fn validate_signature_webauthn() { ); } +#[test] +fn validate_signature_webauthn_ed25519() { + let sig_verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); + let message_id = message_test_id(13); + + // Ed25519 public key derived from the RFC 8032 TEST 1 seed + // (9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60), + // encoded as a COSE_Key map {kty=OKP, alg=EdDSA, crv=Ed25519, x=pk} and + // wrapped in the IC's SubjectPublicKeyInfo (OID 1.3.6.1.4.1.56387.1.1). + let pubkey_hex = "303b300c060a2b0601040183b8430101032b00a4010103272006215820d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + + // WebAuthn signature by the corresponding secret key over the signed + // bytes of `message_test_id(13)` (i.e. "\x0Aic-request" || message_id). + let signature_hex = "d9d9f7a37261757468656e74696361746f725f646174614961726269747261727970636c69656e745f646174615f6a736f6e58887b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a22436d6c6a4c584a6c6358566c6333514e414141414141414141414141414141414141414141414141414141414141414141414141414141414141222c226f726967696e223a2269632d696e67726573732d766572696669636174696f6e2d74657374227d697369676e61747572655840a0f73a56b4177b7da14c1c6be3e847394dc8511dae919e3da3ffad4307c413c350a3d028c047c2e12b822b9a1833eaf7136a38361e5ee05ece1f21c4ef2dc705"; + + let user_signature = UserSignature { + signature: hex::decode(signature_hex).unwrap(), + signer_pubkey: hex::decode(pubkey_hex).unwrap(), + sender_delegation: None, + }; + + assert_eq!( + validate_signature( + &sig_verifier, + &message_id, + &user_signature, + UNIX_EPOCH, + &MockRootOfTrustProvider::new() + ), + Ok(CanisterIdSet::all()) + ); +} + #[test] fn validate_signature_webauthn_with_delegations() { let sig_verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); diff --git a/rs/validator/src/webauthn.rs b/rs/validator/src/webauthn.rs index 35f5794fe127..9f49e92199bb 100644 --- a/rs/validator/src/webauthn.rs +++ b/rs/validator/src/webauthn.rs @@ -359,6 +359,66 @@ mod tests { ); } + #[test] + fn should_return_error_on_incorrect_public_key() { + // A different valid Ed25519 COSE-DER-wrapped key (RFC 8032 TEST 2 + // public key). The signature does not verify under it. + const WRONG_ED25519_PK_COSE_DER_WRAPPED_HEX: &str = "303b300c060a2b0601040183b8430101032b00a40101032720062158203d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"; + let verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); + let (wrong_pk, sig) = load_pk_and_sig( + WRONG_ED25519_PK_COSE_DER_WRAPPED_HEX.as_ref(), + ED25519_WEBAUTHN_SIG_HEX.as_bytes(), + ); + assert_eq!(wrong_pk.algorithm_id, AlgorithmId::Ed25519); + + let challenge = hex::decode(CHALLENGE_HEX).unwrap(); + let message = SignableMock { + domain: vec![], + signed_bytes_without_domain: challenge, + }; + + let result = validate_webauthn_sig(&verifier, &sig, &message, &wrong_pk); + assert!( + result + .as_ref() + .err() + .is_some_and(|e| e.contains("Verifying signature failed.")), + "expected verification failure, got: {result:?}" + ); + } + + #[test] + fn should_return_error_on_correct_length_but_invalid_ed25519_signature() { + let verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); + let (pk, sig) = load_pk_and_sig( + ED25519_PK_COSE_DER_WRAPPED_HEX.as_ref(), + ED25519_WEBAUTHN_SIG_HEX.as_bytes(), + ); + // Replace the 64-byte signature with 64 zero bytes: the length + // check in basic_sig_from_webauthn_sig passes, but the underlying + // Ed25519 verification rejects it. + let bad_sig = WebAuthnSignature::new( + Blob(sig.authenticator_data().0), + Blob(sig.client_data_json().0), + Blob(vec![0u8; 64]), + ); + + let challenge = hex::decode(CHALLENGE_HEX).unwrap(); + let message = SignableMock { + domain: vec![], + signed_bytes_without_domain: challenge, + }; + + let result = validate_webauthn_sig(&verifier, &bad_sig, &message, &pk); + assert!( + result + .as_ref() + .err() + .is_some_and(|e| e.contains("Verifying signature failed.")), + "expected verification failure, got: {result:?}" + ); + } + #[test] fn should_return_error_on_malformed_ed25519_signature() { let verifier = temp_crypto_component_with_fake_registry(node_test_id(0)); From 3e58a8561ce479393bd5e95955d17f0a5c2d0c66 Mon Sep 17 00:00:00 2001 From: IDX GitHub Automation Date: Mon, 11 May 2026 14:42:43 +0000 Subject: [PATCH 4/6] Automatically fixing code for linting and formatting issues --- rs/crypto/tests/request_id_signatures.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rs/crypto/tests/request_id_signatures.rs b/rs/crypto/tests/request_id_signatures.rs index 90058095b24c..28614e15710e 100644 --- a/rs/crypto/tests/request_id_signatures.rs +++ b/rs/crypto/tests/request_id_signatures.rs @@ -206,7 +206,10 @@ fn should_correctly_parse_cose_encoded_der_wrapped_ed25519_pk() { let pk_cose_der = hex::decode(ED25519_PK_COSE_DER_WRAPPED_HEX).unwrap(); let (pk, bytes_type) = user_public_key_from_bytes(&pk_cose_der).unwrap(); assert_eq!(pk.algorithm_id, AlgorithmId::Ed25519); - assert_eq!(bytes_type, KeyBytesContentType::Ed25519PublicKeyDerWrappedCose); + assert_eq!( + bytes_type, + KeyBytesContentType::Ed25519PublicKeyDerWrappedCose + ); } #[test] From 005f8eb281dd0af9ddea47e66645b14578cc3053 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 11 May 2026 14:53:24 +0000 Subject: [PATCH 5/6] refactor(validator): drop redundant double reference on webauthn_sig The basic_sig_from_webauthn_sig parameter was &&WebAuthnSignature, with the call site passing &webauthn_sig where webauthn_sig was already a &WebAuthnSignature. Rust's auto-deref made it work, but the extra layer served no purpose. Take &WebAuthnSignature and pass webauthn_sig directly. --- rs/validator/src/webauthn.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/validator/src/webauthn.rs b/rs/validator/src/webauthn.rs index 9f49e92199bb..98eec7280b90 100644 --- a/rs/validator/src/webauthn.rs +++ b/rs/validator/src/webauthn.rs @@ -15,7 +15,7 @@ pub(crate) fn validate_webauthn_sig( signable: &impl Signable, public_key: &UserPublicKey, ) -> Result<(), String> { - let basic_sig = basic_sig_from_webauthn_sig(&webauthn_sig, public_key.algorithm_id)?; + let basic_sig = basic_sig_from_webauthn_sig(webauthn_sig, public_key.algorithm_id)?; let envelope = match WebAuthnEnvelope::try_from(webauthn_sig) { Ok(envelope) => envelope, @@ -48,7 +48,7 @@ pub(crate) fn validate_webauthn_sig( } fn basic_sig_from_webauthn_sig( - webauthn_sig: &&WebAuthnSignature, + webauthn_sig: &WebAuthnSignature, algorithm_id: AlgorithmId, ) -> Result { match algorithm_id { From 7edf2f110128e9a50ecb92c9ac1e9bd7dfea48ef Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 11 May 2026 16:01:37 +0000 Subject: [PATCH 6/6] fix(validator): separate u8 literal suffix in Ed25519 test CI's clippy run denies clippy::unseparated_literal_suffix on top of clippy::all. Rewrite `0u8` as `0_u8` to satisfy the lint. --- rs/validator/src/webauthn.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/validator/src/webauthn.rs b/rs/validator/src/webauthn.rs index 98eec7280b90..ed4e406beffb 100644 --- a/rs/validator/src/webauthn.rs +++ b/rs/validator/src/webauthn.rs @@ -400,7 +400,7 @@ mod tests { let bad_sig = WebAuthnSignature::new( Blob(sig.authenticator_data().0), Blob(sig.client_data_json().0), - Blob(vec![0u8; 64]), + Blob(vec![0_u8; 64]), ); let challenge = hex::decode(CHALLENGE_HEX).unwrap();