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]