feat(core): normalize Ed25519 raw-key SPKI fingerprint to ed25519:hex (core/fingerprint-normalization)
This commit is contained in:
@@ -364,6 +364,9 @@ fn extract_quinn_client_fingerprint(connection: &quinn::Connection) -> Option<St
|
|||||||
|
|
||||||
#[cfg(any(feature = "quinn", feature = "iroh"))]
|
#[cfg(any(feature = "quinn", feature = "iroh"))]
|
||||||
fn fingerprint_from_cert_der(cert_der: &[u8]) -> Option<String> {
|
fn fingerprint_from_cert_der(cert_der: &[u8]) -> Option<String> {
|
||||||
|
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};
|
use sha2::{Digest, Sha256};
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(cert_der);
|
hasher.update(cert_der);
|
||||||
@@ -371,6 +374,114 @@ fn fingerprint_from_cert_der(cert_der: &[u8]) -> Option<String> {
|
|||||||
Some(format!("SHA256:{}", hex::encode(digest)))
|
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")]
|
#[cfg(feature = "iroh")]
|
||||||
async fn run_iroh_accept_loop(
|
async fn run_iroh_accept_loop(
|
||||||
iroh: iroh::Endpoint,
|
iroh: iroh::Endpoint,
|
||||||
@@ -1259,6 +1370,102 @@ mod tests {
|
|||||||
assert_eq!(a, b, "same cert DER must produce same fingerprint");
|
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<u8> {
|
||||||
|
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<u8> = 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]
|
#[test]
|
||||||
fn acme_directory_production_url() {
|
fn acme_directory_production_url() {
|
||||||
use crate::config::AcmeDirectory;
|
use crate::config::AcmeDirectory;
|
||||||
|
|||||||
@@ -178,10 +178,10 @@ mod tests {
|
|||||||
let key = make_test_key();
|
let key = make_test_key();
|
||||||
let public = serde_json::to_string(&key.public_key).unwrap();
|
let public = serde_json::to_string(&key.public_key).unwrap();
|
||||||
let private = serde_json::to_string(&vec![0xABu8; 32]).unwrap();
|
let private = serde_json::to_string(&vec![0xABu8; 32]).unwrap();
|
||||||
let json = format!(
|
let json =
|
||||||
r#"{{"key_type":"Ed25519","private_key":{private},"public_key":{public}}}"#
|
format!(r#"{{"key_type":"Ed25519","private_key":{private},"public_key":{public}}}"#);
|
||||||
);
|
let result: DerivedKey =
|
||||||
let result: DerivedKey = serde_json::from_str(&json).expect("non-redacted payload deserializes");
|
serde_json::from_str(&json).expect("non-redacted payload deserializes");
|
||||||
assert_eq!(result.key_type, KeyType::Ed25519);
|
assert_eq!(result.key_type, KeyType::Ed25519);
|
||||||
assert_eq!(result.private_key, vec![0xABu8; 32]);
|
assert_eq!(result.private_key, vec![0xABu8; 32]);
|
||||||
assert_eq!(result.public_key, key.public_key);
|
assert_eq!(result.public_key, key.public_key);
|
||||||
|
|||||||
Reference in New Issue
Block a user