8.5 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| call/call-client-verifier-selection | Wire CallClient TLS client-auth and server cert verifier selection by PeerEntry presence (OQ-29, ADR-034) | completed |
|
moderate | high | component | implementation |
Description
Wire the CallClient TLS client-auth (present Ed25519 key as RFC 7250 raw
public key client cert) and the server cert verifier selection by PeerEntry
presence. Per OQ-29 (resolved) and ADR-034 §2-3. This is the most
security-critical call-side change — TLS wiring and verifier selection.
Current state
// crates/alknet-call/src/client/call_client.rs
fn build_quinn_client_config(_credentials: &CallCredentials, alpn: &[u8])
-> Result<quinn::ClientConfig, String>
{
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier)) // ← accepts ANY
.with_no_client_auth(); // ← doesn't present client cert
// ...
}
AcceptAnyServerCertVerifier is a security hole for X.509 (accepts any cert
without CA verification). with_no_client_auth() doesn't present the client's
Ed25519 key, so the server has no client cert to extract a fingerprint from —
the ADR-030 PeerEntry fingerprint → peer_id resolution path is not
activated for quinn connections.
Target state (OQ-29 + ADR-034)
1. TLS client-auth: present Ed25519 key as raw public key client cert
Replace with_no_client_auth() with presenting the client's Ed25519 key as an
RFC 7250 raw public key client cert. This is the client-side equivalent of the
server's RawKeyCertResolver. The CallCredentials.tls_identity carries the
TlsIdentity::RawKey(Ed25519SecretKey) (or X.509 cert pair).
fn build_quinn_client_config(credentials: &CallCredentials, alpn: &[u8])
-> Result<quinn::ClientConfig, String>
{
// 1. Client cert: present Ed25519 raw key (if configured)
let client_auth = build_client_auth(&credentials.tls_identity)?;
// 2. Server cert verifier: by PeerEntry presence (ADR-034 §3)
let verifier = select_server_verifier(&credentials.remote_identity)?;
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_client_auth(client_auth); // ← present the key, not no_client_auth
// ...
}
2. Server cert verifier selection by PeerEntry presence (ADR-034 §3)
Local has PeerEntry for remote? |
Remote cert type | Client verifier |
|---|---|---|
| No (public X.509 endpoint) | X.509 | WebPkiServerVerifier (CA verification) |
| No | Ed25519 raw key | fails closed (no CA to fall back to) |
| Yes (hub, Ed25519 path) | Ed25519 raw key | fingerprint match (ed25519:<hex>) |
| Yes (hub, X.509 path) | X.509 | fingerprint match (SHA256:<hex>) |
CallCredentials.remote_identity: Option<RemoteIdentity> is load-bearing:
Some(fingerprint)→ known peer → fingerprint pin (the fingerprint IS the trust anchor).None→ noPeerEntryfor the remote → CA verification for X.509, fail closed for Ed25519 raw key.Noneis the public-X.509-endpoint state, not a missing field. An implementer must not defaultremote_identityto a placeholder, and must not treatNoneas "skip verification."
fn select_server_verifier(remote_identity: &Option<RemoteIdentity>)
-> Result<Arc<dyn ServerCertVerifier>, String>
{
match remote_identity {
Some(ri) => {
// Known peer → fingerprint pin
Ok(Arc::new(FingerprintPinVerifier::new(ri.fingerprint.clone())))
}
None => {
// Unknown remote → CA verification (WebPkiServerVerifier)
// For Ed25519 raw-key remotes, this fails closed (no CA).
// This is the public-X.509-endpoint path (ADR-034 §2).
let roots = rustls::crypto::aws_lc_rs::default_provider().root_certificates;
Ok(Arc::new(rustls::client::WebPkiServerVerifier::builder(roots.into()).build()?))
}
}
}
3. FingerprintPinVerifier
A new ServerCertVerifier that pins a specific fingerprint:
- For
ed25519:<hex>remotes: extract the raw Ed25519 pub key from the presented cert and match against the pinned fingerprint. - For
SHA256:<hex>remotes: hash the cert DER and match against the pinned fingerprint. - No match → verification failure (connection rejected).
4. CallCredentials
CallCredentials (already defined) carries the three credential dimensions:
pub struct CallCredentials {
pub tls_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
pub auth_token: Option<AuthToken>, // call-protocol-level token
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path)
}
pub struct RemoteIdentity { pub fingerprint: String }
remote_identity: None is load-bearing — the public-X.509-endpoint state
(ADR-034 §2). The implementation must not default it to a placeholder.
What this task does NOT do
- Does NOT change the server-side endpoint (
AcceptAnyCertVerifierin alknet-core is unchanged — it's "request-but-don't-require" for fingerprint extraction). - Does NOT build
PeerCompositeEnv(that'scall/peer-composite-env, a dependency) — but a connection with no resolved identity (noPeerEntry) gets noPeerIdand is not added toPeerCompositeEnv(that's handled incall/peer-composite-env/call/dispatch-peer-identity).
Acceptance Criteria
build_quinn_client_configpresents Ed25519 key as RFC 7250 raw public key client cert (replaceswith_no_client_auth())select_server_verifierselects verifier byremote_identitypresenceSome(fingerprint)→FingerprintPinVerifier(fingerprint match)None+ X.509 →WebPkiServerVerifier(CA verification)None+ Ed25519 raw key → fails closed (no CA to fall back to)FingerprintPinVerifiermatchesed25519:<hex>(raw key extraction) andSHA256:<hex>(DER hash)AcceptAnyServerCertVerifierremoved (security hole for X.509)CallCredentials.remote_identity: Noneis load-bearing (not defaulted to placeholder)- No-env-vars invariant preserved (credentials from Capabilities, not env vars)
- Unit test:
FingerprintPinVerifiermatches correct fingerprint - Unit test:
FingerprintPinVerifierrejects wrong fingerprint - Unit test:
select_server_verifierreturns CA verifier forNone - Unit test: client auth presents Ed25519 key (config built without error)
cargo test -p alknet-callsucceedscargo clippy -p alknet-callsucceeds with no warnings
References
- docs/architecture/crates/call/client-and-adapters.md — CallCredentials, verifier selection, TLS client-auth
- docs/architecture/crates/core/auth.md — Client-side verifier selection table
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 §2-3
- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md — ADR-030 §6 (fingerprint normalization)
Notes
Most security-critical call-side change.
AcceptAnyServerCertVerifieris a security hole for X.509 — replaced by verifier selection byPeerEntrypresence.None+ X.509 = CA verification (public X.509 endpoint);None+ Ed25519 = fail closed (raw-key remotes are always known peers).Some= fingerprint pin (known peer). The client presents its Ed25519 key as a raw public key client cert so the server can extract the fingerprint — this activates the PeerEntry fingerprint → peer_id resolution path on quinn.
Summary
Wired CallClient TLS client-auth (presents Ed25519 key as RFC 7250 raw public key client cert, replacing with_no_client_auth) and server cert verifier selection by PeerEntry presence (ADR-034 §3). Replaced the AcceptAnyServerCertVerifier security hole with: Some(fingerprint) -> FingerprintPinVerifier (ed25519: raw key extraction + SHA256: DER hash, with handshake signature verification); None -> WebPkiServerVerifier (CA verification for public X.509 endpoints; Ed25519 raw-key remotes fail closed). Extracted shared fingerprint logic into a new pub alknet_core::fingerprint module (reused by endpoint server-side and call_client client-side). remote_identity: None kept load-bearing (not defaulted). 11 unit tests + updated 2 integration tests to pin the self-signed server fingerprint. 135 core tests + 252 call unit tests + 2 integration tests pass, clippy clean, fmt clean.