ADR-008: server-path possession verification — the verifying verifier is the default (OQ-TLS-09 resolved)
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.
This commit is contained in:
1 parent
49d4432247
commit
ac440f3a9a
12 files changed
+622
-172
No files matched your search
@@ -14,8 +14,9 @@
|
||||
//! verification).
|
||||
//! - A raw-key *client* identity presents its SPKI under the X.509
|
||||
//! offer (`only_raw_public_keys() == false` — the extension is an
|
||||
//! offer format, not an identity statement); `AcceptAnyCertVerifier`
|
||||
//! accepts it and the server extracts the `ed25519:` fingerprint.
|
||||
//! offer format, not an identity statement); the server's default
|
||||
//! `VerifyPresentedCertVerifier` possession-verifies it (ADR-008)
|
||||
//! and the server extracts the `ed25519:` fingerprint.
|
||||
//!
|
||||
//! Mechanism reference: rustls 0.23.44's cert-type negotiation
|
||||
//! (`server/hs.rs::process_cert_type_extension`,
|
||||
@@ -262,8 +263,10 @@ async fn raw_key_server_path_completes_with_crate_pin_client() {
|
||||
/// Suite 3b — N-4's interop trap, resolved (ADR-007): a raw-key client
|
||||
/// identity presents its SPKI under the X.509 offer
|
||||
/// (`only_raw_public_keys() == false` — the extension is an offer format,
|
||||
/// not an identity statement), which `AcceptAnyCertVerifier` accepts; the
|
||||
/// server extracts the client's `ed25519:` fingerprint end-to-end.
|
||||
/// not an identity statement), which the server's default
|
||||
/// `VerifyPresentedCertVerifier` accepts *and possession-verifies*
|
||||
/// (ADR-008); the server extracts the client's `ed25519:` fingerprint
|
||||
/// end-to-end.
|
||||
#[tokio::test]
|
||||
async fn raw_key_client_presents_spki_and_server_extracts_fingerprint() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -297,3 +300,87 @@ async fn raw_key_client_presents_spki_and_server_extracts_fingerprint() {
|
||||
"the server must extract the raw-key client's ed25519 fingerprint"
|
||||
);
|
||||
}
|
||||
|
||||
/// Suite 4 (ADR-008) — the possession-verifying default end-to-end: a
|
||||
/// raw-key client pins the raw-key server's `ed25519:` fingerprint
|
||||
/// (branch-2 shape), presents its own SPKI under the X.509 offer, and
|
||||
/// the default `VerifyPresentedCertVerifier` verifies the client's
|
||||
/// CertificateVerify against that SPKI's public key — the server
|
||||
/// extracts the client's `ed25519:` fingerprint, possession-checked,
|
||||
/// on the crate's default configuration. (A raw-key *client* without
|
||||
/// the pin stays fail-closed against a raw-key server — the server
|
||||
/// cert-type offer comes from the verifier, suite 2's rule.)
|
||||
#[tokio::test]
|
||||
async fn raw_key_client_vs_raw_key_server_default_verifier_checks_possession() {
|
||||
let server_key = Ed25519SecretKey::generate();
|
||||
let server_public: [u8; 32] = server_key.public().to_bytes();
|
||||
let server_spki =
|
||||
rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, server_public)
|
||||
.to_vec();
|
||||
let server_fp = fingerprint_from_cert_der(&server_spki).expect("server fp");
|
||||
|
||||
let acceptor = server_config(&TlsIdentity::RawKey(server_key.clone()))
|
||||
.await
|
||||
.for_tcp_tls();
|
||||
|
||||
let client_key = Ed25519SecretKey::generate();
|
||||
let client_public: [u8; 32] = client_key.public().to_bytes();
|
||||
let credentials = ConnectionCredentials::new()
|
||||
.with_local_identity(TlsIdentity::RawKey(client_key))
|
||||
.with_remote_identity(RemoteIdentity {
|
||||
fingerprint: server_fp,
|
||||
});
|
||||
|
||||
let server_seen = round_trip(acceptor, connector(&credentials)).await.expect(
|
||||
"a pinning raw-key client must complete against a raw-key server \
|
||||
whose default verifier possession-checks the presented SPKI",
|
||||
);
|
||||
|
||||
let expected = format!("ed25519:{}", hex::encode(client_public));
|
||||
assert_eq!(
|
||||
server_seen.as_deref(),
|
||||
Some(expected.as_str()),
|
||||
"the possession-verifying default must accept the legit raw-key \
|
||||
client and extract its fingerprint"
|
||||
);
|
||||
}
|
||||
|
||||
/// Suite 4b (ADR-008) — an X.509 client (self-signed chain, the
|
||||
/// documented presentation) completes against the verifying default:
|
||||
/// the CertificateVerify is verified through the standard X.509 route.
|
||||
#[tokio::test]
|
||||
async fn x509_client_vs_default_verifier_possession_checked() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (server_cert, server_key, server_der) = write_x509_pair(dir.path());
|
||||
let server_fp = fingerprint_from_cert_der(server_der.as_ref()).expect("server fp");
|
||||
|
||||
let acceptor = server_config(&TlsIdentity::X509 {
|
||||
cert: server_cert.clone(),
|
||||
key: server_key.clone(),
|
||||
})
|
||||
.await
|
||||
.for_tcp_tls();
|
||||
|
||||
let (client_cert, client_key, client_der) = write_x509_pair(dir.path());
|
||||
let client_fp = fingerprint_from_cert_der(client_der.as_ref()).expect("client fp");
|
||||
|
||||
let credentials = ConnectionCredentials::new()
|
||||
.with_local_identity(TlsIdentity::X509 {
|
||||
cert: client_cert,
|
||||
key: client_key,
|
||||
})
|
||||
.with_remote_identity(RemoteIdentity {
|
||||
fingerprint: server_fp,
|
||||
});
|
||||
|
||||
let server_seen = round_trip(acceptor, connector(&credentials))
|
||||
.await
|
||||
.expect("an X.509 client must complete against the verifying default");
|
||||
|
||||
assert_eq!(
|
||||
server_seen.as_deref(),
|
||||
Some(client_fp.as_str()),
|
||||
"the verifying default must accept the legit X.509 client and \
|
||||
extract its fingerprint"
|
||||
);
|
||||
}
|
||||
+128
-39
@@ -1,12 +1,16 @@
|
||||
//! S-1 behavior pin (review 001): `AcceptAnyCertVerifier` performs no
|
||||
//! proof-of-possession check — a handshake presenting the victim's public
|
||||
//! cert bytes (X.509) or SPKI (RFC 7250) under an attacker-owned signer
|
||||
//! completes, and the server extracts the victim's fingerprint.
|
||||
//! S-1 posture pins (review 001 §S-1, resolved by ADR-008) — both
|
||||
//! cert-type verifiers, both cert types:
|
||||
//!
|
||||
//! This test PINS the spoofable posture in both directions. If a future
|
||||
//! change adds proof-of-possession (OQ-TLS-09 option (b)) or an upstream
|
||||
//! rustls change enforces the signature, these tests fail and force the
|
||||
//! `AcceptAnyCertVerifier` doc + OQ-TLS-09 update together.
|
||||
//! 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).
|
||||
@@ -25,12 +29,6 @@ use alktls::{Ed25519SecretKey, TlsServerConfig};
|
||||
|
||||
const ALPN: &[u8] = b"alk/impersonation";
|
||||
|
||||
async fn server_config() -> alktls::TlsServerConfig {
|
||||
TlsServerConfig::new(&alktls::TlsIdentity::SelfSigned, &[ALPN.to_vec()])
|
||||
.await
|
||||
.expect("server config must construct")
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -134,13 +132,15 @@ fn attacker_client_config(
|
||||
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,
|
||||
) -> Option<String> {
|
||||
let server_config = server_config().await;
|
||||
let acceptor = server_config.for_tcp_tls();
|
||||
|
||||
) -> Result<Option<String>, String> {
|
||||
let client_config = attacker_client_config(presented_cert, attacker_signer);
|
||||
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
|
||||
|
||||
@@ -155,30 +155,53 @@ async fn run_impersonation(
|
||||
acceptor.accept(server_io),
|
||||
);
|
||||
|
||||
let mut client_stream = client.expect("attacker handshake must complete (the S-1 posture)");
|
||||
let mut server_stream = server.expect("server side of the handshake must complete");
|
||||
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
|
||||
.expect("application data must flow after the spoofed handshake");
|
||||
.map_err(|e| format!("client write: {e}"))?;
|
||||
|
||||
let mut buf = [0u8; 5];
|
||||
server_stream
|
||||
.read_exact(&mut buf)
|
||||
.await
|
||||
.expect("the server must read the attacker's application data");
|
||||
.map_err(|e| format!("server read: {e}"))?;
|
||||
|
||||
let (_, server_conn) = server_stream.get_ref();
|
||||
server_conn
|
||||
Ok(server_conn
|
||||
.peer_certificates()
|
||||
.and_then(|certs| certs.first().map(|c| fingerprint_from_cert_der(c.as_ref())))
|
||||
.flatten()
|
||||
.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 x509_victim_cert_with_attacker_key_completes_and_fingerprint_is_victims() {
|
||||
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)
|
||||
@@ -191,19 +214,22 @@ async fn x509_victim_cert_with_attacker_key_completes_and_fingerprint_is_victims
|
||||
|
||||
let attacker_signer = Ed25519SecretKey::generate();
|
||||
|
||||
let server_seen = run_impersonation(victim_cert_der, &attacker_signer).await;
|
||||
let result =
|
||||
run_impersonation(default_acceptor().await, victim_cert_der, &attacker_signer).await;
|
||||
|
||||
assert_eq!(
|
||||
server_seen.as_deref(),
|
||||
Some(victim_fingerprint.as_str()),
|
||||
"S-1: the server must extract the VICTIM's fingerprint from a \
|
||||
handshake the attacker completed with its own key — the no-pop \
|
||||
posture is the documented behavior (OQ-TLS-09)"
|
||||
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 raw_key_victim_spki_with_attacker_key_completes_and_fingerprint_is_victims() {
|
||||
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 =
|
||||
@@ -221,15 +247,78 @@ async fn raw_key_victim_spki_with_attacker_key_completes_and_fingerprint_is_vict
|
||||
"the attacker must hold a different key than the victim"
|
||||
);
|
||||
|
||||
let server_seen =
|
||||
run_impersonation(CertificateDer::from(victim_spki_der), &attacker_signer).await;
|
||||
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 (RFC 7250 variant): the server must extract the VICTIM's \
|
||||
ed25519 fingerprint from a handshake the attacker completed with \
|
||||
its own signer — the spoofable posture is the documented behavior \
|
||||
(OQ-TLS-09)"
|
||||
"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."
|
||||
);
|
||||
}
|
||||
@@ -188,7 +188,7 @@ fn server_paths_carry_max_early_data_and_alpn() {
|
||||
assert!(
|
||||
!format!("{config:?}").is_empty(),
|
||||
"the config carries its verifier through the derived Debug (request-but-don't-require \
|
||||
shape asserted at unit level on AcceptAnyCertVerifier itself)"
|
||||
shape asserted at unit level on the verifiers themselves)"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user