task 1: S-1 remediation — no-pop posture doc + OQ-TLS-09 + permanent impersonation pin
- AcceptAnyCertVerifier doc: the presented CertificateVerify signature
is not verified (no proof-of-possession) — the server-extracted
fingerprint is attacker-suppliable from observed public cert/SPKI
bytes; states the two safe patterns (auth-layer challenge-response /
a verifying verifier) and points at OQ-TLS-09
- FingerprintPinVerifier doc (N-1): fixed the "stolen-but-stale
fingerprint" phrasing (the cert is presented fresh each handshake;
the signature check defeats a stolen/observed cert used by a party
without the private key) and added the server-verifier cross-reference
- OQ-TLS-09 recorded (open, high): which layer owns server-path
proof-of-possession — three options; deferral noted (needs the
auth-layer design or an API call before the first consumer)
- tests/impersonation_posture.rs (tcp-gated): the S-1 probe made
permanent, both variants — X.509 victim cert + attacker key and RFC
7250 victim SPKI + attacker key complete the handshake, application
data flows, and the server extracts the victim's fingerprint; any
future pop change must fail/update this test with the doc + OQ
- server.md / client.md synced with the same posture
- task note: the review's N-4 parenthetical ("alknet's client resolver
offers both types") is inaccurate — rustls 0.23.41/0.23.44 offer
[RawPublicKey] iff the resolver's only_raw_public_keys() is true;
task 6 should write N-4 from the rustls sources
Verified: cargo test 68 default / 77 all-features (+2) green; clippy
-D warnings clean (default + all-features); fmt clean; cargo doc
--no-deps warning-free
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! 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";
|
||||
|
||||
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).
|
||||
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
|
||||
}
|
||||
|
||||
async fn run_impersonation(
|
||||
presented_cert: CertificateDer<'static>,
|
||||
attacker_signer: &Ed25519SecretKey,
|
||||
) -> Option<String> {
|
||||
let server_config = server_config().await;
|
||||
let acceptor = server_config.for_tcp_tls();
|
||||
|
||||
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.expect("attacker handshake must complete (the S-1 posture)");
|
||||
let mut server_stream = server.expect("server side of the handshake must complete");
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
client_stream
|
||||
.write_all(b"spoof")
|
||||
.await
|
||||
.expect("application data must flow after the spoofed handshake");
|
||||
|
||||
let mut buf = [0u8; 5];
|
||||
server_stream
|
||||
.read_exact(&mut buf)
|
||||
.await
|
||||
.expect("the server must read the attacker's application data");
|
||||
|
||||
let (_, server_conn) = server_stream.get_ref();
|
||||
server_conn
|
||||
.peer_certificates()
|
||||
.and_then(|certs| certs.first().map(|c| fingerprint_from_cert_der(c.as_ref())))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn x509_victim_cert_with_attacker_key_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");
|
||||
assert!(victim_fingerprint.starts_with("SHA256:"));
|
||||
|
||||
let attacker_signer = Ed25519SecretKey::generate();
|
||||
|
||||
let server_seen = run_impersonation(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)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_key_victim_spki_with_attacker_key_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");
|
||||
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 server_seen =
|
||||
run_impersonation(CertificateDer::from(victim_spki_der), &attacker_signer).await;
|
||||
|
||||
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)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user