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:
@@ -89,9 +89,12 @@ verification — only the pin.
|
||||
|
||||
Handshake signatures are still verified (TLS 1.2/1.3, aws-lc-rs
|
||||
algorithms; Ed25519 SPKI certs route through
|
||||
`verify_tls13_signature_with_raw_key`): a stolen fingerprint cannot
|
||||
be replayed with a forged signature — the presenter must prove
|
||||
possession of the corresponding private key.
|
||||
`verify_tls13_signature_with_raw_key`): the presenter must prove
|
||||
possession of the corresponding private key, so a stolen or observed
|
||||
certificate cannot be used by a party that does not hold the matching
|
||||
key. This verifier checks proof-of-possession; the server-side
|
||||
`AcceptAnyCertVerifier` does not (see
|
||||
[server.md](server.md), OQ-TLS-09).
|
||||
|
||||
## The root-store fallback (alknet ADR-088 §5)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ are authoritative; the Phase 0 doc's statuses are the historical record.
|
||||
| OQ-TLS-06 | ACME task shutdown surface | resolved (detached-only for v1) | low |
|
||||
| OQ-TLS-07 | iroh key surface | **resolved** (ADR-005, byte access pinned) | low |
|
||||
| OQ-TLS-08 | `quinn` → `noq` feature rename | **resolved** (ADR-003) | high |
|
||||
| OQ-TLS-09 | Server-path proof-of-possession | **open** | high |
|
||||
|
||||
## Identity & types
|
||||
|
||||
@@ -104,6 +105,44 @@ are authoritative; the Phase 0 doc's statuses are the historical record.
|
||||
[ADR-003](decisions/003-noq-replaces-quinn.md).
|
||||
- **Cross-references**: ADR-002, ADR-003, ADR-004
|
||||
|
||||
### OQ-TLS-09: Which layer owns proof-of-possession on the server path?
|
||||
|
||||
- **Origin**: docs/reviews/001-implementation-review.md §S-1 (the
|
||||
impersonation probe: a handshake with the victim's cert bytes + an
|
||||
attacker signer completes, and the server extracts the victim's
|
||||
fingerprint)
|
||||
- **Status**: open (recorded 2026-09-10)
|
||||
- **Priority**: high
|
||||
- **Question**: `AcceptAnyCertVerifier` never checks the client's
|
||||
CertificateVerify signature — the presented identity is spoofable by
|
||||
anyone holding the public cert/SPKI bytes, and the auth layer cannot
|
||||
detect it (the fingerprint it is handed *is* the victim's). alknet
|
||||
ADR-034 inherited the request-but-don't-require shape without
|
||||
recording this property. Who enforces possession?
|
||||
- **Options**:
|
||||
- **(a) Keep request-but-don't-require; the auth layer owns
|
||||
challenge-response** over the established channel, bound to the
|
||||
presented public key. No crate change; the S-1 doc note on
|
||||
`AcceptAnyCertVerifier` is the honest description until then.
|
||||
- **(b) Add a `VerifyPresentedCertVerifier` sibling**
|
||||
(request-and-verify): same nine-scheme list, permissive
|
||||
`verify_client_cert`, signature methods delegate to
|
||||
`rustls::crypto::verify_tls{12,13}_signature(_with_raw_key)` — the
|
||||
routing the client-side `FingerprintPinVerifier` already
|
||||
implements. Additive; must land before the first consumer (an API
|
||||
shape decision).
|
||||
- **(c) Make the auth layer's fingerprint resolution
|
||||
possession-checked** (a possession proof accompanies each
|
||||
fingerprint resolution). Cross-crate; the auth layer does not exist
|
||||
yet.
|
||||
- **Constraints**: the spoofable posture is pinned by
|
||||
`tests/impersonation_posture.rs` (both cert types) — any decision
|
||||
must fail or update that test together with the
|
||||
`AcceptAnyCertVerifier` doc note.
|
||||
- **Cross-references**: src/server.rs (`AcceptAnyCertVerifier`),
|
||||
src/client.rs (`FingerprintPinVerifier`),
|
||||
docs/reviews/001-implementation-review.md §S-1, alknet ADR-034
|
||||
|
||||
## Quality / process
|
||||
|
||||
### OQ-TLS-05: Test surface for the invariants
|
||||
@@ -133,5 +172,6 @@ are authoritative; the Phase 0 doc's statuses are the historical record.
|
||||
|
||||
## Deferred / Blocked
|
||||
|
||||
(none — all promoted OQs are resolved; new OQs added during review
|
||||
land here with their deferral half per `docs/sdd_process.md`)
|
||||
- OQ-TLS-09 (server-path proof-of-possession): open by design — the
|
||||
decision needs the rewrite's auth-layer design in hand (option (c))
|
||||
or an API-shape call before the first consumer (option (b)).
|
||||
@@ -79,9 +79,27 @@ fingerprint is matched against peer records by the auth layer
|
||||
the TLS crate hands over the fingerprint string; peer resolution is
|
||||
not a TLS concern (ADR-005).
|
||||
|
||||
**The presented signature is not verified (no proof-of-possession)** —
|
||||
review 001's S-1: the client's CertificateVerify signature is never
|
||||
checked against the presented cert's public key, so any party holding
|
||||
a peer's public cert bytes (X.509) or SPKI (RFC 7250) can complete a
|
||||
handshake *as that peer*, and the server hands the auth layer the
|
||||
victim's fingerprint. The auth layer cannot detect this — the
|
||||
fingerprint it is handed *is* the victim's. Until the caller enforces
|
||||
possession, treat the extracted fingerprint as an unauthenticated
|
||||
claim, not proof of identity. Two patterns make it safe (the auth
|
||||
layer owns either): challenge-response over the established channel
|
||||
bound to the presented public key, or a verifier that verifies the
|
||||
CertificateVerify (the routing `FingerprintPinVerifier` implements —
|
||||
tracked as OQ-TLS-09; option (b) there is additive but must land
|
||||
before the first consumer). The spoofable posture is pinned by
|
||||
`tests/impersonation_posture.rs` (both cert types) — a change here
|
||||
must fail or update that test together with this doc.
|
||||
|
||||
Server-side only: this must not be reused as a client-side
|
||||
`ServerCertVerifier` — client-side verification is alknet ADR-034's
|
||||
selection matrix (see [client.md](client.md)).
|
||||
selection matrix (see [client.md](client.md)), and unlike the
|
||||
client-side pin verifier this type has no proof-of-possession check.
|
||||
|
||||
## `RawKeyCertResolver`
|
||||
|
||||
|
||||
+11
-3
@@ -231,9 +231,17 @@ impl rustls::client::ResolvesClientCert for NoClientCertResolver {
|
||||
/// 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.
|
||||
/// signature verification algorithms): the presenter must prove possession
|
||||
/// of the private key corresponding to the pinned public key, so the pin
|
||||
/// cannot be satisfied by replaying the cert's public bytes under a
|
||||
/// different key. The cert is presented fresh in every handshake — the
|
||||
/// threat the signature check addresses is a stolen or observed
|
||||
/// *certificate* being used by a party that does not hold the matching
|
||||
/// private key.
|
||||
///
|
||||
/// This verifier checks proof-of-possession. The **server-side**
|
||||
/// `AcceptAnyCertVerifier` does not — see that type's docs (S-1 in
|
||||
/// `docs/reviews/001-implementation-review.md`, OQ-TLS-09).
|
||||
pub struct FingerprintPinVerifier {
|
||||
fingerprint: String,
|
||||
supported: rustls::crypto::WebPkiSupportedAlgorithms,
|
||||
|
||||
+37
-1
@@ -249,11 +249,47 @@ pub fn generate_self_signed_cert() -> Result<SelfSignedCert, TlsError> {
|
||||
/// (`IdentityProvider::resolve_from_fingerprint`) is the auth layer's
|
||||
/// concern, outside this crate (alktls ADR-005).
|
||||
///
|
||||
/// # The presented signature is NOT verified (no proof-of-possession)
|
||||
///
|
||||
/// The client's CertificateVerify signature is never checked against the
|
||||
/// presented cert's public key: `verify_client_cert` accepts any cert and
|
||||
/// the `verify_tls12_signature`/`verify_tls13_signature` methods assert
|
||||
/// validity unconditionally. Consequently **the fingerprint this verifier
|
||||
/// lets the server extract is attacker-suppliable**: any party that
|
||||
/// observes a peer's public cert bytes (X.509) or SPKI (RFC 7250) can
|
||||
/// complete a handshake presenting those bytes under its own key, and
|
||||
/// `peer_certificates()` yields the victim's cert — every downstream
|
||||
/// fingerprint decision (scopes, tokens, resumption) is then made against
|
||||
/// a spoofed identity. The auth layer's peer table cannot detect this: the
|
||||
/// fingerprint it is handed *is* the victim's. Until the caller enforces
|
||||
/// possession, treat the extracted fingerprint as an unauthenticated
|
||||
/// claim, not proof of identity.
|
||||
///
|
||||
/// Two patterns make the identity safe — the auth layer owns either:
|
||||
///
|
||||
/// 1. **Challenge-response over the established channel**, bound to the
|
||||
/// presented public key: possession is proven post-handshake, so a
|
||||
/// stolen/observed fingerprint cannot be replayed without the key.
|
||||
/// 2. **A verifier that does verify the CertificateVerify** against the
|
||||
/// presented cert — the same
|
||||
/// `verify_tls13_signature_with_raw_key` / `verify_tls12_signature`
|
||||
/// routing the client-side `FingerprintPinVerifier` implements. The
|
||||
/// crate has the routing code, just not on this type (a tracked
|
||||
/// question — see OQ-TLS-09 in `docs/architecture/open-questions.md`).
|
||||
///
|
||||
/// This behavior is pinned end-to-end by
|
||||
/// `tests/impersonation_posture.rs`: a handshake with the victim's cert
|
||||
/// bytes + an attacker signer completes and the server extracts the
|
||||
/// victim's fingerprint. A change that adds proof-of-possession (or an
|
||||
/// upstream rustls change that enforces it) must fail that test and
|
||||
/// update this doc + OQ-TLS-09 together.
|
||||
///
|
||||
/// **Server-side only.** This must not be reused as a client-side
|
||||
/// `ServerCertVerifier` — client-side verification is alknet ADR-034's
|
||||
/// selection matrix (see the client module): CA verification for unknown
|
||||
/// X.509 remotes, fingerprint pinning for known peers, fail closed for
|
||||
/// unknown raw keys.
|
||||
/// unknown raw keys. Unlike the client-side pin verifier, this type has
|
||||
/// **no proof-of-possession check** (see above).
|
||||
pub struct AcceptAnyCertVerifier;
|
||||
|
||||
impl std::fmt::Debug for AcceptAnyCertVerifier {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: fix-accept-any-cert-verifier-posture
|
||||
name: S-1 remediation — AcceptAnyCertVerifier no-pop posture (doc + ADR/OQ + permanent probe)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: medium
|
||||
@@ -80,23 +80,23 @@ finding, not a code-change mandate. What's missing:
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] The doc note exists on `AcceptAnyCertVerifier` and states the
|
||||
- [x] The doc note exists on `AcceptAnyCertVerifier` and states the
|
||||
no-pop consequence in one reading
|
||||
- [ ] The N-1 companion line exists on `FingerprintPinVerifier`
|
||||
- [ ] OQ-TLS-09 recorded with status `open` and the three options
|
||||
- [ ] The permanent probe test passes and asserts the spoofable
|
||||
- [x] The N-1 companion line exists on `FingerprintPinVerifier`
|
||||
- [x] OQ-TLS-09 recorded with status `open` and the three options
|
||||
- [x] The permanent probe test passes and asserts the spoofable
|
||||
fingerprint end-to-end (both cert types)
|
||||
- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
|
||||
`cargo fmt --check`, `cargo doc --no-deps` green
|
||||
- [ ] `cargo test --all-features` green (the probe is tcp-gated)
|
||||
- [x] `cargo test --all-features` green (the probe is tcp-gated)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A consumer reading only the rustdoc understands that presented
|
||||
- [x] A consumer reading only the rustdoc understands that presented
|
||||
identity is spoofable without caller-enforced possession
|
||||
- [ ] The spoofable posture is pinned by an executed test, not just
|
||||
- [x] The spoofable posture is pinned by an executed test, not just
|
||||
prose
|
||||
- [ ] The ownership question is tracked (OQ-TLS-09) — not silently
|
||||
- [x] The ownership question is tracked (OQ-TLS-09) — not silently
|
||||
inherited
|
||||
|
||||
## References
|
||||
@@ -111,6 +111,59 @@ finding, not a code-change mandate. What's missing:
|
||||
|
||||
> Agent fills this during implementation.
|
||||
|
||||
- The probe used a tokio duplex pair + `TlsConnector`/`TlsAcceptor`
|
||||
(tokio-rustls, `tcp`-gated) rather than raw `complete_io` — same
|
||||
shape, cleaner await-based flow. The attacker client uses a
|
||||
test-local accept-anything `ServerCertVerifier` and a fixed
|
||||
`ResolvesClientCert` presenting the victim's bytes with the
|
||||
attacker's signer, `only_raw_public_keys() == false` in both
|
||||
variants (the cert-type negotiation stays X.509-typed, exactly the
|
||||
review-probe shape; verified against rustls 0.23.44's
|
||||
`validate_client_cert_type_extension` — an offer of only
|
||||
`[RawPublicKey]` would fail `IncorrectCertificateTypeExtension`
|
||||
against `requires_raw_public_keys() == false`, so the SPKI is
|
||||
presented under the default X.509 offer and passes through unparsed).
|
||||
- N-4 (the `requires_raw_public_keys()` doc note) is deliberately NOT
|
||||
in this task — it belongs to `docs-pin-c1-c4-n3-n4`. While verifying
|
||||
the probe shape, the review's N-4 parenthetical ("alknet's own
|
||||
client resolver offers both types") was found inaccurate: rustls
|
||||
0.23.41 AND 0.23.44 send `client_certificate_types = [RawPublicKey]`
|
||||
whenever the resolver's `only_raw_public_keys()` is true — there is
|
||||
no "offers both types" behavior in either version. Task 6 should
|
||||
write the N-4 note from the rustls sources, not from the review's
|
||||
parenthetical.
|
||||
- Docs synced beyond the type docs: `docs/architecture/server.md` §
|
||||
`AcceptAnyCertVerifier` and `docs/architecture/client.md` §
|
||||
`FingerprintPinVerifier` now carry the S-1 posture + the N-1
|
||||
cross-reference.
|
||||
|
||||
## Summary
|
||||
|
||||
> Agent fills this on completion.
|
||||
|
||||
**Landed (this task):**
|
||||
|
||||
1. `src/server.rs` — `AcceptAnyCertVerifier` doc rewritten: a
|
||||
"# The presented signature is NOT verified (no proof-of-possession)"
|
||||
section stating the spoofable-fingerprint consequence, the two safe
|
||||
patterns (challenge-response / a verifying verifier), the
|
||||
OQ-TLS-09 pointer, and the pinning-test reference.
|
||||
2. `src/client.rs` — `FingerprintPinVerifier` doc: the
|
||||
"stolen-but-stale fingerprint" phrasing replaced (the cert is
|
||||
presented fresh each handshake; the signature check defeats use of
|
||||
a stolen/observed cert by a party without the private key) plus the
|
||||
cross-reference: this verifier checks pop; the server-side
|
||||
`AcceptAnyCertVerifier` does not.
|
||||
3. `docs/architecture/open-questions.md` — OQ-TLS-09 recorded, status
|
||||
**open**, the three options (a/b/c), the pinning-test constraint,
|
||||
and the deferral note (the decision needs the auth-layer design or
|
||||
an API call before the first consumer).
|
||||
4. `tests/impersonation_posture.rs` (tcp-gated) — the impersonation
|
||||
probe made permanent, both variants: X.509 victim cert + attacker
|
||||
key, and RFC 7250 victim SPKI + attacker key; each asserts the
|
||||
handshake completes, application data flows, and the server-extracted
|
||||
fingerprint equals the victim's.
|
||||
|
||||
**Verification:** 68 default / 77 all-features tests green
|
||||
(+2 new); clippy `-D warnings` clean (default + all-features); fmt
|
||||
clean; `cargo doc --no-deps` warning-free.
|
||||
@@ -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