diff --git a/Cargo.lock b/Cargo.lock index ac9793ab12e3..25d81d737bf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8379,6 +8379,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 ea187388453f..662fe6246842 100644 --- a/rs/crypto/internal/crypto_lib/basic_sig/cose/BUILD.bazel +++ b/rs/crypto/internal/crypto_lib/basic_sig/cose/BUILD.bazel @@ -9,6 +9,7 @@ rust_library( version = "0.9.0", deps = [ # Keep sorted. + "//packages/ic-ed25519", "//packages/ic-secp256r1", "//rs/crypto/internal/crypto_lib/basic_sig/rsa_pkcs1", "//rs/types/types", @@ -22,6 +23,7 @@ rust_test( crate = ":cose", deps = [ # Keep sorted. + "//packages/ic-ed25519", "//packages/ic-secp256r1", "//rs/crypto/internal/crypto_lib/basic_sig/rsa_pkcs1", "//rs/crypto/internal/test_vectors", @@ -38,6 +40,7 @@ rust_test_suite( deps = [ # Keep sorted. ":cose", + "//packages/ic-ed25519", "//packages/ic-secp256r1", "//rs/crypto/internal/crypto_lib/basic_sig/rsa_pkcs1", "//rs/crypto/internal/test_vectors", 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/crypto/tests/request_id_signatures.rs b/rs/crypto/tests/request_id_signatures.rs index 8d3ea1e2e95e..28614e15710e 100644 --- a/rs/crypto/tests/request_id_signatures.rs +++ b/rs/crypto/tests/request_id_signatures.rs @@ -194,6 +194,24 @@ 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.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/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 37ae35da8d38..ed4e406beffb 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 { @@ -57,12 +57,25 @@ 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 exactly 64 raw bytes (not DER wrapped). + // See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types + 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 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 +297,159 @@ 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_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![0_u8; 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)); + 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 + .as_ref() + .err() + .is_some_and(|e| e.contains("Invalid Ed25519 signature length")), + "expected length error, got: {result:?}" + ); + } + } + mod rsa { use super::*; @@ -389,14 +555,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]