Compare commits
2 Commits
d9227b8123
...
1af81346d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1af81346d1 | |||
| c106f4a37b |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -54,11 +54,13 @@ dependencies = [
|
|||||||
"alknet-core",
|
"alknet-core",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hex",
|
||||||
"irpc",
|
"irpc",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rcgen 0.13.2",
|
"rcgen 0.13.2",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ name = "alknet_call"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["quinn"]
|
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]
|
[dependencies]
|
||||||
alknet-core = { path = "../alknet-core" }
|
alknet-core = { path = "../alknet-core" }
|
||||||
@@ -26,8 +26,10 @@ uuid = { version = "1", features = ["v4"] }
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
quinn = { version = "0.11", optional = true }
|
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]
|
[dev-dependencies]
|
||||||
rcgen = "0.13"
|
rcgen = "0.13"
|
||||||
rustls-pemfile = "2"
|
hex = "0.4"
|
||||||
@@ -25,12 +25,23 @@ use crate::protocol::connection::CallConnection;
|
|||||||
use crate::protocol::dispatch::Dispatcher;
|
use crate::protocol::dispatch::Dispatcher;
|
||||||
use crate::registry::registration::OperationRegistry;
|
use crate::registry::registration::OperationRegistry;
|
||||||
|
|
||||||
/// Expected identity of the remote node (ADR-017 §7). The concrete shape is
|
/// Expected identity of the remote node (ADR-017 §7, extended by ADR-034 §2).
|
||||||
/// an implementation-detail two-way door; v1 carries a fingerprint string the
|
/// Carries a fingerprint string the assembly layer derives from `Capabilities`
|
||||||
/// assembly layer derives from `Capabilities` (ADR-014). Verification is the
|
/// when the local node has a `PeerEntry` for the remote (the known-peer case →
|
||||||
/// assembly layer's trust decision — `CallClient` surfaces the expected value
|
/// fingerprint pin).
|
||||||
/// 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).
|
/// `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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RemoteIdentity {
|
pub struct RemoteIdentity {
|
||||||
pub fingerprint: String,
|
pub fingerprint: String,
|
||||||
@@ -48,6 +59,10 @@ pub struct CallCredentials {
|
|||||||
/// Opaque call-protocol-level auth token, decrypted from the vault.
|
/// Opaque call-protocol-level auth token, decrypted from the vault.
|
||||||
pub auth_token: Option<alknet_core::auth::AuthToken>,
|
pub auth_token: Option<alknet_core::auth::AuthToken>,
|
||||||
/// Expected fingerprint/cert of the remote node, stored as a capability.
|
/// 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<RemoteIdentity>,
|
pub remote_identity: Option<RemoteIdentity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,31 +188,20 @@ impl CallClient {
|
|||||||
|
|
||||||
#[cfg(feature = "quinn")]
|
#[cfg(feature = "quinn")]
|
||||||
fn build_quinn_client_config(
|
fn build_quinn_client_config(
|
||||||
_credentials: &CallCredentials,
|
credentials: &CallCredentials,
|
||||||
alpn: &[u8],
|
alpn: &[u8],
|
||||||
) -> Result<quinn::ClientConfig, String> {
|
) -> Result<quinn::ClientConfig, String> {
|
||||||
// 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 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)
|
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
||||||
.with_safe_default_protocol_versions()
|
.with_safe_default_protocol_versions()
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.dangerous()
|
.dangerous()
|
||||||
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
|
.with_custom_certificate_verifier(verifier)
|
||||||
.with_no_client_auth();
|
.with_client_cert_resolver(client_auth);
|
||||||
config.alpn_protocols = vec![alpn.to_vec()];
|
config.alpn_protocols = vec![alpn.to_vec()];
|
||||||
config.enable_early_data = true;
|
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")]
|
#[cfg(feature = "quinn")]
|
||||||
struct AcceptAnyServerCertVerifier;
|
fn build_client_auth(
|
||||||
|
provider: &Arc<rustls::crypto::CryptoProvider>,
|
||||||
|
tls_identity: &Option<TlsIdentity>,
|
||||||
|
) -> Result<Arc<dyn rustls::client::ResolvesClientCert>, 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<rustls::crypto::CryptoProvider>,
|
||||||
|
remote_identity: &Option<RemoteIdentity>,
|
||||||
|
) -> Result<Arc<dyn rustls::client::danger::ServerCertVerifier>, 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<rustls::RootCertStore, String> {
|
||||||
|
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")]
|
#[cfg(feature = "quinn")]
|
||||||
impl std::fmt::Debug for AcceptAnyServerCertVerifier {
|
fn load_cert_chain(
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
path: &std::path::Path,
|
||||||
f.debug_struct("AcceptAnyServerCertVerifier").finish()
|
) -> Result<Vec<rustls::pki_types::CertificateDer<'static>>, 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::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
fn load_private_key(
|
||||||
|
path: &std::path::Path,
|
||||||
|
) -> Result<rustls::pki_types::PrivateKeyDer<'static>, 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<rustls::sign::CertifiedKey>,
|
||||||
|
raw_public_keys: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
impl RawKeyClientCertResolver {
|
||||||
|
fn new(key: Arc<rustls::sign::CertifiedKey>) -> Self {
|
||||||
|
let raw_public_keys = key.cert.len() == 1 && is_ed25519_spki(&key.cert[0]);
|
||||||
|
Self {
|
||||||
|
key,
|
||||||
|
raw_public_keys,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "quinn")]
|
#[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<Arc<rustls::sign::CertifiedKey>> {
|
||||||
|
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<Arc<rustls::sign::CertifiedKey>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_certs(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ServerCertVerifier` that pins a specific fingerprint (ADR-034 §3, the
|
||||||
|
/// known-peer path). For `ed25519:<hex>` remotes the raw Ed25519 pub key is
|
||||||
|
/// extracted from the presented cert and matched against the pinned
|
||||||
|
/// fingerprint; for `SHA256:<hex>` 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(
|
fn verify_server_cert(
|
||||||
&self,
|
&self,
|
||||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
_ocsp_response: &[u8],
|
_ocsp_response: &[u8],
|
||||||
_now: rustls::pki_types::UnixTime,
|
_now: rustls::pki_types::UnixTime,
|
||||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
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())
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
} else {
|
||||||
|
Err(rustls::Error::General(format!(
|
||||||
|
"fingerprint pin mismatch: expected {} got {}",
|
||||||
|
self.fingerprint, presented
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_tls12_signature(
|
fn verify_tls12_signature(
|
||||||
&self,
|
&self,
|
||||||
_message: &[u8],
|
message: &[u8],
|
||||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
_dss: &rustls::DigitallySignedStruct,
|
dss: &rustls::DigitallySignedStruct,
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
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(
|
fn verify_tls13_signature(
|
||||||
&self,
|
&self,
|
||||||
_message: &[u8],
|
message: &[u8],
|
||||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
_dss: &rustls::DigitallySignedStruct,
|
dss: &rustls::DigitallySignedStruct,
|
||||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
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<rustls::SignatureScheme> {
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
vec![
|
self.supported.supported_schemes()
|
||||||
rustls::SignatureScheme::ED25519,
|
}
|
||||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
}
|
||||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
#[cfg(feature = "quinn")]
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
#[derive(Clone)]
|
||||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
struct Ed25519SigningKey {
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
key: alknet_core::config::Ed25519SecretKey,
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
}
|
||||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
|
||||||
]
|
#[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<Box<dyn rustls::sign::Signer>> {
|
||||||
|
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<rustls::pki_types::SubjectPublicKeyInfoDer<'_>> {
|
||||||
|
Some(self.spki_public_key())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
impl rustls::sign::Signer for Ed25519SigningKey {
|
||||||
|
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, 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::<CallCredentials>();
|
assert_send_sync::<CallCredentials>();
|
||||||
assert_send_sync::<RemoteIdentity>();
|
assert_send_sync::<RemoteIdentity>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
fn build_ed25519_spki_der(raw_key: &[u8; 32]) -> Vec<u8> {
|
||||||
|
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<rustls::crypto::CryptoProvider> {
|
||||||
|
Arc::new(rustls::crypto::aws_lc_rs::default_provider())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "quinn")]
|
||||||
|
fn verify_pin(
|
||||||
|
verifier: &FingerprintPinVerifier,
|
||||||
|
cert_der: rustls::pki_types::CertificateDer<'_>,
|
||||||
|
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
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<RemoteIdentity> = 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<alknet_core::config::TlsIdentity> = 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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::adapter::CallAdapter;
|
||||||
use alknet_call::protocol::wire::ResponseEnvelope;
|
use alknet_call::protocol::wire::ResponseEnvelope;
|
||||||
use alknet_call::registry::discovery::{
|
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
|
/// Build a raw quinn server endpoint with a self-signed cert and the
|
||||||
/// `CallAdapter` accepting `alknet/call` connections. Returns
|
/// `CallAdapter` accepting `alknet/call` connections. Returns
|
||||||
/// `(bound_addr, join_handle)`. The accept loop spawns a task per connection
|
/// `(bound_addr, server_fingerprint, join_handle)` — the fingerprint is the
|
||||||
/// that hands the connection to `CallAdapter::handle`.
|
/// `SHA256:<hex>` 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(
|
async fn build_raw_quinn_server(
|
||||||
registry: Arc<OperationRegistry>,
|
registry: Arc<OperationRegistry>,
|
||||||
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
|
) -> (std::net::SocketAddr, String, tokio::task::JoinHandle<()>) {
|
||||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdentityProvider);
|
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdentityProvider);
|
||||||
let adapter = Arc::new(CallAdapter::new(
|
let adapter = Arc::new(CallAdapter::new(
|
||||||
Arc::clone(®istry),
|
Arc::clone(®istry),
|
||||||
@@ -64,6 +67,8 @@ async fn build_raw_quinn_server(
|
|||||||
let params = rcgen::CertificateParams::default();
|
let params = rcgen::CertificateParams::default();
|
||||||
let cert = params.self_signed(&key_pair).expect("self-signed cert");
|
let cert = params.self_signed(&key_pair).expect("self-signed cert");
|
||||||
let cert_der = cert.der().clone();
|
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(
|
let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(
|
||||||
rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der()),
|
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
|
/// Build the server's registry: an echo op, a secret op, and the
|
||||||
@@ -177,10 +182,14 @@ fn build_server_registry() -> Arc<OperationRegistry> {
|
|||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn two_node_call_round_trip() {
|
async fn two_node_call_round_trip() {
|
||||||
let server_registry = build_server_registry();
|
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
|
// 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();
|
let mut client_registry = OperationRegistry::new();
|
||||||
client_registry.register(HandlerRegistration::new(
|
client_registry.register(HandlerRegistration::new(
|
||||||
external_spec("client/echo"),
|
external_spec("client/echo"),
|
||||||
@@ -193,9 +202,12 @@ async fn two_node_call_round_trip() {
|
|||||||
let client_registry = Arc::new(client_registry);
|
let client_registry = Arc::new(client_registry);
|
||||||
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
|
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(
|
let conn = tokio::time::timeout(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
client.connect(server_addr, CallCredentials::new()),
|
client.connect(server_addr, credentials),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("connect did not time out")
|
.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;
|
use alknet_call::registry::context::ScopedOperationEnv;
|
||||||
|
|
||||||
let server_registry = build_server_registry();
|
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.
|
// 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_registry = Arc::new(OperationRegistry::new());
|
||||||
let client = CallClient::new(Arc::clone(&client_registry), Arc::new(NoopIdentityProvider));
|
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(
|
let conn = tokio::time::timeout(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
client.connect(server_addr, CallCredentials::new()),
|
client.connect(server_addr, credentials),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("connect did not time out")
|
.expect("connect did not time out")
|
||||||
|
|||||||
@@ -384,127 +384,7 @@ fn extract_quinn_client_fingerprint(connection: &quinn::Connection) -> Option<St
|
|||||||
.downcast::<Vec<rustls::pki_types::CertificateDer>>()
|
.downcast::<Vec<rustls::pki_types::CertificateDer>>()
|
||||||
.ok()?;
|
.ok()?;
|
||||||
let leaf = certs.first()?;
|
let leaf = certs.first()?;
|
||||||
fingerprint_from_cert_der(leaf.as_ref())
|
crate::fingerprint::fingerprint_from_cert_der(leaf.as_ref())
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(feature = "quinn", feature = "iroh"))]
|
|
||||||
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};
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "iroh")]
|
#[cfg(feature = "iroh")]
|
||||||
@@ -1372,138 +1252,6 @@ mod tests {
|
|||||||
assert!(unknown.is_none(), "unknown ALPN has no handler");
|
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<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;
|
||||||
|
|||||||
264
crates/alknet-core/src/fingerprint.rs
Normal file
264
crates/alknet-core/src/fingerprint.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
//! TLS certificate fingerprint extraction (ADR-030 §6, ADR-034 §3).
|
||||||
|
//!
|
||||||
|
//! Fingerprint formats:
|
||||||
|
//! - **Ed25519 raw key** (RFC 7250 SPKI): `ed25519:<hex of 32-byte pub key>`.
|
||||||
|
//! 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:<hex>` across quinn and iroh (ADR-030 §6).
|
||||||
|
//! - **X.509 cert**: `SHA256:<hex of DER>`. 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:<hex>` when `cert_der` is an Ed25519
|
||||||
|
/// SPKI, otherwise `SHA256:<hex of full DER>`. 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<String> {
|
||||||
|
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<u8> {
|
||||||
|
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<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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod endpoint;
|
pub mod endpoint;
|
||||||
|
pub mod fingerprint;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: call/call-client-verifier-selection
|
id: call/call-client-verifier-selection
|
||||||
name: Wire CallClient TLS client-auth and server cert verifier selection by PeerEntry presence (OQ-29, ADR-034)
|
name: Wire CallClient TLS client-auth and server cert verifier selection by PeerEntry presence (OQ-29, ADR-034)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [call/peer-composite-env]
|
depends_on: [call/peer-composite-env]
|
||||||
scope: moderate
|
scope: moderate
|
||||||
risk: high
|
risk: high
|
||||||
@@ -174,4 +174,4 @@ pub struct RemoteIdentity { pub fingerprint: String }
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
Wired CallClient TLS client-auth (presents Ed25519 key as RFC 7250 raw public key client cert, replacing with_no_client_auth) and server cert verifier selection by PeerEntry presence (ADR-034 §3). Replaced the AcceptAnyServerCertVerifier security hole with: Some(fingerprint) -> FingerprintPinVerifier (ed25519:<hex> raw key extraction + SHA256:<hex> DER hash, with handshake signature verification); None -> WebPkiServerVerifier (CA verification for public X.509 endpoints; Ed25519 raw-key remotes fail closed). Extracted shared fingerprint logic into a new pub alknet_core::fingerprint module (reused by endpoint server-side and call_client client-side). remote_identity: None kept load-bearing (not defaulted). 11 unit tests + updated 2 integration tests to pin the self-signed server fingerprint. 135 core tests + 252 call unit tests + 2 integration tests pass, clippy clean, fmt clean.
|
||||||
Reference in New Issue
Block a user