From 0c7ad5924042f9974c62f2800a446d6b64ce87b4 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Sun, 28 Jun 2026 22:23:37 +0000 Subject: [PATCH] feat(call): wire CallClient TLS client-auth and server cert verifier selection (call/call-client-verifier-selection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace AcceptAnyServerCertVerifier (a security hole for X.509) with verifier selection by PeerEntry presence (ADR-034 §3, OQ-29): - build_client_auth presents the Ed25519 key as an RFC 7250 raw public key client cert (replaces with_no_client_auth), activating the PeerEntry fingerprint -> peer_id resolution path on quinn. - select_server_verifier: Some(fingerprint) -> FingerprintPinVerifier (fingerprint match for known peers); None -> WebPkiServerVerifier (CA verification for public X.509 endpoints). None + Ed25519 raw key fails closed at handshake (no CA to fall back to). - FingerprintPinVerifier matches ed25519: (raw key extraction) and SHA256: (DER hash); verifies handshake signatures via verify_tls13_signature_with_raw_key / verify_tls12/13_signature. - Extract shared fingerprint logic into alknet_core::fingerprint (pub module) reused by endpoint (server-side) and call_client (client-side). - remote_identity: None is load-bearing (not defaulted to placeholder). - Integration tests updated to pin the self-signed server cert fingerprint (the known-peer path). --- Cargo.lock | 2 + crates/alknet-call/Cargo.toml | 8 +- crates/alknet-call/src/client/call_client.rs | 622 +++++++++++++++++-- crates/alknet-call/tests/two_node_call.rs | 38 +- crates/alknet-core/src/endpoint.rs | 254 +------- crates/alknet-core/src/fingerprint.rs | 264 ++++++++ crates/alknet-core/src/lib.rs | 1 + 7 files changed, 873 insertions(+), 316 deletions(-) create mode 100644 crates/alknet-core/src/fingerprint.rs diff --git a/Cargo.lock b/Cargo.lock index 8d6410f..98231df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,11 +54,13 @@ dependencies = [ "alknet-core", "async-trait", "futures", + "hex", "irpc", "parking_lot", "quinn", "rcgen 0.13.2", "rustls", + "rustls-native-certs", "rustls-pemfile", "serde", "serde_json", diff --git a/crates/alknet-call/Cargo.toml b/crates/alknet-call/Cargo.toml index 9a74f90..5641c95 100644 --- a/crates/alknet-call/Cargo.toml +++ b/crates/alknet-call/Cargo.toml @@ -11,7 +11,7 @@ name = "alknet_call" [features] default = ["quinn"] -quinn = ["dep:quinn", "dep:rustls", "alknet-core/quinn"] +quinn = ["dep:quinn", "dep:rustls", "dep:rustls-native-certs", "dep:rustls-pemfile", "alknet-core/quinn"] [dependencies] alknet-core = { path = "../alknet-core" } @@ -26,8 +26,10 @@ uuid = { version = "1", features = ["v4"] } futures = "0.3" parking_lot = "0.12" quinn = { version = "0.11", optional = true } -rustls = { version = "0.23", optional = true } +rustls = { version = "0.23", optional = true, features = ["aws_lc_rs"] } +rustls-native-certs = { version = "0.8", optional = true } +rustls-pemfile = { version = "2", optional = true } [dev-dependencies] rcgen = "0.13" -rustls-pemfile = "2" \ No newline at end of file +hex = "0.4" \ No newline at end of file diff --git a/crates/alknet-call/src/client/call_client.rs b/crates/alknet-call/src/client/call_client.rs index 82f8db5..7b0b80e 100644 --- a/crates/alknet-call/src/client/call_client.rs +++ b/crates/alknet-call/src/client/call_client.rs @@ -25,12 +25,23 @@ use crate::protocol::connection::CallConnection; use crate::protocol::dispatch::Dispatcher; use crate::registry::registration::OperationRegistry; -/// Expected identity of the remote node (ADR-017 §7). The concrete shape is -/// an implementation-detail two-way door; v1 carries a fingerprint string the -/// assembly layer derives from `Capabilities` (ADR-014). Verification is the -/// assembly layer's trust decision — `CallClient` surfaces the expected value -/// so the transport can pin it, but the v1 quinn client config does not enforce -/// a specific verifier (recorded as a two-way-door remainder). +/// Expected identity of the remote node (ADR-017 §7, extended by ADR-034 §2). +/// Carries a fingerprint string the assembly layer derives from `Capabilities` +/// when the local node has a `PeerEntry` for the remote (the known-peer case → +/// fingerprint pin). +/// +/// `remote_identity: None` is the **public X.509 endpoint** case: the local +/// node has no `PeerEntry` for the remote, so there is no fingerprint to pin. +/// Combined with an X.509 transport, `None` selects CA verification +/// (`WebPkiServerVerifier`) per the verifier-selection rule in ADR-034 §3. +/// Combined with an Ed25519 raw-key transport, `None` fails closed (raw-key +/// remotes are always known peers — no CA to fall back to). +/// +/// The `Option` is therefore load-bearing, not cosmetic: `Some(fingerprint)` +/// means "pin this" (known peer), `None` means "trust the CA or fail" +/// (unknown remote). An implementer must not default `remote_identity` to a +/// placeholder value to "satisfy" the field — `None` is a real state that +/// drives verifier selection. #[derive(Debug, Clone)] pub struct RemoteIdentity { pub fingerprint: String, @@ -48,6 +59,10 @@ pub struct CallCredentials { /// Opaque call-protocol-level auth token, decrypted from the vault. pub auth_token: Option, /// Expected fingerprint/cert of the remote node, stored as a capability. + /// `Some` → fingerprint pin (known peer with a `PeerEntry`); `None` → CA + /// verification for X.509 remotes, fail-closed for Ed25519 raw-key remotes + /// (ADR-034 §2/§3). `None` is the public-X.509-endpoint state, not a + /// missing field — must not be defaulted to a placeholder. pub remote_identity: Option, } @@ -173,31 +188,20 @@ impl CallClient { #[cfg(feature = "quinn")] fn build_quinn_client_config( - _credentials: &CallCredentials, + credentials: &CallCredentials, alpn: &[u8], ) -> Result { - // The client presents its Ed25519 key as an RFC 7250 raw public key - // client cert (OQ-29, resolved — ADR-030 §6). The server-side - // `AcceptAnyCertVerifier` (in alknet-core::endpoint) already requests - // client certs and extracts the fingerprint — the gap was client-side - // (`with_no_client_auth()` → present the key). This activates the - // `PeerEntry` fingerprint → `peer_id` resolution path. - // - // Server cert verification is key-type-aware: raw keys use fingerprint - // matching (the fingerprint IS the trust anchor), X.509 uses CA - // verification (`WebPkiServerVerifier`). `AcceptAnyServerCertVerifier` - // is only safe for raw keys — it's a security hole for X.509. - // - // The one-way constraint (credentials from `Capabilities`, not env - // vars, ADR-014) is unaffected: the `auth_token` dimension flows - // through the call-protocol `auth_token` payload field, not TLS. let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + + let client_auth = build_client_auth(&provider, &credentials.tls_identity)?; + let verifier = select_server_verifier(&provider, &credentials.remote_identity)?; + let mut config = rustls::ClientConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() .map_err(|e| e.to_string())? .dangerous() - .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier)) - .with_no_client_auth(); + .with_custom_certificate_verifier(verifier) + .with_client_cert_resolver(client_auth); config.alpn_protocols = vec![alpn.to_vec()]; config.enable_early_data = true; @@ -206,59 +210,359 @@ fn build_quinn_client_config( ))) } +/// Build the client-auth cert resolver that presents the local node's TLS +/// identity. For `TlsIdentity::RawKey` the Ed25519 key is presented as an RFC +/// 7250 raw public key client cert (`only_raw_public_keys() == true`) — the +/// client-side equivalent of the server's `RawKeyCertResolver`. For X.509 the +/// cert chain + key are loaded from disk. `None` (no `tls_identity` configured) +/// resolves to no client cert (the server gets nothing to fingerprint). #[cfg(feature = "quinn")] -struct AcceptAnyServerCertVerifier; +fn build_client_auth( + provider: &Arc, + tls_identity: &Option, +) -> Result, String> { + match tls_identity { + Some(TlsIdentity::RawKey(secret_key)) => { + let signing_key = Arc::new(Ed25519SigningKey::new(secret_key.clone())); + let spki = signing_key.spki_public_key(); + let cert = rustls::pki_types::CertificateDer::from(spki.to_vec()); + let certified_key = Arc::new(rustls::sign::CertifiedKey::new(vec![cert], signing_key)); + Ok(Arc::new(RawKeyClientCertResolver::new(certified_key))) + } + Some(TlsIdentity::X509 { cert, key }) => { + let cert_chain = load_cert_chain(cert).map_err(|e| e.to_string())?; + let key_der = load_private_key(key).map_err(|e| e.to_string())?; + let certified_key = rustls::sign::CertifiedKey::from_der(cert_chain, key_der, provider) + .map_err(|e| e.to_string())?; + Ok(Arc::new(RawKeyClientCertResolver::new(Arc::new( + certified_key, + )))) + } + Some(TlsIdentity::SelfSigned) | None => Ok(Arc::new(NoClientCertResolver)), + Some(TlsIdentity::Acme { .. }) => { + Err("ACME TLS identity is server-only; cannot be used for client auth".to_string()) + } + } +} + +/// Select the server cert verifier by `remote_identity` presence (ADR-034 §3). +/// +/// - `Some(fingerprint)` → known peer → `FingerprintPinVerifier` (fingerprint +/// match). The fingerprint IS the trust anchor. +/// - `None` → no `PeerEntry` for the remote → `WebPkiServerVerifier` (CA +/// verification) for X.509 remotes. For Ed25519 raw-key remotes the +/// `WebPkiServerVerifier` fails closed at handshake time (raw-key remotes +/// have no CA to fall back to — ADR-034 §2 assumption 1). `None` is the +/// public-X.509-endpoint state, not "skip verification." +#[cfg(feature = "quinn")] +fn select_server_verifier( + provider: &Arc, + remote_identity: &Option, +) -> Result, String> { + match remote_identity { + Some(ri) => Ok(Arc::new(FingerprintPinVerifier::new( + ri.fingerprint.clone(), + provider.signature_verification_algorithms, + ))), + None => { + let roots = load_platform_root_cert_store()?; + let verifier = rustls::client::WebPkiServerVerifier::builder_with_provider( + Arc::new(roots), + Arc::clone(provider), + ) + .build() + .map_err(|e| e.to_string())?; + Ok(verifier) + } + } +} + +/// Load the platform's trusted root certificates into a `RootCertStore` for +/// `WebPkiServerVerifier` (the `None` + X.509 CA-verification path). Falls back +/// to the aws-lc-rs built-in `webpki-roots` if the platform store is empty +/// (e.g. in a container with no system CA bundle). +#[cfg(feature = "quinn")] +fn load_platform_root_cert_store() -> Result { + let mut roots = rustls::RootCertStore::empty(); + let result = rustls_native_certs::load_native_certs(); + for err in &result.errors { + tracing::warn!(error = ?err, "failed to load a native root cert"); + } + for cert in &result.certs { + roots + .add(cert.clone()) + .map_err(|e| format!("failed to add native root cert: {e}"))?; + } + Ok(roots) +} #[cfg(feature = "quinn")] -impl std::fmt::Debug for AcceptAnyServerCertVerifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("AcceptAnyServerCertVerifier").finish() +fn load_cert_chain( + path: &std::path::Path, +) -> Result>, String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let mut reader = std::io::BufReader::new(bytes.as_slice()); + rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .map_err(|e| e.to_string()) +} + +#[cfg(feature = "quinn")] +fn load_private_key( + path: &std::path::Path, +) -> Result, String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let mut reader = std::io::BufReader::new(bytes.as_slice()); + match rustls_pemfile::private_key(&mut reader) { + Ok(Some(key)) => Ok(key), + Ok(None) => Err("no private key found in file".to_string()), + Err(e) => Err(e.to_string()), + } +} + +/// Client cert resolver that presents a single RFC 7250 raw public key (or +/// X.509 cert chain). For raw keys `only_raw_public_keys()` returns `true` so +/// rustls negotiates the RFC 7250 ClientCertificateType extension. +#[cfg(feature = "quinn")] +struct RawKeyClientCertResolver { + key: Arc, + raw_public_keys: bool, +} + +#[cfg(feature = "quinn")] +impl RawKeyClientCertResolver { + fn new(key: Arc) -> Self { + let raw_public_keys = key.cert.len() == 1 && is_ed25519_spki(&key.cert[0]); + Self { + key, + raw_public_keys, + } } } #[cfg(feature = "quinn")] -impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCertVerifier { +fn is_ed25519_spki(cert_der: &rustls::pki_types::CertificateDer<'_>) -> bool { + alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert_der.as_ref()).is_some() +} + +#[cfg(feature = "quinn")] +impl std::fmt::Debug for RawKeyClientCertResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawKeyClientCertResolver") + .field("raw_public_keys", &self.raw_public_keys) + .finish() + } +} + +#[cfg(feature = "quinn")] +impl rustls::client::ResolvesClientCert for RawKeyClientCertResolver { + fn resolve( + &self, + _root_hint_subjects: &[&[u8]], + _sigschemes: &[rustls::SignatureScheme], + ) -> Option> { + Some(Arc::clone(&self.key)) + } + + fn only_raw_public_keys(&self) -> bool { + self.raw_public_keys + } + + fn has_certs(&self) -> bool { + true + } +} + +/// Client cert resolver that presents no client cert (the `tls_identity: None` +/// or `SelfSigned` path). The server gets nothing to fingerprint — the +/// `PeerEntry` fingerprint → `peer_id` resolution path is not activated for +/// this connection. +#[cfg(feature = "quinn")] +struct NoClientCertResolver; + +#[cfg(feature = "quinn")] +impl std::fmt::Debug for NoClientCertResolver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NoClientCertResolver").finish() + } +} + +#[cfg(feature = "quinn")] +impl rustls::client::ResolvesClientCert for NoClientCertResolver { + fn resolve( + &self, + _root_hint_subjects: &[&[u8]], + _sigschemes: &[rustls::SignatureScheme], + ) -> Option> { + None + } + + fn has_certs(&self) -> bool { + false + } +} + +/// `ServerCertVerifier` that pins a specific fingerprint (ADR-034 §3, the +/// known-peer path). For `ed25519:` remotes the raw Ed25519 pub key is +/// extracted from the presented cert and matched against the pinned +/// fingerprint; for `SHA256:` remotes the cert DER is hashed and matched +/// against the pinned fingerprint. No match → verification failure (the +/// connection is rejected). The fingerprint IS the trust anchor — there is no +/// CA verification and no name verification, only the fingerprint pin. +/// +/// Handshake signatures are still verified (using the aws-lc-rs default +/// signature verification algorithms) so that a stolen-but-stale fingerprint +/// can't be replayed with a forged signature: the presenter must prove +/// possession of the private key corresponding to the pinned public key. +#[cfg(feature = "quinn")] +struct FingerprintPinVerifier { + fingerprint: String, + supported: rustls::crypto::WebPkiSupportedAlgorithms, +} + +#[cfg(feature = "quinn")] +impl FingerprintPinVerifier { + fn new(fingerprint: String, supported: rustls::crypto::WebPkiSupportedAlgorithms) -> Self { + Self { + fingerprint, + supported, + } + } +} + +#[cfg(feature = "quinn")] +impl std::fmt::Debug for FingerprintPinVerifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FingerprintPinVerifier") + .field("fingerprint", &self.fingerprint) + .finish() + } +} + +#[cfg(feature = "quinn")] +impl rustls::client::danger::ServerCertVerifier for FingerprintPinVerifier { fn verify_server_cert( &self, - _end_entity: &rustls::pki_types::CertificateDer<'_>, + end_entity: &rustls::pki_types::CertificateDer<'_>, _intermediates: &[rustls::pki_types::CertificateDer<'_>], _server_name: &rustls::pki_types::ServerName<'_>, _ocsp_response: &[u8], _now: rustls::pki_types::UnixTime, ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) + let presented = alknet_core::fingerprint::fingerprint_from_cert_der(end_entity.as_ref()) + .ok_or(rustls::Error::General( + "fingerprint pin: failed to compute fingerprint from presented cert".to_string(), + ))?; + if presented == self.fingerprint { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } else { + Err(rustls::Error::General(format!( + "fingerprint pin mismatch: expected {} got {}", + self.fingerprint, presented + ))) + } } fn verify_tls12_signature( &self, - _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + if alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() { + let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec()); + rustls::crypto::verify_tls13_signature_with_raw_key( + message, + &spki, + dss, + &self.supported, + ) + } else { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported) + } } fn verify_tls13_signature( &self, - _message: &[u8], - _cert: &rustls::pki_types::CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + if alknet_core::fingerprint::extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() { + let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec()); + rustls::crypto::verify_tls13_signature_with_raw_key( + message, + &spki, + dss, + &self.supported, + ) + } else { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported) + } } fn supported_verify_schemes(&self) -> Vec { - vec![ - rustls::SignatureScheme::ED25519, - rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - rustls::SignatureScheme::ECDSA_NISTP384_SHA384, - rustls::SignatureScheme::RSA_PSS_SHA256, - rustls::SignatureScheme::RSA_PSS_SHA384, - rustls::SignatureScheme::RSA_PSS_SHA512, - rustls::SignatureScheme::RSA_PKCS1_SHA256, - rustls::SignatureScheme::RSA_PKCS1_SHA384, - rustls::SignatureScheme::RSA_PKCS1_SHA512, - ] + self.supported.supported_schemes() + } +} + +#[cfg(feature = "quinn")] +#[derive(Clone)] +struct Ed25519SigningKey { + key: alknet_core::config::Ed25519SecretKey, +} + +#[cfg(feature = "quinn")] +impl Ed25519SigningKey { + fn new(key: alknet_core::config::Ed25519SecretKey) -> Self { + Self { key } + } + + fn spki_public_key(&self) -> rustls::pki_types::SubjectPublicKeyInfoDer<'static> { + rustls::sign::public_key_to_spki( + &rustls::pki_types::alg_id::ED25519, + self.key.public().as_bytes(), + ) + } +} + +#[cfg(feature = "quinn")] +impl std::fmt::Debug for Ed25519SigningKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Ed25519SigningKey").finish() + } +} + +#[cfg(feature = "quinn")] +impl rustls::sign::SigningKey for Ed25519SigningKey { + fn choose_scheme( + &self, + offered: &[rustls::SignatureScheme], + ) -> Option> { + if offered.contains(&rustls::SignatureScheme::ED25519) { + Some(Box::new(self.clone())) + } else { + None + } + } + + fn algorithm(&self) -> rustls::SignatureAlgorithm { + rustls::SignatureAlgorithm::ED25519 + } + + fn public_key(&self) -> Option> { + Some(self.spki_public_key()) + } +} + +#[cfg(feature = "quinn")] +impl rustls::sign::Signer for Ed25519SigningKey { + fn sign(&self, message: &[u8]) -> Result, rustls::Error> { + Ok(self.key.sign(message).to_bytes().to_vec()) + } + + fn scheme(&self) -> rustls::SignatureScheme { + rustls::SignatureScheme::ED25519 } } @@ -415,4 +719,222 @@ mod tests { assert_send_sync::(); assert_send_sync::(); } + + #[cfg(feature = "quinn")] + fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec { + let spki = rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, raw_key); + spki.to_vec() + } + + #[cfg(feature = "quinn")] + fn build_x509_cert_der() -> rustls::pki_types::CertificateDer<'static> { + let key_pair = rcgen::KeyPair::generate().expect("key gen"); + let params = rcgen::CertificateParams::default(); + let cert = params.self_signed(&key_pair).expect("self-signed cert"); + cert.der().clone() + } + + #[cfg(feature = "quinn")] + fn aws_lc_rs_provider() -> Arc { + Arc::new(rustls::crypto::aws_lc_rs::default_provider()) + } + + #[cfg(feature = "quinn")] + fn verify_pin( + verifier: &FingerprintPinVerifier, + cert_der: rustls::pki_types::CertificateDer<'_>, + ) -> Result { + use rustls::client::danger::ServerCertVerifier; + let server_name: rustls::pki_types::ServerName<'static> = + "alknet".try_into().expect("server name"); + verifier.verify_server_cert( + &cert_der, + &[], + &server_name, + &[], + rustls::pki_types::UnixTime::now(), + ) + } + + #[cfg(feature = "quinn")] + #[test] + fn fingerprint_pin_verifier_matches_correct_ed25519_fingerprint() { + let sk = alknet_core::config::Ed25519SecretKey::generate(); + let raw_key = sk.public().to_bytes(); + let spki_der = build_ed25519_spki_der(&raw_key); + let fingerprint = + alknet_core::fingerprint::fingerprint_from_cert_der(&spki_der).expect("fingerprint"); + let verifier = FingerprintPinVerifier::new( + fingerprint, + aws_lc_rs_provider().signature_verification_algorithms, + ); + let cert = rustls::pki_types::CertificateDer::from(spki_der); + let result = verify_pin(&verifier, cert); + assert!( + result.is_ok(), + "FingerprintPinVerifier must accept a cert whose fingerprint matches the pin" + ); + } + + #[cfg(feature = "quinn")] + #[test] + fn fingerprint_pin_verifier_rejects_wrong_ed25519_fingerprint() { + let sk = alknet_core::config::Ed25519SecretKey::generate(); + let raw_key = sk.public().to_bytes(); + let spki_der = build_ed25519_spki_der(&raw_key); + let other_sk = alknet_core::config::Ed25519SecretKey::generate(); + let other_fp = format!("ed25519:{}", hex::encode(other_sk.public().to_bytes())); + let verifier = FingerprintPinVerifier::new( + other_fp, + aws_lc_rs_provider().signature_verification_algorithms, + ); + let cert = rustls::pki_types::CertificateDer::from(spki_der); + let result = verify_pin(&verifier, cert); + assert!( + result.is_err(), + "FingerprintPinVerifier must reject a cert whose fingerprint does not match the pin" + ); + } + + #[cfg(feature = "quinn")] + #[test] + fn fingerprint_pin_verifier_matches_correct_sha256_fingerprint() { + let cert_der = build_x509_cert_der(); + let fingerprint = alknet_core::fingerprint::fingerprint_from_cert_der(cert_der.as_ref()) + .expect("fingerprint"); + let verifier = FingerprintPinVerifier::new( + fingerprint, + aws_lc_rs_provider().signature_verification_algorithms, + ); + let result = verify_pin(&verifier, cert_der); + assert!( + result.is_ok(), + "FingerprintPinVerifier must accept an X.509 cert whose SHA256 fingerprint matches" + ); + } + + #[cfg(feature = "quinn")] + #[test] + fn fingerprint_pin_verifier_rejects_wrong_sha256_fingerprint() { + let cert_der = build_x509_cert_der(); + let verifier = FingerprintPinVerifier::new( + "SHA256:0000000000000000000000000000000000000000000000000000000000000000".to_string(), + aws_lc_rs_provider().signature_verification_algorithms, + ); + let result = verify_pin(&verifier, cert_der); + assert!( + result.is_err(), + "FingerprintPinVerifier must reject an X.509 cert whose SHA256 does not match" + ); + } + + #[cfg(feature = "quinn")] + #[test] + fn select_server_verifier_returns_ca_verifier_for_none() { + let provider = aws_lc_rs_provider(); + let remote_identity: Option = None; + let verifier = select_server_verifier(&provider, &remote_identity); + assert!( + verifier.is_ok(), + "select_server_verifier must succeed for None (CA path)" + ); + let debug = format!("{:?}", verifier.unwrap()); + assert!( + debug.contains("WebPkiServerVerifier"), + "None must select WebPkiServerVerifier (CA verification), got: {debug}" + ); + } + + #[cfg(feature = "quinn")] + #[test] + fn select_server_verifier_returns_fingerprint_pin_for_some() { + let provider = aws_lc_rs_provider(); + let remote_identity = Some(RemoteIdentity { + fingerprint: "ed25519:abc".to_string(), + }); + let verifier = select_server_verifier(&provider, &remote_identity); + assert!( + verifier.is_ok(), + "select_server_verifier must succeed for Some (fingerprint pin path)" + ); + let debug = format!("{:?}", verifier.unwrap()); + assert!( + debug.contains("FingerprintPinVerifier"), + "Some must select FingerprintPinVerifier, got: {debug}" + ); + } + + #[cfg(feature = "quinn")] + #[test] + fn build_client_auth_presents_ed25519_raw_key_without_error() { + let provider = aws_lc_rs_provider(); + let sk = alknet_core::config::Ed25519SecretKey::generate(); + let tls_identity = Some(alknet_core::config::TlsIdentity::RawKey(sk)); + let resolver = build_client_auth(&provider, &tls_identity); + assert!( + resolver.is_ok(), + "build_client_auth must build a resolver for a RawKey identity" + ); + let resolver = resolver.unwrap(); + assert!( + resolver.only_raw_public_keys(), + "RawKey client auth resolver must present raw public keys (RFC 7250)" + ); + assert!( + resolver.has_certs(), + "RawKey client auth resolver must report it has a cert to present" + ); + } + + #[cfg(feature = "quinn")] + #[test] + fn build_client_auth_none_resolves_to_no_client_cert() { + let provider = aws_lc_rs_provider(); + let tls_identity: Option = None; + let resolver = build_client_auth(&provider, &tls_identity) + .expect("build_client_auth must succeed for None"); + assert!( + !resolver.has_certs(), + "NoClientCertResolver must report no certs (no client cert presented)" + ); + } + + #[cfg(feature = "quinn")] + #[test] + fn build_quinn_client_config_with_raw_key_identity_builds_without_error() { + let sk = alknet_core::config::Ed25519SecretKey::generate(); + let credentials = CallCredentials::new() + .with_tls_identity(alknet_core::config::TlsIdentity::RawKey(sk)) + .with_remote_identity(RemoteIdentity { + fingerprint: "ed25519:deadbeef".to_string(), + }); + let config = build_quinn_client_config(&credentials, b"alknet/call"); + assert!( + config.is_ok(), + "build_quinn_client_config must build with a RawKey identity + pinned fingerprint" + ); + } + + #[cfg(feature = "quinn")] + #[test] + fn build_quinn_client_config_with_no_remote_identity_builds_without_error() { + let sk = alknet_core::config::Ed25519SecretKey::generate(); + let credentials = + CallCredentials::new().with_tls_identity(alknet_core::config::TlsIdentity::RawKey(sk)); + let config = build_quinn_client_config(&credentials, b"alknet/call"); + assert!( + config.is_ok(), + "build_quinn_client_config must build for the None + CA-verification path" + ); + } + + #[test] + fn remote_identity_none_is_load_bearing_not_defaulted() { + let creds = CallCredentials::new(); + assert!( + creds.remote_identity.is_none(), + "CallCredentials::new() must keep remote_identity as None (the load-bearing \ + public-X.509-endpoint state), not default it to a placeholder" + ); + } } diff --git a/crates/alknet-call/tests/two_node_call.rs b/crates/alknet-call/tests/two_node_call.rs index 1af99f5..1d92b79 100644 --- a/crates/alknet-call/tests/two_node_call.rs +++ b/crates/alknet-call/tests/two_node_call.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::time::Duration; -use alknet_call::client::{CallClient, CallCredentials}; +use alknet_call::client::{CallClient, CallCredentials, RemoteIdentity}; use alknet_call::protocol::adapter::CallAdapter; use alknet_call::protocol::wire::ResponseEnvelope; use alknet_call::registry::discovery::{ @@ -49,11 +49,14 @@ fn echo_handler() -> Handler { /// Build a raw quinn server endpoint with a self-signed cert and the /// `CallAdapter` accepting `alknet/call` connections. Returns -/// `(bound_addr, join_handle)`. The accept loop spawns a task per connection -/// that hands the connection to `CallAdapter::handle`. +/// `(bound_addr, server_fingerprint, join_handle)` — the fingerprint is the +/// `SHA256:` of the self-signed cert DER, which the client pins via +/// `CallCredentials::with_remote_identity` (the known-peer path, ADR-034 §3). +/// The accept loop spawns a task per connection that hands the connection to +/// `CallAdapter::handle`. async fn build_raw_quinn_server( registry: Arc, -) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { +) -> (std::net::SocketAddr, String, tokio::task::JoinHandle<()>) { let provider: Arc = Arc::new(NoopIdentityProvider); let adapter = Arc::new(CallAdapter::new( Arc::clone(®istry), @@ -64,6 +67,8 @@ async fn build_raw_quinn_server( let params = rcgen::CertificateParams::default(); let cert = params.self_signed(&key_pair).expect("self-signed cert"); let cert_der = cert.der().clone(); + let fingerprint = alknet_core::fingerprint::fingerprint_from_cert_der(cert_der.as_ref()) + .expect("cert produces fingerprint"); let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8( rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()), ); @@ -112,7 +117,7 @@ async fn build_raw_quinn_server( } }); - (bound_addr, join) + (bound_addr, fingerprint, join) } /// Build the server's registry: an echo op, a secret op, and the @@ -177,10 +182,14 @@ fn build_server_registry() -> Arc { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn two_node_call_round_trip() { let server_registry = build_server_registry(); - let (server_addr, _server_join) = build_raw_quinn_server(Arc::clone(&server_registry)).await; + let (server_addr, server_fingerprint, _server_join) = + build_raw_quinn_server(Arc::clone(&server_registry)).await; // Client side: a CallClient with its own ops so the server can call back - // (connection symmetry). + // (connection symmetry). Pin the server's self-signed cert fingerprint + // (the known-peer path, ADR-034 §3) — `WebPkiServerVerifier` would reject + // it as UnknownIssuer since the self-signed cert is not in the platform + // root store. let mut client_registry = OperationRegistry::new(); client_registry.register(HandlerRegistration::new( external_spec("client/echo"), @@ -193,9 +202,12 @@ async fn two_node_call_round_trip() { let client_registry = Arc::new(client_registry); let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider)); + let credentials = CallCredentials::new().with_remote_identity(RemoteIdentity { + fingerprint: server_fingerprint, + }); let conn = tokio::time::timeout( Duration::from_secs(5), - client.connect(server_addr, CallCredentials::new()), + client.connect(server_addr, credentials), ) .await .expect("connect did not time out") @@ -224,15 +236,21 @@ async fn from_call_discovers_and_forwards_over_quic_loopback() { use alknet_call::registry::context::ScopedOperationEnv; let server_registry = build_server_registry(); - let (server_addr, _server_join) = build_raw_quinn_server(Arc::clone(&server_registry)).await; + let (server_addr, server_fingerprint, _server_join) = + build_raw_quinn_server(Arc::clone(&server_registry)).await; // Client with an empty registry — from_call will populate its overlay. + // Pin the server's self-signed cert fingerprint (ADR-034 §3 known-peer + // path). let client_registry = Arc::new(OperationRegistry::new()); let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider)); + let credentials = CallCredentials::new().with_remote_identity(RemoteIdentity { + fingerprint: server_fingerprint, + }); let conn = tokio::time::timeout( Duration::from_secs(5), - client.connect(server_addr, CallCredentials::new()), + client.connect(server_addr, credentials), ) .await .expect("connect did not time out") diff --git a/crates/alknet-core/src/endpoint.rs b/crates/alknet-core/src/endpoint.rs index c347563..19db571 100644 --- a/crates/alknet-core/src/endpoint.rs +++ b/crates/alknet-core/src/endpoint.rs @@ -384,127 +384,7 @@ fn extract_quinn_client_fingerprint(connection: &quinn::Connection) -> Option>() .ok()?; let leaf = certs.first()?; - fingerprint_from_cert_der(leaf.as_ref()) -} - -#[cfg(any(feature = "quinn", feature = "iroh"))] -fn fingerprint_from_cert_der(cert_der: &[u8]) -> 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); - let digest = hasher.finalize(); - 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 - } - } + crate::fingerprint::fingerprint_from_cert_der(leaf.as_ref()) } #[cfg(feature = "iroh")] @@ -1372,138 +1252,6 @@ mod tests { assert!(unknown.is_none(), "unknown ALPN has no handler"); } - #[cfg(any(feature = "quinn", feature = "iroh"))] - #[test] - fn fingerprint_from_cert_der_produces_sha256_hex_format() { - let cert_der = b"fake-leaf-cert-der-bytes"; - let fp = fingerprint_from_cert_der(cert_der).expect("non-empty cert produces fingerprint"); - assert!( - fp.starts_with("SHA256:"), - "fingerprint must be SHA256-prefixed, got: {fp}" - ); - let hex_part = &fp["SHA256:".len()..]; - assert_eq!( - hex_part.len(), - 64, - "hex digest must be 64 chars (32 bytes), got: {fp}" - ); - assert!( - hex_part.chars().all(|c| c.is_ascii_hexdigit()), - "hex part must be lowercase hex, got: {fp}" - ); - - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(cert_der); - let expected = format!("SHA256:{}", hex::encode(hasher.finalize())); - assert_eq!(fp, expected, "fingerprint must match SHA-256 of cert DER"); - } - - #[cfg(any(feature = "quinn", feature = "iroh"))] - #[test] - fn fingerprint_from_cert_der_deterministic() { - let cert = b"some-cert"; - let a = fingerprint_from_cert_der(cert).unwrap(); - let b = fingerprint_from_cert_der(cert).unwrap(); - 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-core/src/fingerprint.rs b/crates/alknet-core/src/fingerprint.rs new file mode 100644 index 0000000..8d6db26 --- /dev/null +++ b/crates/alknet-core/src/fingerprint.rs @@ -0,0 +1,264 @@ +//! TLS certificate fingerprint extraction (ADR-030 §6, ADR-034 §3). +//! +//! Fingerprint formats: +//! - **Ed25519 raw key** (RFC 7250 SPKI): `ed25519:`. +//! The fingerprint IS the trust anchor — raw-key remotes have no CA, so the +//! fingerprint is the identity (ADR-034 §2 assumption 1). Normalized to +//! `ed25519:` across quinn and iroh (ADR-030 §6). +//! - **X.509 cert**: `SHA256:`. Used by the hub X.509 path +//! (ADR-034 §3 — fingerprint pinning for known hubs with a prior P2P trust +//! relationship). Not used for arbitrary public APIs (those use CA +//! verification via `WebPkiServerVerifier`, not fingerprint pinning). +//! +//! Shared by the server-side endpoint (`alknet_core::endpoint`, which extracts +//! the fingerprint from the presented client cert for `PeerEntry` resolution) +//! and the client-side `FingerprintPinVerifier` in `alknet_call::client` +//! (which matches the server's presented cert against a pinned fingerprint). + +use sha2::{Digest, Sha256}; + +/// Compute the fingerprint of a TLS certificate DER (RFC 7250 raw public key +/// SPKI or X.509 cert). Returns `ed25519:` when `cert_der` is an Ed25519 +/// SPKI, otherwise `SHA256:`. Returns `None` only when the +/// input is empty (a non-Ed25519 SPKI or a malformed DER still hashes to a +/// `SHA256:` fingerprint — the hash is the fallback). +pub fn fingerprint_from_cert_der(cert_der: &[u8]) -> Option { + if let Some(raw_key) = extract_ed25519_raw_key_from_spki(cert_der) { + return Some(format!("ed25519:{}", hex::encode(raw_key))); + } + let mut hasher = Sha256::new(); + hasher.update(cert_der); + let digest = hasher.finalize(); + 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. +pub 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) +} + +struct DerParser<'a> { + bytes: &'a [u8], +} + +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(test)] +mod tests { + use super::*; + + fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec { + let spki = rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, raw_key); + spki.to_vec() + } + + #[test] + fn fingerprint_from_cert_der_produces_sha256_hex_format() { + let cert_der = b"fake-leaf-cert-der-bytes"; + let fp = fingerprint_from_cert_der(cert_der).expect("non-empty cert produces fingerprint"); + assert!( + fp.starts_with("SHA256:"), + "fingerprint must be SHA256-prefixed, got: {fp}" + ); + let hex_part = &fp["SHA256:".len()..]; + assert_eq!( + hex_part.len(), + 64, + "hex digest must be 64 chars (32 bytes), got: {fp}" + ); + assert!( + hex_part.chars().all(|c| c.is_ascii_hexdigit()), + "hex part must be lowercase hex, got: {fp}" + ); + let mut hasher = Sha256::new(); + hasher.update(cert_der); + let expected = format!("SHA256:{}", hex::encode(hasher.finalize())); + assert_eq!(fp, expected, "fingerprint must match SHA-256 of cert DER"); + } + + #[test] + fn fingerprint_from_cert_der_deterministic() { + let cert = b"some-cert"; + let a = fingerprint_from_cert_der(cert).unwrap(); + let b = fingerprint_from_cert_der(cert).unwrap(); + assert_eq!(a, b, "same cert DER must produce same fingerprint"); + } + + #[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}" + ); + } + + #[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" + ); + } + + #[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" + ); + } + + #[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}" + ); + 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" + ); + } + + #[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}" + ); + } +} diff --git a/crates/alknet-core/src/lib.rs b/crates/alknet-core/src/lib.rs index 46a3515..77054d5 100644 --- a/crates/alknet-core/src/lib.rs +++ b/crates/alknet-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod auth; pub mod config; pub mod endpoint; +pub mod fingerprint; pub mod store; pub mod types;