From 3174b8953f3645be1dd9b7d77cef398f4ea3d23b Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 15:23:05 +0200 Subject: [PATCH 1/3] test write --- .github/test-write-access.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/test-write-access.txt diff --git a/.github/test-write-access.txt b/.github/test-write-access.txt new file mode 100644 index 000000000000..30d74d258442 --- /dev/null +++ b/.github/test-write-access.txt @@ -0,0 +1 @@ +test \ No newline at end of file From b65adef11778a64284f7cc83147eb75fd286a068 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 13:23:16 +0000 Subject: [PATCH 2/3] cleanup --- .github/test-write-access.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/test-write-access.txt diff --git a/.github/test-write-access.txt b/.github/test-write-access.txt deleted file mode 100644 index 30d74d258442..000000000000 --- a/.github/test-write-access.txt +++ /dev/null @@ -1 +0,0 @@ -test \ No newline at end of file From 5c676ff3011becd88154ec64e4e134d7926f4884 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 3/3] 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]