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
|
Handshake signatures are still verified (TLS 1.2/1.3, aws-lc-rs
|
||||||
algorithms; Ed25519 SPKI certs route through
|
algorithms; Ed25519 SPKI certs route through
|
||||||
`verify_tls13_signature_with_raw_key`): a stolen fingerprint cannot
|
`verify_tls13_signature_with_raw_key`): the presenter must prove
|
||||||
be replayed with a forged signature — the presenter must prove
|
possession of the corresponding private key, so a stolen or observed
|
||||||
possession of the corresponding private key.
|
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)
|
## 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-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-07 | iroh key surface | **resolved** (ADR-005, byte access pinned) | low |
|
||||||
| OQ-TLS-08 | `quinn` → `noq` feature rename | **resolved** (ADR-003) | high |
|
| OQ-TLS-08 | `quinn` → `noq` feature rename | **resolved** (ADR-003) | high |
|
||||||
|
| OQ-TLS-09 | Server-path proof-of-possession | **open** | high |
|
||||||
|
|
||||||
## Identity & types
|
## 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).
|
[ADR-003](decisions/003-noq-replaces-quinn.md).
|
||||||
- **Cross-references**: ADR-002, ADR-003, ADR-004
|
- **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
|
## Quality / process
|
||||||
|
|
||||||
### OQ-TLS-05: Test surface for the invariants
|
### 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
|
## Deferred / Blocked
|
||||||
|
|
||||||
(none — all promoted OQs are resolved; new OQs added during review
|
- OQ-TLS-09 (server-path proof-of-possession): open by design — the
|
||||||
land here with their deferral half per `docs/sdd_process.md`)
|
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
|
the TLS crate hands over the fingerprint string; peer resolution is
|
||||||
not a TLS concern (ADR-005).
|
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
|
Server-side only: this must not be reused as a client-side
|
||||||
`ServerCertVerifier` — client-side verification is alknet ADR-034's
|
`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`
|
## `RawKeyCertResolver`
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -231,9 +231,17 @@ impl rustls::client::ResolvesClientCert for NoClientCertResolver {
|
|||||||
/// CA verification and no name verification, only the fingerprint pin.
|
/// CA verification and no name verification, only the fingerprint pin.
|
||||||
///
|
///
|
||||||
/// Handshake signatures are still verified (using the aws-lc-rs default
|
/// Handshake signatures are still verified (using the aws-lc-rs default
|
||||||
/// signature verification algorithms) so that a stolen-but-stale fingerprint
|
/// signature verification algorithms): the presenter must prove possession
|
||||||
/// can't be replayed with a forged signature: the presenter must prove
|
/// of the private key corresponding to the pinned public key, so the pin
|
||||||
/// possession of the private key corresponding to the pinned public key.
|
/// 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 {
|
pub struct FingerprintPinVerifier {
|
||||||
fingerprint: String,
|
fingerprint: String,
|
||||||
supported: rustls::crypto::WebPkiSupportedAlgorithms,
|
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
|
/// (`IdentityProvider::resolve_from_fingerprint`) is the auth layer's
|
||||||
/// concern, outside this crate (alktls ADR-005).
|
/// 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
|
/// **Server-side only.** This must not be reused as a client-side
|
||||||
/// `ServerCertVerifier` — client-side verification is alknet ADR-034's
|
/// `ServerCertVerifier` — client-side verification is alknet ADR-034's
|
||||||
/// selection matrix (see the client module): CA verification for unknown
|
/// selection matrix (see the client module): CA verification for unknown
|
||||||
/// X.509 remotes, fingerprint pinning for known peers, fail closed for
|
/// 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;
|
pub struct AcceptAnyCertVerifier;
|
||||||
|
|
||||||
impl std::fmt::Debug for AcceptAnyCertVerifier {
|
impl std::fmt::Debug for AcceptAnyCertVerifier {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: fix-accept-any-cert-verifier-posture
|
id: fix-accept-any-cert-verifier-posture
|
||||||
name: S-1 remediation — AcceptAnyCertVerifier no-pop posture (doc + ADR/OQ + permanent probe)
|
name: S-1 remediation — AcceptAnyCertVerifier no-pop posture (doc + ADR/OQ + permanent probe)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: []
|
depends_on: []
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: medium
|
risk: medium
|
||||||
@@ -80,23 +80,23 @@ finding, not a code-change mandate. What's missing:
|
|||||||
|
|
||||||
## Verification
|
## 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
|
no-pop consequence in one reading
|
||||||
- [ ] The N-1 companion line exists on `FingerprintPinVerifier`
|
- [x] The N-1 companion line exists on `FingerprintPinVerifier`
|
||||||
- [ ] OQ-TLS-09 recorded with status `open` and the three options
|
- [x] OQ-TLS-09 recorded with status `open` and the three options
|
||||||
- [ ] The permanent probe test passes and asserts the spoofable
|
- [x] The permanent probe test passes and asserts the spoofable
|
||||||
fingerprint end-to-end (both cert types)
|
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 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
|
## 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
|
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
|
prose
|
||||||
- [ ] The ownership question is tracked (OQ-TLS-09) — not silently
|
- [x] The ownership question is tracked (OQ-TLS-09) — not silently
|
||||||
inherited
|
inherited
|
||||||
|
|
||||||
## References
|
## References
|
||||||
@@ -111,6 +111,59 @@ finding, not a code-change mandate. What's missing:
|
|||||||
|
|
||||||
> Agent fills this during implementation.
|
> 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
|
## Summary
|
||||||
|
|
||||||
> Agent fills this on completion.
|
> 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