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.
387 lines
15 KiB
Rust
387 lines
15 KiB
Rust
//! U-3 executed handshake suites (review 001): the pin path, the
|
|
//! fail-closed rule, and the RFC 7250 raw-key server path as *executed*
|
|
//! behavior — not structural config assertions. A duplex pair +
|
|
//! `tokio-rustls` drives real rustls handshakes; no external transport
|
|
//! is involved (ADR-006's boundary is not crossed).
|
|
//!
|
|
//! Cert-type negotiation is resolved by ADR-007 (OQ-TLS-10):
|
|
//!
|
|
//! - `FingerprintPinVerifier` offers `server_certificate_types =
|
|
//! [RawPublicKey]` iff the pin is `ed25519:` — the crate's own pin
|
|
//! client completes against a crate-built raw-key server, and an
|
|
//! `ed25519:` pin against an X.509 server fails closed at
|
|
//! negotiation (a cert-type mismatch can never downgrade to
|
|
//! 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); 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`,
|
|
//! `client/hs.rs::process_cert_type_extension`).
|
|
|
|
#![cfg(feature = "tcp")]
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use rustls::pki_types::{CertificateDer, ServerName};
|
|
use tokio::io::duplex;
|
|
|
|
use alktls::fingerprint_from_cert_der;
|
|
use alktls::{
|
|
ConnectionCredentials, Ed25519SecretKey, RemoteIdentity, TlsClientConfig, TlsIdentity,
|
|
TlsServerConfig,
|
|
};
|
|
|
|
const ALPN: &[u8] = b"alk/handshake";
|
|
|
|
fn write_x509_pair(dir: &std::path::Path) -> (PathBuf, PathBuf, CertificateDer<'static>) {
|
|
let key_pair = rcgen::KeyPair::generate().expect("key gen");
|
|
let cert = rcgen::CertificateParams::default()
|
|
.self_signed(&key_pair)
|
|
.expect("self-signed cert");
|
|
let cert_path = dir.join("cert.pem");
|
|
let key_path = dir.join("key.pem");
|
|
std::fs::write(&cert_path, cert.pem()).expect("write cert pem");
|
|
std::fs::write(&key_path, key_pair.serialize_pem()).expect("write key pem");
|
|
(cert_path, key_path, cert.der().clone())
|
|
}
|
|
|
|
async fn server_config(identity: &TlsIdentity) -> TlsServerConfig {
|
|
TlsServerConfig::new(identity, &[ALPN.to_vec()])
|
|
.await
|
|
.expect("server config must construct")
|
|
}
|
|
|
|
fn connector(credentials: &ConnectionCredentials) -> tokio_rustls::TlsConnector {
|
|
let config = TlsClientConfig::new(credentials, ALPN).expect("client config must construct");
|
|
tokio_rustls::TlsConnector::from(Arc::new(config.into_rustls_config()))
|
|
}
|
|
|
|
/// Drive a full handshake over a duplex pair and one application-data
|
|
/// round-trip. Returns the fingerprint the server extracted from the
|
|
/// client's presented cert (`None` when no client cert was presented),
|
|
/// or the error string from whichever side failed first.
|
|
async fn round_trip(
|
|
acceptor: tokio_rustls::TlsAcceptor,
|
|
connector: tokio_rustls::TlsConnector,
|
|
) -> Result<Option<String>, String> {
|
|
let (client_io, server_io) = duplex(64 * 1024);
|
|
let server_name = ServerName::try_from("handshake.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"ping")
|
|
.await
|
|
.map_err(|e| format!("client write: {e}"))?;
|
|
let mut buf = [0u8; 4];
|
|
server_stream
|
|
.read_exact(&mut buf)
|
|
.await
|
|
.map_err(|e| format!("server read: {e}"))?;
|
|
|
|
let (_, conn) = server_stream.get_ref();
|
|
Ok(conn
|
|
.peer_certificates()
|
|
.and_then(|certs| certs.first().map(|c| fingerprint_from_cert_der(c.as_ref())))
|
|
.flatten())
|
|
}
|
|
|
|
/// Suite 1 — the pin path end-to-end: a client pinning the server's
|
|
/// `SHA256:<hex>` cert fingerprint handshakes the matching X.509 server;
|
|
/// app data flows; the server extracts the client cert's fingerprint.
|
|
#[tokio::test]
|
|
async fn pin_match_completes_and_server_extracts_client_fingerprint() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let (cert_path, key_path, cert_der) = write_x509_pair(dir.path());
|
|
let server_fp = fingerprint_from_cert_der(cert_der.as_ref()).expect("server fp");
|
|
assert!(server_fp.starts_with("SHA256:"));
|
|
|
|
let acceptor = server_config(&TlsIdentity::X509 {
|
|
cert: cert_path.clone(),
|
|
key: key_path.clone(),
|
|
})
|
|
.await
|
|
.for_tcp_tls();
|
|
|
|
let client_fp = fingerprint_from_cert_der(cert_der.as_ref()).expect("client fp");
|
|
let credentials = ConnectionCredentials::new()
|
|
.with_local_identity(TlsIdentity::X509 {
|
|
cert: cert_path,
|
|
key: key_path,
|
|
})
|
|
.with_remote_identity(RemoteIdentity {
|
|
fingerprint: server_fp,
|
|
});
|
|
|
|
let server_seen = round_trip(acceptor, connector(&credentials))
|
|
.await
|
|
.expect("handshake with a matching pin must complete");
|
|
|
|
assert_eq!(
|
|
server_seen.as_deref(),
|
|
Some(client_fp.as_str()),
|
|
"the server must extract the client cert's fingerprint through the \
|
|
request-but-don't-require verifier"
|
|
);
|
|
}
|
|
|
|
/// Suite 1b — pin mismatch fails the handshake: same server, a pin for a
|
|
/// different cert rejects at handshake time (the pin IS the anchor).
|
|
#[tokio::test]
|
|
async fn pin_mismatch_fails_the_handshake() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let (cert_path, key_path, _) = write_x509_pair(dir.path());
|
|
|
|
let acceptor = server_config(&TlsIdentity::X509 {
|
|
cert: cert_path.clone(),
|
|
key: key_path.clone(),
|
|
})
|
|
.await
|
|
.for_tcp_tls();
|
|
|
|
let credentials = ConnectionCredentials::new()
|
|
.with_local_identity(TlsIdentity::X509 {
|
|
cert: cert_path,
|
|
key: key_path,
|
|
})
|
|
.with_remote_identity(RemoteIdentity {
|
|
fingerprint: "SHA256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
.to_string(),
|
|
});
|
|
|
|
let result = round_trip(acceptor, connector(&credentials)).await;
|
|
let err = result.expect_err("a mismatched pin must fail the handshake");
|
|
assert!(
|
|
err.contains("fingerprint pin mismatch"),
|
|
"the pin verifier's mismatch error must surface at handshake, got: {err}"
|
|
);
|
|
}
|
|
|
|
/// Suite 2 — the fail-closed rule, executed: `remote_identity: None` (the
|
|
/// CA-verification path) against a raw-key server fails the handshake —
|
|
/// never a silent downgrade. Mechanically the raw-key server resolver
|
|
/// (`only_raw_public_keys() == true`) requires the client to offer
|
|
/// `[RawPublicKey]` server cert types; the CA-path verifier keeps the
|
|
/// `requires_raw_public_keys() == false` default and offers none, so
|
|
/// rustls aborts with a HandshakeFailure alert. Either way — no
|
|
/// negotiation or no CA — the raw-key remote fails closed.
|
|
#[tokio::test]
|
|
async fn unknown_raw_key_remote_fails_closed() {
|
|
let acceptor = server_config(&TlsIdentity::RawKey(Ed25519SecretKey::generate()))
|
|
.await
|
|
.for_tcp_tls();
|
|
|
|
let credentials = ConnectionCredentials::new();
|
|
let result = round_trip(acceptor, connector(&credentials)).await;
|
|
let err = result.expect_err("an unknown raw-key remote must fail closed");
|
|
assert!(
|
|
err.contains("HandshakeFailure"),
|
|
"the fail-closed abort must surface as a handshake alert, got: {err}"
|
|
);
|
|
}
|
|
|
|
/// Suite 2b — pin-format mismatch fails closed at cert-type negotiation
|
|
/// (ADR-007): an `ed25519:` pin makes the client offer raw-key-only
|
|
/// server cert types; the X.509 server's presentation cannot satisfy
|
|
/// that offer, so rustls aborts with HandshakeFailure *before* the pin
|
|
/// verifier ever runs — never a downgrade to CA verification or a
|
|
/// silent fallback to the X.509 offer.
|
|
#[tokio::test]
|
|
async fn ed25519_pin_against_x509_server_fails_closed_at_negotiation() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let (cert_path, key_path, _) = write_x509_pair(dir.path());
|
|
|
|
let acceptor = server_config(&TlsIdentity::X509 {
|
|
cert: cert_path,
|
|
key: key_path,
|
|
})
|
|
.await
|
|
.for_tcp_tls();
|
|
|
|
let credentials = ConnectionCredentials::new().with_remote_identity(RemoteIdentity {
|
|
fingerprint: "ed25519:0000000000000000000000000000000000000000000000000000000000000000"
|
|
.to_string(),
|
|
});
|
|
|
|
let result = round_trip(acceptor, connector(&credentials)).await;
|
|
let err = result.expect_err("an ed25519 pin against an X.509 server must fail closed");
|
|
assert!(
|
|
err.contains("HandshakeFailure"),
|
|
"the cert-type negotiation abort must surface as a handshake alert, got: {err}"
|
|
);
|
|
}
|
|
|
|
/// Suite 3 — the RFC 7250 raw-key server path end-to-end, entirely on
|
|
/// the crate's public API (ADR-007): the server presents its SPKI, the
|
|
/// client pins the `ed25519:<hex>` fingerprint with the crate's own
|
|
/// `FingerprintPinVerifier` (whose `requires_raw_public_keys()` now
|
|
/// derives from the pin format), and the handshake completes with the
|
|
/// raw-key cert type negotiated — server cert verified as the SPKI
|
|
/// carrying the raw Ed25519 public key.
|
|
#[tokio::test]
|
|
async fn raw_key_server_path_completes_with_crate_pin_client() {
|
|
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");
|
|
assert!(server_fp.starts_with("ed25519:"));
|
|
|
|
let acceptor = server_config(&TlsIdentity::RawKey(server_key.clone()))
|
|
.await
|
|
.for_tcp_tls();
|
|
|
|
let credentials = ConnectionCredentials::new().with_remote_identity(RemoteIdentity {
|
|
fingerprint: server_fp,
|
|
});
|
|
|
|
let server_seen = round_trip(acceptor, connector(&credentials))
|
|
.await
|
|
.expect("the crate pin client must complete the RFC 7250 raw-key handshake (ADR-007)");
|
|
|
|
assert!(
|
|
server_seen.is_none(),
|
|
"no client cert was presented; the server must see none, got: {server_seen:?}"
|
|
);
|
|
}
|
|
|
|
/// 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 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");
|
|
let (cert_path, key_path, cert_der) = write_x509_pair(dir.path());
|
|
let server_fp = fingerprint_from_cert_der(cert_der.as_ref()).expect("server fp");
|
|
|
|
let acceptor = server_config(&TlsIdentity::X509 {
|
|
cert: cert_path.clone(),
|
|
key: key_path.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 raw-key client identity must present its SPKI to the crate's \
|
|
own server verifier (ADR-007)",
|
|
);
|
|
|
|
let expected = format!("ed25519:{}", hex::encode(client_public));
|
|
assert_eq!(
|
|
server_seen.as_deref(),
|
|
Some(expected.as_str()),
|
|
"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"
|
|
);
|
|
}
|