From ea31200d17e7b40ee6c71ec577659aa723b8b345 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Sun, 28 Jun 2026 21:39:32 +0000 Subject: [PATCH] feat(core): normalize Ed25519 raw-key SPKI fingerprint to ed25519:hex (core/fingerprint-normalization) --- crates/alknet-core/src/endpoint.rs | 207 ++++++++++++++++++++++++++++ crates/alknet-vault/src/protocol.rs | 8 +- 2 files changed, 211 insertions(+), 4 deletions(-) diff --git a/crates/alknet-core/src/endpoint.rs b/crates/alknet-core/src/endpoint.rs index e69564f..c347563 100644 --- a/crates/alknet-core/src/endpoint.rs +++ b/crates/alknet-core/src/endpoint.rs @@ -389,6 +389,9 @@ fn extract_quinn_client_fingerprint(connection: &quinn::Connection) -> Option Option { + if let Some(raw_key) = extract_ed25519_raw_key_from_spki(cert_der) { + return Some(format!("ed25519:{}", hex::encode(raw_key))); + } use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(cert_der); @@ -396,6 +399,114 @@ fn fingerprint_from_cert_der(cert_der: &[u8]) -> Option { Some(format!("SHA256:{}", hex::encode(digest))) } +/// `SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }` +/// `AlgorithmIdentifier ::= SEQUENCE { algorithm OBJECT IDENTIFIER, parameters ANY OPTIONAL }` +/// For Ed25519 the algorithm OID is `1.3.101.112` (DER bytes `2b 65 70`), with no parameters, +/// and `subjectPublicKey` is a BIT STRING containing one unused-bits byte (`0x00`) followed +/// by the 32-byte raw Ed25519 public key. Returns the 32 raw key bytes when `cert_der` is an +/// RFC 7250 raw public key (SPKI) with the Ed25519 algorithm identifier; returns `None` +/// otherwise (X.509 cert, non-Ed25519 SPKI, or malformed DER), in which case callers should +/// fall back to hashing the full DER. +#[cfg(any(feature = "quinn", feature = "iroh"))] +fn extract_ed25519_raw_key_from_spki(cert_der: &[u8]) -> Option<[u8; 32]> { + const ED25519_OID_BYTES: [u8; 3] = [0x2b, 0x65, 0x70]; + + let mut parser = DerParser::new(cert_der); + let spki_contents = parser.expect_sequence()?; + let mut spki_parser = DerParser::new(spki_contents); + + let alg_id_contents = spki_parser.expect_sequence()?; + let mut alg_id_parser = DerParser::new(alg_id_contents); + let oid_bytes = alg_id_parser.expect_oid()?; + if oid_bytes != ED25519_OID_BYTES { + return None; + } + + let bit_string_contents = spki_parser.expect_bit_string()?; + if bit_string_contents.len() != 33 || bit_string_contents[0] != 0x00 { + return None; + } + let mut raw_key = [0u8; 32]; + raw_key.copy_from_slice(&bit_string_contents[1..33]); + Some(raw_key) +} + +#[cfg(any(feature = "quinn", feature = "iroh"))] +struct DerParser<'a> { + bytes: &'a [u8], +} + +#[cfg(any(feature = "quinn", feature = "iroh"))] +impl<'a> DerParser<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes } + } + + fn read_tlv(&mut self) -> Option<(u8, &'a [u8])> { + let (tag, len_size, header_len) = self.decode_header()?; + let total = header_len.checked_add(len_size)?; + if total > self.bytes.len() { + return None; + } + let content = &self.bytes[header_len..total]; + self.bytes = &self.bytes[total..]; + Some((tag, content)) + } + + fn decode_header(&self) -> Option<(u8, usize, usize)> { + if self.bytes.is_empty() { + return None; + } + let tag = self.bytes[0]; + if self.bytes.len() < 2 { + return None; + } + let first_len = self.bytes[1]; + if first_len < 0x80 { + return Some((tag, first_len as usize, 2)); + } + let num_bytes = (first_len & 0x7f) as usize; + if num_bytes == 0 || num_bytes > 4 { + return None; + } + if self.bytes.len() < 2 + num_bytes { + return None; + } + let mut len: usize = 0; + for i in 0..num_bytes { + len = (len << 8) | (self.bytes[2 + i] as usize); + } + Some((tag, len, 2 + num_bytes)) + } + + fn expect_sequence(&mut self) -> Option<&'a [u8]> { + let (tag, content) = self.read_tlv()?; + if tag == 0x30 { + Some(content) + } else { + None + } + } + + fn expect_oid(&mut self) -> Option<&'a [u8]> { + let (tag, content) = self.read_tlv()?; + if tag == 0x06 { + Some(content) + } else { + None + } + } + + fn expect_bit_string(&mut self) -> Option<&'a [u8]> { + let (tag, content) = self.read_tlv()?; + if tag == 0x03 { + Some(content) + } else { + None + } + } +} + #[cfg(feature = "iroh")] async fn run_iroh_accept_loop( iroh: iroh::Endpoint, @@ -1297,6 +1408,102 @@ mod tests { assert_eq!(a, b, "same cert DER must produce same fingerprint"); } + #[cfg(any(feature = "quinn", feature = "iroh"))] + fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec { + use rustls::pki_types::alg_id; + let spki = rustls::sign::public_key_to_spki(&alg_id::ED25519, raw_key); + spki.to_vec() + } + + #[cfg(any(feature = "quinn", feature = "iroh"))] + #[test] + fn fingerprint_from_ed25519_spki_produces_ed25519_prefix() { + let sk = crate::config::Ed25519SecretKey::generate(); + let raw_key = sk.public().to_bytes(); + let spki_der = build_ed25519_spki_der(&raw_key); + let fp = fingerprint_from_cert_der(&spki_der).expect("spki produces fingerprint"); + assert!( + fp.starts_with("ed25519:"), + "Ed25519 raw key SPKI must produce ed25519: fingerprint, got: {fp}" + ); + } + + #[cfg(any(feature = "quinn", feature = "iroh"))] + #[test] + fn fingerprint_from_ed25519_spki_is_lowercase_hex_of_32_byte_key() { + let sk = crate::config::Ed25519SecretKey::generate(); + let raw_key = sk.public().to_bytes(); + let spki_der = build_ed25519_spki_der(&raw_key); + let fp = fingerprint_from_cert_der(&spki_der).expect("spki produces fingerprint"); + let hex_part = &fp["ed25519:".len()..]; + assert_eq!( + hex_part.len(), + 64, + "ed25519 hex part must be 64 chars (32 bytes), got: {fp}" + ); + assert!( + hex_part + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + "ed25519 hex part must be lowercase hex, got: {fp}" + ); + assert_eq!( + hex_part, + hex::encode(raw_key), + "ed25519 fingerprint must be hex of the raw 32-byte key, not the DER wrapper" + ); + } + + #[cfg(any(feature = "quinn", feature = "iroh"))] + #[test] + fn fingerprint_from_ed25519_spki_matches_iroh_format() { + let sk = crate::config::Ed25519SecretKey::generate(); + let raw_key = sk.public().to_bytes(); + let spki_der = build_ed25519_spki_der(&raw_key); + let quinn_fp = fingerprint_from_cert_der(&spki_der).expect("spki produces fingerprint"); + let iroh_fp = format!("ed25519:{}", hex::encode(raw_key)); + assert_eq!( + quinn_fp, iroh_fp, + "same Ed25519 key must produce the same fingerprint via quinn SPKI and iroh NodeId paths" + ); + } + + #[cfg(any(feature = "quinn", feature = "iroh"))] + #[test] + fn fingerprint_from_x509_cert_stays_sha256_of_der() { + let cert_der = b"fake-x509-cert-der-bytes-not-an-spki"; + let fp = fingerprint_from_cert_der(cert_der).expect("x509 produces fingerprint"); + assert!( + fp.starts_with("SHA256:"), + "X.509 cert must keep SHA256: format, got: {fp}" + ); + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(cert_der); + assert_eq!( + fp, + format!("SHA256:{}", hex::encode(hasher.finalize())), + "X.509 fingerprint must be SHA-256 of cert DER" + ); + } + + #[cfg(any(feature = "quinn", feature = "iroh"))] + #[test] + fn fingerprint_from_non_ed25519_spki_falls_back_to_sha256() { + let raw_key = [0u8; 32]; + let fake_non_ed25519_spki: Vec = vec![ + 0x30, 0x1c, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x06, 0x01, 0x03, 0x15, 0x00, 0x20, + ] + .into_iter() + .chain(raw_key.iter().copied()) + .collect(); + let fp = fingerprint_from_cert_der(&fake_non_ed25519_spki).expect("fallback fingerprint"); + assert!( + fp.starts_with("SHA256:"), + "non-Ed25519 SPKI must fall back to SHA256:, got: {fp}" + ); + } + #[test] fn acme_directory_production_url() { use crate::config::AcmeDirectory; diff --git a/crates/alknet-vault/src/protocol.rs b/crates/alknet-vault/src/protocol.rs index 1cc472b..3d19c1c 100644 --- a/crates/alknet-vault/src/protocol.rs +++ b/crates/alknet-vault/src/protocol.rs @@ -178,10 +178,10 @@ mod tests { let key = make_test_key(); let public = serde_json::to_string(&key.public_key).unwrap(); let private = serde_json::to_string(&vec![0xABu8; 32]).unwrap(); - let json = format!( - r#"{{"key_type":"Ed25519","private_key":{private},"public_key":{public}}}"# - ); - let result: DerivedKey = serde_json::from_str(&json).expect("non-redacted payload deserializes"); + let json = + format!(r#"{{"key_type":"Ed25519","private_key":{private},"public_key":{public}}}"#); + let result: DerivedKey = + serde_json::from_str(&json).expect("non-redacted payload deserializes"); assert_eq!(result.key_type, KeyType::Ed25519); assert_eq!(result.private_key, vec![0xABu8; 32]); assert_eq!(result.public_key, key.public_key);