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:
2026-09-10 22:00:57 +00:00
parent d7db6b17a6
commit e86b8ba1b5
7 changed files with 414 additions and 21 deletions
+6 -3
View File
@@ -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)
+42 -2
View File
@@ -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)).
+19 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+63 -10
View File
@@ -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.
+235
View File
@@ -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)"
);
}