Close review 001 §S-1: the default client-cert verifier never checked the client's CertificateVerify, so anyone holding a peer's *public* cert/SPKI bytes (public by design — peers publish them to be dialable) could complete a handshake as that peer, and the auth layer could not detect it. The consumer designs are known (X.509 and raw-key TCP/QUIC endpoints with identity-bearing clients), so implementing now — the zero-consumer moment — avoids the guaranteed breaking republish of flipping the default later. - VerifyPresentedCertVerifier (new): request, don't require, verify possession — permissive verify_client_cert (self-signed chains and bare SPKIs stay valid presentation) + CertificateVerify routing by presented cert kind (Ed25519 SPKI -> verify_tls13_signature_with_ raw_key both TLS versions; X.509 -> standard route), the same routing FingerprintPinVerifier implements. Nine-scheme list verbatim (shared fn, exact-list pin covers both). - Default on every TlsServerConfig path — X509 / RawKey / SelfSigned / ACME (the verifier install is crate-side rustls in new_acme, not rustls-acme's). - AcceptAnyCertVerifier stays public as the explicit no-pop escape hatch, no longer installed by any crate path. - tests/impersonation_posture.rs: four pins — default rejects the attacker (X.509: UnsupportedSignatureAlgorithmForPublicKeyContext; raw-key: BadSignature), escape hatch still accepts + extracts the victim's fingerprint (both cert types). - tests/handshake_behavior.rs: suites 4/4b — possession-checked legit clients (raw-key pin vs raw-key server; X.509 client vs X.509 server) complete and the server extracts the fingerprint; suite 3b doc updated. - Docs: ADR-008 written; OQ-TLS-09 -> resolved (option (b)); ADR-007 §Limits deferral retired to not-planned; server.md/client.md invariants/README/overview synced. Verification: cargo test 81 / --features tcp 95 / --all-features 104 green; clippy -D warnings clean (default + all-features); fmt clean; cargo doc warning-free.
325 lines
12 KiB
Rust
325 lines
12 KiB
Rust
//! S-1 posture pins (review 001 §S-1, resolved by ADR-008) — both
|
|
//! cert-type verifiers, both cert types:
|
|
//!
|
|
//! 1. **Default** (`VerifyPresentedCertVerifier`, ADR-008): a handshake
|
|
//! presenting the victim's public cert bytes (X.509) or SPKI
|
|
//! (RFC 7250) under an attacker-owned signer **fails** the
|
|
//! CertificateVerify possession check — the attacker cannot
|
|
//! complete the handshake as the victim.
|
|
//! 2. **Escape hatch** (`AcceptAnyCertVerifier`, explicitly
|
|
//! installed): the same handshake completes and the server extracts
|
|
//! the victim's fingerprint — the documented no-pop posture of the
|
|
//! escape hatch, pinned so it cannot silently change either
|
|
//! direction.
|
|
//!
|
|
//! Gated on `tcp` (tokio-rustls provides the duplex-driven handshake;
|
|
//! no external transport is involved).
|
|
|
|
#![cfg(feature = "tcp")]
|
|
|
|
use std::sync::Arc;
|
|
|
|
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
|
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
|
use rustls::DigitallySignedStruct;
|
|
use tokio::io::duplex;
|
|
|
|
use alktls::fingerprint_from_cert_der;
|
|
use alktls::{Ed25519SecretKey, TlsServerConfig};
|
|
|
|
const ALPN: &[u8] = b"alk/impersonation";
|
|
|
|
/// The attacker client: presents `presented_cert` (the victim's public
|
|
/// bytes) with the attacker's own Ed25519 signer, and accepts any server
|
|
/// cert (the probe stays independent of the crate's pin path).
|
|
fn attacker_client_config(
|
|
presented_cert: CertificateDer<'static>,
|
|
attacker_signer: &Ed25519SecretKey,
|
|
) -> rustls::ClientConfig {
|
|
struct AcceptAnyServerCertVerifier;
|
|
|
|
impl std::fmt::Debug for AcceptAnyServerCertVerifier {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("AcceptAnyServerCertVerifier").finish()
|
|
}
|
|
}
|
|
|
|
impl ServerCertVerifier for AcceptAnyServerCertVerifier {
|
|
fn verify_server_cert(
|
|
&self,
|
|
_end_entity: &CertificateDer<'_>,
|
|
_intermediates: &[CertificateDer<'_>],
|
|
_server_name: &ServerName<'_>,
|
|
_ocsp_response: &[u8],
|
|
_now: UnixTime,
|
|
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
|
}
|
|
|
|
fn verify_tls12_signature(
|
|
&self,
|
|
_message: &[u8],
|
|
_cert: &CertificateDer<'_>,
|
|
_dss: &DigitallySignedStruct,
|
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
|
Ok(HandshakeSignatureValid::assertion())
|
|
}
|
|
|
|
fn verify_tls13_signature(
|
|
&self,
|
|
_message: &[u8],
|
|
_cert: &CertificateDer<'_>,
|
|
_dss: &DigitallySignedStruct,
|
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
|
Ok(HandshakeSignatureValid::assertion())
|
|
}
|
|
|
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
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,
|
|
]
|
|
}
|
|
}
|
|
|
|
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
|
let signing_key = Arc::new(alktls::Ed25519SigningKey::new(attacker_signer.clone()));
|
|
let certified_key = Arc::new(rustls::sign::CertifiedKey::new(
|
|
vec![presented_cert],
|
|
signing_key,
|
|
));
|
|
|
|
struct FixedResolver(Arc<rustls::sign::CertifiedKey>);
|
|
|
|
impl rustls::client::ResolvesClientCert for FixedResolver {
|
|
fn resolve(
|
|
&self,
|
|
_root_hint_subjects: &[&[u8]],
|
|
_sigschemes: &[rustls::SignatureScheme],
|
|
) -> Option<Arc<rustls::sign::CertifiedKey>> {
|
|
Some(Arc::clone(&self.0))
|
|
}
|
|
|
|
fn only_raw_public_keys(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn has_certs(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for FixedResolver {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("FixedResolver").finish()
|
|
}
|
|
}
|
|
|
|
let mut config = rustls::ClientConfig::builder_with_provider(provider)
|
|
.with_safe_default_protocol_versions()
|
|
.expect("protocol versions")
|
|
.dangerous()
|
|
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
|
|
.with_client_cert_resolver(Arc::new(FixedResolver(certified_key)));
|
|
config.alpn_protocols = vec![ALPN.to_vec()];
|
|
config
|
|
}
|
|
|
|
/// Drive the attacker handshake against `acceptor`. Returns
|
|
/// `Ok(Option<fingerprint>)` when the handshake completed (the server
|
|
/// side's extracted fingerprint), `Err(message)` when either side
|
|
/// aborted.
|
|
async fn run_impersonation(
|
|
acceptor: tokio_rustls::TlsAcceptor,
|
|
presented_cert: CertificateDer<'static>,
|
|
attacker_signer: &Ed25519SecretKey,
|
|
) -> Result<Option<String>, String> {
|
|
let client_config = attacker_client_config(presented_cert, attacker_signer);
|
|
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
|
|
|
|
let (client_io, server_io) = duplex(64 * 1024);
|
|
|
|
let server_name = ServerName::try_from("impersonation.test".to_string())
|
|
.expect("dns name")
|
|
.to_owned();
|
|
|
|
let (client, server) = tokio::join!(
|
|
connector.connect(server_name, client_io),
|
|
acceptor.accept(server_io),
|
|
);
|
|
|
|
let mut client_stream = client.map_err(|e| format!("client: {e}"))?;
|
|
let mut server_stream = server.map_err(|e| format!("server: {e}"))?;
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
client_stream
|
|
.write_all(b"spoof")
|
|
.await
|
|
.map_err(|e| format!("client write: {e}"))?;
|
|
|
|
let mut buf = [0u8; 5];
|
|
server_stream
|
|
.read_exact(&mut buf)
|
|
.await
|
|
.map_err(|e| format!("server read: {e}"))?;
|
|
|
|
let (_, server_conn) = server_stream.get_ref();
|
|
Ok(server_conn
|
|
.peer_certificates()
|
|
.and_then(|certs| certs.first().map(|c| fingerprint_from_cert_der(c.as_ref())))
|
|
.flatten())
|
|
}
|
|
|
|
async fn default_acceptor() -> tokio_rustls::TlsAcceptor {
|
|
TlsServerConfig::new(&alktls::TlsIdentity::SelfSigned, &[ALPN.to_vec()])
|
|
.await
|
|
.expect("server config must construct")
|
|
.for_tcp_tls()
|
|
}
|
|
|
|
/// An explicit escape-hatch server: the crate's SelfSigned identity with
|
|
/// `AcceptAnyCertVerifier` installed in place of the default (the
|
|
/// documented no-pop opt-out, built through the public config builder).
|
|
fn escape_hatch_acceptor() -> tokio_rustls::TlsAcceptor {
|
|
let cert = alktls::generate_self_signed_cert().expect("self-signed cert generates");
|
|
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
|
let mut config = rustls::ServerConfig::builder_with_provider(provider)
|
|
.with_safe_default_protocol_versions()
|
|
.expect("protocol versions")
|
|
.with_client_cert_verifier(Arc::new(alktls::AcceptAnyCertVerifier))
|
|
.with_single_cert(cert.cert_chain, cert.private_key)
|
|
.expect("self-signed server cert installs");
|
|
config.alpn_protocols = vec![ALPN.to_vec()];
|
|
tokio_rustls::TlsAcceptor::from(Arc::new(config))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_verifier_rejects_x509_victim_cert_with_attacker_key() {
|
|
let victim_key_pair = rcgen::KeyPair::generate().expect("victim key gen");
|
|
let victim_cert = rcgen::CertificateParams::default()
|
|
.self_signed(&victim_key_pair)
|
|
.expect("victim cert");
|
|
let victim_cert_der = victim_cert.der().clone();
|
|
|
|
let victim_fingerprint = fingerprint_from_cert_der(victim_cert_der.as_ref())
|
|
.expect("fingerprint of the victim cert");
|
|
assert!(victim_fingerprint.starts_with("SHA256:"));
|
|
|
|
let attacker_signer = Ed25519SecretKey::generate();
|
|
|
|
let result =
|
|
run_impersonation(default_acceptor().await, victim_cert_der, &attacker_signer).await;
|
|
|
|
let err = result.expect_err(
|
|
"ADR-008: the default verifier must reject a handshake that presents \
|
|
the victim's X.509 cert under the attacker's key",
|
|
);
|
|
assert!(
|
|
err.contains("invalid peer certificate"),
|
|
"the possession check must fail the handshake with a certificate \
|
|
error, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_verifier_rejects_raw_key_victim_spki_with_attacker_key() {
|
|
let victim_key = Ed25519SecretKey::generate();
|
|
let victim_public_bytes: [u8; 32] = victim_key.public().to_bytes();
|
|
let victim_spki_der =
|
|
rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, victim_public_bytes)
|
|
.to_vec();
|
|
|
|
let victim_fingerprint =
|
|
fingerprint_from_cert_der(&victim_spki_der).expect("fingerprint of the victim SPKI");
|
|
assert!(victim_fingerprint.starts_with("ed25519:"));
|
|
|
|
let attacker_signer = Ed25519SecretKey::generate();
|
|
assert_ne!(
|
|
attacker_signer.public().to_bytes(),
|
|
victim_public_bytes,
|
|
"the attacker must hold a different key than the victim"
|
|
);
|
|
|
|
let result = run_impersonation(
|
|
default_acceptor().await,
|
|
CertificateDer::from(victim_spki_der),
|
|
&attacker_signer,
|
|
)
|
|
.await;
|
|
|
|
let err = result.expect_err(
|
|
"ADR-008: the default verifier must reject a handshake that presents \
|
|
the victim's SPKI under the attacker's key",
|
|
);
|
|
assert!(
|
|
err.contains("invalid peer certificate"),
|
|
"the possession check must fail the handshake with a certificate \
|
|
error, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn escape_hatch_x509_victim_cert_completes_and_fingerprint_is_victims() {
|
|
let victim_key_pair = rcgen::KeyPair::generate().expect("victim key gen");
|
|
let victim_cert = rcgen::CertificateParams::default()
|
|
.self_signed(&victim_key_pair)
|
|
.expect("victim cert");
|
|
let victim_cert_der = victim_cert.der().clone();
|
|
|
|
let victim_fingerprint = fingerprint_from_cert_der(victim_cert_der.as_ref())
|
|
.expect("fingerprint of the victim cert");
|
|
|
|
let attacker_signer = Ed25519SecretKey::generate();
|
|
|
|
let server_seen = run_impersonation(escape_hatch_acceptor(), victim_cert_der, &attacker_signer)
|
|
.await
|
|
.expect("the escape-hatch verifier must accept the no-pop handshake");
|
|
|
|
assert_eq!(
|
|
server_seen.as_deref(),
|
|
Some(victim_fingerprint.as_str()),
|
|
"S-1 (escape hatch): AcceptAnyCertVerifier performs no \
|
|
proof-of-possession check — the server extracts the VICTIM's \
|
|
fingerprint from a handshake the attacker completed with its own \
|
|
key. This is the documented, deliberately-installed posture."
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn escape_hatch_raw_key_victim_spki_completes_and_fingerprint_is_victims() {
|
|
let victim_key = Ed25519SecretKey::generate();
|
|
let victim_public_bytes: [u8; 32] = victim_key.public().to_bytes();
|
|
let victim_spki_der =
|
|
rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, victim_public_bytes)
|
|
.to_vec();
|
|
|
|
let victim_fingerprint =
|
|
fingerprint_from_cert_der(&victim_spki_der).expect("fingerprint of the victim SPKI");
|
|
|
|
let attacker_signer = Ed25519SecretKey::generate();
|
|
|
|
let server_seen = run_impersonation(
|
|
escape_hatch_acceptor(),
|
|
CertificateDer::from(victim_spki_der),
|
|
&attacker_signer,
|
|
)
|
|
.await
|
|
.expect("the escape-hatch verifier must accept the no-pop handshake");
|
|
|
|
assert_eq!(
|
|
server_seen.as_deref(),
|
|
Some(victim_fingerprint.as_str()),
|
|
"S-1 (escape hatch, RFC 7250 variant): the server extracts the \
|
|
VICTIM's ed25519 fingerprint from a handshake the attacker \
|
|
completed with its own signer — the no-pop posture only exists \
|
|
where AcceptAnyCertVerifier is explicitly installed."
|
|
);
|
|
}
|