fix: resolve review #004 findings W1-W4 + close review gate

W1 (call/protocol/abort-cascade-wiring): wire AbortCascade into
CallAdapter handle_stream for EVENT_ABORTED. Cascades with
AbortPolicy::AbortDependents, aborts root, no descendant frames on
wire (ADR-016 Decision 2). Two integration tests added.

W2 (core/endpoint-client-fingerprint): extract TLS client cert
fingerprint in dispatch_quinn (SHA256:<hex> of leaf cert DER via
peer_identity) and dispatch_iroh (ed25519:<hex> of peer NodeId).
Fingerprint format documented in auth.md. Server config change
(with_no_client_auth → request-but-don't-require) deferred to new
follow-up task core/endpoint-request-client-cert.

W3 (vault/mnemonic-debug-redaction): replace Mnemonic derive(Debug)
with manual redacting impl (phrase: "[REDACTED]"). Seed confirmed
no Debug impl. Redaction test added.

W4 (core/auth-apikey-resources): Option B — drop entry.resources from
spec. External identities (token/fingerprint) grant scopes only;
resource-scoped ACLs are composition-internal (ADR-015/022). auth.md
corrected + limitation documented. Two tests confirm empty resources.

review-post-impl-fixes: all 4 verified, workspace green (326 tests,
0 failures, 0 clippy warnings). Review #004 status → resolved.

Graph: 34 tasks, 12 gens.
This commit is contained in:
2026-06-24 11:00:54 +00:00
parent d149932e2a
commit 97216764ea
12 changed files with 492 additions and 32 deletions

View File

@@ -16,10 +16,11 @@ use serde_json::Value;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::{debug, warn}; use tracing::{debug, warn};
use super::abort::AbortCascade;
use super::connection::CallConnection; use super::connection::CallConnection;
use super::wire::{ use super::wire::{
CallError, EventEnvelope, FrameFramedReader, FrameFramedWriter, ResponseEnvelope, CallError, EventEnvelope, FrameFramedReader, FrameFramedWriter, ResponseEnvelope,
EVENT_REQUESTED, EVENT_ABORTED, EVENT_REQUESTED,
}; };
use crate::registry::context::{AbortPolicy, OperationContext, ScopedOperationEnv}; use crate::registry::context::{AbortPolicy, OperationContext, ScopedOperationEnv};
use crate::registry::env::{CompositeOperationEnv, LocalOperationEnv, OperationEnv}; use crate::registry::env::{CompositeOperationEnv, LocalOperationEnv, OperationEnv};
@@ -207,11 +208,8 @@ impl CallAdapter {
} }
}; };
if envelope.r#type != EVENT_REQUESTED { match envelope.r#type.as_str() {
debug!(event_type = %envelope.r#type, id = %envelope.id, "ignoring non-requested event on inbound stream"); EVENT_REQUESTED => {
continue;
}
let request_id = envelope.id.clone(); let request_id = envelope.id.clone();
let payload = envelope.payload.clone(); let payload = envelope.payload.clone();
@@ -225,6 +223,21 @@ impl CallAdapter {
break; break;
} }
} }
EVENT_ABORTED => {
let request_id = envelope.id.clone();
let mut pending = connection.pending().lock();
let mut cascade = AbortCascade::new(&mut pending);
let aborted = cascade.cascade_abort(&request_id, AbortPolicy::AbortDependents);
pending.handle_aborted(&request_id);
if !aborted.is_empty() {
debug!(count = aborted.len(), "abort cascade evicted descendants");
}
}
other => {
debug!(event_type = %other, id = %envelope.id, "ignoring non-requested/non-aborted event on inbound stream");
}
}
}
} }
} }
@@ -312,6 +325,7 @@ mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Mutex as StdMutex; use std::sync::Mutex as StdMutex;
use std::time::{Duration, Instant};
struct StaticIdentityProvider { struct StaticIdentityProvider {
tokens: StdMutex<HashMap<String, Identity>>, tokens: StdMutex<HashMap<String, Identity>>,
@@ -946,4 +960,92 @@ mod tests {
other => panic!("expected NOT_FOUND, got {other:?}"), other => panic!("expected NOT_FOUND, got {other:?}"),
} }
} }
fn encode_frame(envelope: &EventEnvelope) -> Vec<u8> {
let body = serde_json::to_vec(envelope).unwrap();
let mut buf = (body.len() as u32).to_be_bytes().to_vec();
buf.extend_from_slice(&body);
buf
}
#[tokio::test]
async fn handle_stream_aborted_cascades_parent_and_child() {
let registry = registry_with(
"parent/run",
Visibility::External,
AccessControl::default(),
echo_handler(),
);
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let adapter = CallAdapter::new(registry, provider);
let conn = Arc::new(CallConnection::new(stub_connection()));
{
let mut pending = conn.pending().lock();
pending.register_call(
"parent-1".to_string(),
Instant::now() + Duration::from_secs(30),
None,
);
pending.register_call(
"child-1".to_string(),
Instant::now() + Duration::from_secs(30),
Some("parent-1".to_string()),
);
}
let frame = encode_frame(&EventEnvelope::aborted("parent-1"));
let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame));
let (send, _recv_sink) = tokio::io::duplex(64);
let send = alknet_core::types::SendStream::from_mock(send);
let recv = alknet_core::types::RecvStream::from_mock(recv);
adapter.handle_stream(conn.clone(), send, recv).await;
let pending = conn.pending().lock();
assert!(
!pending.contains("parent-1"),
"parent entry must be removed after abort"
);
assert!(
!pending.contains("child-1"),
"child entry must be removed by cascade"
);
}
#[tokio::test]
async fn handle_stream_aborted_unknown_request_id_is_noop() {
let registry = registry_with(
"parent/run",
Visibility::External,
AccessControl::default(),
echo_handler(),
);
let provider: Arc<dyn IdentityProvider> = Arc::new(StaticIdentityProvider::new());
let adapter = CallAdapter::new(registry, provider);
let conn = Arc::new(CallConnection::new(stub_connection()));
{
let mut pending = conn.pending().lock();
pending.register_call(
"unrelated-1".to_string(),
Instant::now() + Duration::from_secs(30),
None,
);
}
let frame = encode_frame(&EventEnvelope::aborted("does-not-exist"));
let recv = tokio::io::BufReader::new(std::io::Cursor::new(frame));
let (send, _recv_sink) = tokio::io::duplex(64);
let send = alknet_core::types::SendStream::from_mock(send);
let recv = alknet_core::types::RecvStream::from_mock(recv);
adapter.handle_stream(conn.clone(), send, recv).await;
let pending = conn.pending().lock();
assert!(
pending.contains("unrelated-1"),
"unrelated entry must survive abort of unknown id"
);
}
} }

View File

@@ -318,4 +318,52 @@ mod tests {
let s = format!("{e}"); let s = format!("{e}");
assert!(s.starts_with("tls config error:")); assert!(s.starts_with("tls config error:"));
} }
#[test]
fn resolve_api_key_returns_empty_resources() {
use sha2::{Digest, Sha256};
let token = "alk_test_secret";
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let hash = format!("sha256:{}", hex::encode(hasher.finalize()));
let entry = ApiKeyEntry {
prefix: "alk_tes".to_string(),
hash,
scopes: vec!["admin".to_string()],
description: "test key".to_string(),
expires_at: None,
};
let policy = AuthPolicy {
authorized_fingerprints: HashSet::new(),
api_keys: vec![entry],
};
let identity = policy.resolve_api_key(token);
assert!(identity.is_some(), "api key with matching prefix and hash should resolve");
let identity = identity.unwrap();
assert_eq!(identity.id, "alk_tes");
assert_eq!(identity.scopes, vec!["admin"]);
assert!(
identity.resources.is_empty(),
"token-resolved identities must have empty resources (Option B — scopes only)"
);
}
#[test]
fn resolve_identity_from_fingerprint_returns_empty_resources() {
let policy = AuthPolicy {
authorized_fingerprints: HashSet::from(["SHA256:known".to_string()]),
api_keys: vec![],
};
let identity = policy
.resolve_identity_from_fingerprint("SHA256:known")
.expect("known fingerprint should resolve");
assert_eq!(identity.id, "SHA256:known");
assert!(
identity.resources.is_empty(),
"fingerprint-resolved identities must have empty resources (Option B — scopes only)"
);
}
} }

View File

@@ -303,7 +303,8 @@ fn dispatch_quinn(
}; };
let remote_addr = Some(connection.remote_address()); let remote_addr = Some(connection.remote_address());
let auth = build_auth_context(&alpn, remote_addr, None, identity_provider); let fingerprint = extract_quinn_client_fingerprint(&connection);
let auth = build_auth_context(&alpn, remote_addr, fingerprint, identity_provider);
let conn = Connection::from_quinn_with_alpn(connection, alpn.clone()); let conn = Connection::from_quinn_with_alpn(connection, alpn.clone());
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handler.handle(conn, &auth).await { if let Err(e) = handler.handle(conn, &auth).await {
@@ -325,6 +326,25 @@ fn extract_quinn_alpn(connection: &quinn::Connection) -> Vec<u8> {
Vec::new() Vec::new()
} }
#[cfg(feature = "quinn")]
fn extract_quinn_client_fingerprint(connection: &quinn::Connection) -> Option<String> {
let identity = connection.peer_identity()?;
let certs = identity
.downcast::<Vec<rustls::pki_types::CertificateDer>>()
.ok()?;
let leaf = certs.first()?;
fingerprint_from_cert_der(leaf.as_ref())
}
#[cfg(any(feature = "quinn", feature = "iroh"))]
fn fingerprint_from_cert_der(cert_der: &[u8]) -> Option<String> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(cert_der);
let digest = hasher.finalize();
Some(format!("SHA256:{}", hex::encode(digest)))
}
#[cfg(feature = "iroh")] #[cfg(feature = "iroh")]
async fn run_iroh_accept_loop( async fn run_iroh_accept_loop(
iroh: iroh::Endpoint, iroh: iroh::Endpoint,
@@ -393,7 +413,8 @@ fn dispatch_iroh(
} }
}; };
let auth = build_auth_context(&alpn, None, None, identity_provider); let fingerprint = extract_iroh_client_fingerprint(&connection);
let auth = build_auth_context(&alpn, None, fingerprint, identity_provider);
let conn = Connection::from_iroh(connection); let conn = Connection::from_iroh(connection);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handler.handle(conn, &auth).await { if let Err(e) = handler.handle(conn, &auth).await {
@@ -402,6 +423,12 @@ fn dispatch_iroh(
}); });
} }
#[cfg(feature = "iroh")]
fn extract_iroh_client_fingerprint(connection: &iroh::endpoint::Connection) -> Option<String> {
let node_id = connection.remote_node_id().ok()?;
Some(format!("ed25519:{}", node_id))
}
#[cfg(any(feature = "quinn", feature = "iroh"))] #[cfg(any(feature = "quinn", feature = "iroh"))]
fn build_auth_context( fn build_auth_context(
alpn: &[u8], alpn: &[u8],
@@ -979,4 +1006,40 @@ mod tests {
let unknown = registry.get(b"alknet/unknown"); let unknown = registry.get(b"alknet/unknown");
assert!(unknown.is_none(), "unknown ALPN has no handler"); assert!(unknown.is_none(), "unknown ALPN has no handler");
} }
#[cfg(any(feature = "quinn", feature = "iroh"))]
#[test]
fn fingerprint_from_cert_der_produces_sha256_hex_format() {
let cert_der = b"fake-leaf-cert-der-bytes";
let fp = fingerprint_from_cert_der(cert_der).expect("non-empty cert produces fingerprint");
assert!(
fp.starts_with("SHA256:"),
"fingerprint must be SHA256-prefixed, got: {fp}"
);
let hex_part = &fp["SHA256:".len()..];
assert_eq!(
hex_part.len(),
64,
"hex digest must be 64 chars (32 bytes), got: {fp}"
);
assert!(
hex_part.chars().all(|c| c.is_ascii_hexdigit()),
"hex part must be lowercase hex, got: {fp}"
);
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(cert_der);
let expected = format!("SHA256:{}", hex::encode(hasher.finalize()));
assert_eq!(fp, expected, "fingerprint must match SHA-256 of cert DER");
}
#[cfg(any(feature = "quinn", feature = "iroh"))]
#[test]
fn fingerprint_from_cert_der_deterministic() {
let cert = b"some-cert";
let a = fingerprint_from_cert_der(cert).unwrap();
let b = fingerprint_from_cert_der(cert).unwrap();
assert_eq!(a, b, "same cert DER must produce same fingerprint");
}
} }

View File

@@ -32,12 +32,19 @@ impl From<Language> for bip39::Language {
/// ///
/// Wraps the `bip39` crate's `Mnemonic` type and provides seed derivation. /// Wraps the `bip39` crate's `Mnemonic` type and provides seed derivation.
/// The internal phrase is zeroized on drop. /// The internal phrase is zeroized on drop.
#[derive(Debug)]
pub struct Mnemonic { pub struct Mnemonic {
inner: Bip39Mnemonic, inner: Bip39Mnemonic,
phrase: String, phrase: String,
} }
impl std::fmt::Debug for Mnemonic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Mnemonic")
.field("phrase", &"[REDACTED]")
.finish()
}
}
impl Mnemonic { impl Mnemonic {
/// Generate a new random mnemonic with the given word count. /// Generate a new random mnemonic with the given word count.
/// ///
@@ -163,4 +170,20 @@ mod tests {
assert_eq!(seed.len(), 64); assert_eq!(seed.len(), 64);
assert!(!seed.is_empty()); assert!(!seed.is_empty());
} }
#[test]
fn test_mnemonic_debug_redacts_phrase() {
let mnemonic = Mnemonic::generate(24).unwrap();
let debug_output = format!("{:?}", mnemonic);
assert!(
debug_output.contains("[REDACTED]"),
"Debug must show [REDACTED] for phrase, got: {debug_output}"
);
for word in mnemonic.phrase().split_whitespace() {
assert!(
!debug_output.contains(word),
"Debug must not leak phrase word '{word}', got: {debug_output}"
);
}
}
} }

View File

@@ -150,10 +150,41 @@ The "Config" prefix indicates that identities are resolved from configuration (a
How it resolves: How it resolves:
- **Fingerprint**: Look up in `DynamicConfig::auth::authorized_keys_fingerprints`. If found, return `Identity { id: fingerprint, scopes: ["relay:connect"], resources: {} }`. - **Fingerprint**: Look up in `DynamicConfig::auth::authorized_keys_fingerprints`. If found, return `Identity { id: fingerprint, scopes: ["relay:connect"], resources: {} }`.
- **Token**: Parse as UTF-8. If it starts with `alk_`, look up in `DynamicConfig::auth::api_keys` by prefix match + SHA-256 hash. If found and not expired, return `Identity { id: prefix, scopes: entry.scopes, resources: entry.resources }`. - **Token**: Parse as UTF-8. If it starts with `alk_`, look up in `DynamicConfig::auth::api_keys` by prefix match + SHA-256 hash. If found and not expired, return `Identity { id: prefix, scopes: entry.scopes, resources: {} }`.
> **Resource-scoped ACLs and external identities.** `Identity.resources` is
> populated only by the composition path (`CompositionAuthority::as_identity`,
> ADR-015/022) — never by token or fingerprint resolvers. API keys and
> fingerprints grant **scopes only**; resource-scoped access is an
> internal-composition concern. An `OperationSpec` that declares
> `resource_type`/`resource_action` will return `FORBIDDEN` when the caller
> authenticated via token or fingerprint, because `Identity.resources` is
> empty. This is a documented limitation, not a bug: if a future crate needs
> per-key resource binding, it must earn a dedicated ADR that adds a
> `resources` field to `ApiKeyEntry` and the fingerprint config path, rather
> than silently widening the external-auth contract.
Changes to `DynamicConfig` via `ConfigReloadHandle` are reflected immediately — `ConfigIdentityProvider` reads from `ArcSwap` on every call. Changes to `DynamicConfig` via `ConfigReloadHandle` are reflected immediately — `ConfigIdentityProvider` reads from `ArcSwap` on every call.
### Fingerprint string format
`tls_client_fingerprint` and `authorized_fingerprints` use a prefixed-hex
format. The prefix identifies the key type; the body is the hex-encoded
hash or raw key bytes. `AuthPolicy::resolve_identity_from_fingerprint`
does a literal `HashSet::contains()` — no normalization — so the extractor
and the operator config must use the same format.
| Transport | Source | Format |
|-----------|--------|--------|
| quinn (X.509) | leaf client cert DER | `SHA256:<hex of SHA-256(cert_der)>` |
| iroh (raw Ed25519) | peer `NodeId` | `ed25519:<lowercase hex of 32-byte pub key>` |
When no client cert is presented (the current default — server uses
`with_no_client_auth()`), the fingerprint is `None` and identity remains
unresolved at the endpoint layer. A follow-up task will switch the server
config to request-but-not-require client certs so fingerprints flow for
peers that present them.
## Resolution Flow ## Resolution Flow
### Endpoint-level (before `handle()`) ### Endpoint-level (before `handle()`)

View File

@@ -1,6 +1,6 @@
--- ---
status: open status: resolved
last_updated: 2026-06-23 last_updated: 2026-06-24
reviewed_artifacts: reviewed_artifacts:
- crates/alknet-vault/src/{lib,cache,derivation,encryption,ethereum,mnemonic,protocol,service}.rs - crates/alknet-vault/src/{lib,cache,derivation,encryption,ethereum,mnemonic,protocol,service}.rs
- crates/alknet-core/src/{lib,auth,config,endpoint,types}.rs - crates/alknet-core/src/{lib,auth,config,endpoint,types}.rs
@@ -567,3 +567,43 @@ Review #004 is open. Zero critical findings; four warnings, all local; five
suggestions. The implementation is sound, the spec drift is bounded, and the suggestions. The implementation is sound, the spec drift is bounded, and the
one wiring gap (W1) has all the hard logic already written and tested — it one wiring gap (W1) has all the hard logic already written and tested — it
just needs to be called. just needs to be called.
---
## Resolution (2026-06-24)
All four warnings (W1W4) resolved. Workspace green:
`cargo build --workspace --all-features`, `cargo test --workspace
--all-features` (326 tests, 0 failures), `cargo clippy --workspace
--all-features --all-targets` (0 warnings).
- **W1 (abort cascade wiring)**: `CallAdapter::handle_stream` now
matches `EVENT_ABORTED`, invokes `AbortCascade::cascade_abort` with
`AbortPolicy::AbortDependents`, and aborts the root. No descendant
`call.aborted` frames sent on the wire (ADR-016 Decision 2). Two
integration tests cover the cascade + unknown-id no-op paths.
(`tasks/call/protocol/abort-cascade-wiring.md` → completed)
- **W2 (fingerprint extraction)**: `dispatch_quinn` extracts the leaf
client cert DER via `peer_identity()` → `SHA256:<hex>`; `dispatch_iroh`
extracts the peer `NodeId` → `ed25519:<hex>`. Fingerprint format
documented in `auth.md`. Server config still uses
`with_no_client_auth()` — extraction is a safe no-op until the
follow-up task `core/endpoint-request-client-cert` switches to
request-but-don't-require. Two unit tests cover fingerprint format +
determinism.
(`tasks/core/endpoint-client-fingerprint.md` → completed)
- **W3 (Mnemonic Debug redaction)**: `#[derive(Debug)]` replaced with
manual redacting impl matching the `DerivedKey` pattern. `Seed`
confirmed to have no `Debug` impl. Test asserts no phrase word leaks.
(`tasks/vault/mnemonic-debug-redaction.md` → completed)
- **W4 (ApiKeyEntry resources)**: Option B chosen — spec corrected to
drop `entry.resources`; `auth.md` now documents that external
identities (token/fingerprint) grant scopes only, resource-scoped
ACLs are a composition-internal concern (ADR-015/022). Two tests
confirm both resolvers return empty resources.
(`tasks/core/auth-apikey-resources.md` → completed)
S1S5 (suggestions) remain opportunistic; not gated by this review.

View File

@@ -1,7 +1,7 @@
--- ---
id: call/protocol/abort-cascade-wiring id: call/protocol/abort-cascade-wiring
name: Wire AbortCascade into CallAdapter inbound event path (ADR-016) name: Wire AbortCascade into CallAdapter inbound event path (ADR-016)
status: pending status: completed
depends_on: [call/protocol/abort-cascade] depends_on: [call/protocol/abort-cascade]
scope: narrow scope: narrow
risk: medium risk: medium
@@ -128,3 +128,13 @@ frame actually reaches `cascade_abort`.
> This task closes that integration gap — all the hard logic already > This task closes that integration gap — all the hard logic already
> exists and is tested; this task adds the ~30-line bolt and the one > exists and is tested; this task adds the ~30-line bolt and the one
> integration test that would have caught the gap. > integration test that would have caught the gap.
## Summary
`handle_stream` now matches `EVENT_ABORTED` → invokes
`AbortCascade::cascade_abort` with `AbortPolicy::AbortDependents`, then
aborts the root. Non-requested/non-aborted events still log at `debug!`.
No descendant `call.aborted` frames sent on the wire. Two integration
tests: cascade removes parent + child from `PendingRequestMap`; unknown
request_id is a no-op. `cargo test -p alknet-call` (161 tests) and
clippy clean.

View File

@@ -1,7 +1,7 @@
--- ---
id: core/auth-apikey-resources id: core/auth-apikey-resources
name: Reconcile ApiKeyEntry.resources — add field to type and populate in resolve_api_key, or drop from spec name: Reconcile ApiKeyEntry.resources — add field to type and populate in resolve_api_key, or drop from spec
status: pending status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
risk: low risk: low
@@ -115,3 +115,16 @@ applied to handler-internal composition identities
> implementation is more than ~30 lines, split a follow-up > implementation is more than ~30 lines, split a follow-up
> `level: implementation` task (`core/auth-apikey-resources-impl`) > `level: implementation` task (`core/auth-apikey-resources-impl`)
> depending on this one. > depending on this one.
## Summary
Decision: **Option B** — dropped `entry.resources` from the spec.
Rationale: `Identity.resources` is populated only by
`CompositionAuthority::as_identity` (the composition path, ADR-015/022).
All architecture examples use scope-based ACLs for external identities
(`fs:read`, `vastai:query`, `llm:call`). Adding a second
resource-population path for API keys would muddy the external/internal
separation without a demonstrated downstream need. `auth.md:153`
corrected to `resources: {}`; documented limitation added. Two tests
confirm both `resolve_api_key` and `resolve_identity_from_fingerprint`
return empty resources. `cargo test -p alknet-core` and clippy clean.

View File

@@ -1,7 +1,7 @@
--- ---
id: core/endpoint-client-fingerprint id: core/endpoint-client-fingerprint
name: Extract TLS client certificate fingerprint in endpoint dispatch (ADR-004) name: Extract TLS client certificate fingerprint in endpoint dispatch (ADR-004)
status: pending status: completed
depends_on: [] depends_on: []
scope: narrow scope: narrow
risk: medium risk: medium
@@ -116,3 +116,16 @@ in the codebase before committing.
> `None` when no cert was presented, which is the current behavior, so > `None` when no cert was presented, which is the current behavior, so
> landing extraction first is a safe no-op until the server config > landing extraction first is a safe no-op until the server config
> changes. > changes.
## Summary
Added `extract_quinn_client_fingerprint` (leaf cert DER → `SHA256:<hex>`
via `peer_identity()` downcast) and `extract_iroh_client_fingerprint`
(peer `NodeId``ed25519:<hex>`). Both dispatch functions now pass the
extracted fingerprint to `build_auth_context`. Fingerprint format
documented in `auth.md` (table: quinn X.509 vs iroh raw Ed25519).
Server config still uses `with_no_client_auth()` — extraction is a safe
no-op. Follow-up task `core/endpoint-request-client-cert` created for the
server config change. Two unit tests cover fingerprint format +
determinism. `cargo test -p alknet-core --all-features` (59 tests) and
clippy clean.

View File

@@ -0,0 +1,90 @@
---
id: core/endpoint-request-client-cert
name: Switch rustls ServerConfig from with_no_client_auth to request-but-don't-require client certs
status: pending
depends_on: [core/endpoint-client-fingerprint]
scope: narrow
risk: medium
impact: component
level: implementation
---
## Description
`core/endpoint-client-fingerprint` landed the extraction logic: when a
client certificate *is* presented, `dispatch_quinn` / `dispatch_iroh`
extract the fingerprint and populate `AuthContext`. However, the server
still builds `rustls::ServerConfig` with `with_no_client_auth()` in all
three `TlsIdentity` branches (`endpoint.rs:477`, `490`, `501`), so the
server never *requests* a client cert. Extraction is a safe no-op until
this task changes the server-side TLS config.
This follow-up switches from `with_no_client_auth()` to a
request-but-don't-require mode so that peers presenting a client cert
(X.509 or RFC 7250 raw Ed25519 key) flow through the extraction path
landed in the predecessor task, while peers without a cert still connect
without regression.
### Design decision: how to request-but-not-require
rustls does not have a direct `with_optional_client_auth()` builder.
The standard approach is:
1. Build the config with `.with_client_auth(verifier)` where `verifier`
is a custom `ServerCertVerifier` that accepts any presentation (returns
`Ok(Certified::yes())` when a cert is presented, `Ok(Certified::no())`
when none is presented — rustls 0.23's `WebPkiServerVerifier` cannot
be used directly for optional auth).
2. Alternatively, use `rustls::server::WebPkiServerVerifier` with a
`NoClientAuth` fallback — check the exact rustls API available in the
pinned version before implementing.
Read the rustls API docs for the pinned version
(`rustls::server::ServerConfig::builder_with_provider`) and confirm the
correct verifier construction. The key property: a peer *may* present a
cert, and if it does, `peer_identity()` returns it; if it doesn't, the
connection still succeeds.
### iroh path
iroh's `Endpoint` builder uses its own TLS session internally. For the
raw-key path (`TlsIdentity::RawKey`), iroh already advertises
`only_raw_public_keys()` via `RawKeyCertResolver` — the server-side half
of RFC 7250. The client-side presentation is set by the client's
`rustls::ClientConfig`, not the server. So the iroh path may already
receive peer identities when the client is an iroh node (the `NodeId` is
always in the TLS cert). Verify: does `Connection::remote_node_id()`
already work for iroh connections today, or does it require the server to
request client certs? If iroh always presents a cert (raw-key mode), no
server-side change is needed for the iroh path — only quinn/X.509 needs
this task. Confirm before implementing.
## Acceptance Criteria
- [ ] `build_rustls_server_config` uses request-but-don't-require client auth (not `with_no_client_auth()`) for at least the X.509 path
- [ ] Peer presenting a client cert: `peer_identity()` returns the cert chain → fingerprint extraction works end-to-end
- [ ] Peer without a client cert: connection still succeeds, `tls_client_fingerprint` is `None` (no regression)
- [ ] iroh path: confirm whether a server-side change is needed; if yes, apply it; if no, document why
- [ ] Integration test: quinn endpoint with a client that presents a cert → `AuthContext.tls_client_fingerprint` is `Some(SHA256:...)`
- [ ] Integration test: quinn endpoint with a client that presents no cert → `AuthContext.tls_client_fingerprint` is `None` and connection succeeds
- [ ] `cargo test -p alknet-core --all-features` succeeds
- [ ] `cargo clippy -p alknet-core --all-features --all-targets` succeeds with no warnings
- [ ] `auth.md` updated: server-config decision documented (request-but-don't-require, not no-client-auth)
## References
- tasks/core/endpoint-client-fingerprint.md — predecessor task (landed extraction, deferred this config change)
- crates/alknet-core/src/endpoint.rs:477, 490, 501 — the three `with_no_client_auth()` sites
- crates/alknet-core/src/endpoint.rs — `extract_quinn_client_fingerprint` / `extract_iroh_client_fingerprint` (already landed, waiting for certs to flow)
- docs/architecture/crates/core/auth.md — fingerprint format table and endpoint-level resolution flow
- docs/architecture/decisions/004-auth-as-shared-core.md — ADR-004 (hybrid resolution)
- docs/architecture/open-questions.md — OQ-12 (TLS identity provisioning)
## Notes
> Split from `core/endpoint-client-fingerprint` per the task's own
> suggestion: extraction is correct either way (returns `None` when no
> cert), so landing it first is a safe no-op. This task is the
> behavioral change that makes fingerprints actually flow. The risk is
> medium because it alters the TLS handshake for every connection —
> ensure the no-cert-peer case has explicit test coverage.

View File

@@ -1,7 +1,7 @@
--- ---
id: review-post-impl-fixes id: review-post-impl-fixes
name: Review the four post-implementation sanity-check #004 fixes for spec conformance name: Review the four post-implementation sanity-check #004 fixes for spec conformance
status: pending status: completed
depends_on: [call/protocol/abort-cascade-wiring, core/endpoint-client-fingerprint, vault/mnemonic-debug-redaction, core/auth-apikey-resources] depends_on: [call/protocol/abort-cascade-wiring, core/endpoint-client-fingerprint, vault/mnemonic-debug-redaction, core/auth-apikey-resources]
scope: moderate scope: moderate
risk: low risk: low
@@ -94,3 +94,22 @@ check.md`, does not introduce new spec drift, and is adequately tested.
> `impact: phase`. It does not need to re-derive the findings — review > `impact: phase`. It does not need to re-derive the findings — review
> #004 already did that work. It only needs to confirm the fixes land > #004 already did that work. It only needs to confirm the fixes land
> correctly and the workspace stays green. > correctly and the workspace stays green.
## Summary
All four fixes verified against acceptance criteria:
- W1: `handle_stream` handles `EVENT_ABORTED`, cascades with
`AbortDependents`, no descendant frames on wire, root removed, two
integration tests pass.
- W2: both dispatch paths extract fingerprints, format documented in
`auth.md`, no-cert case returns `None` (no regression), server-config
change deferred to `core/endpoint-request-client-cert`.
- W3: `Mnemonic` has manual redacting `Debug`, `Seed` has no `Debug`,
redaction test passes.
- W4: Option B — spec corrected, limitation documented, both resolvers
return empty resources, tests pass.
Workspace green: `cargo build --workspace --all-features` ✓, `cargo test
--workspace --all-features` (326 tests, 0 failures) ✓, `cargo clippy
--workspace --all-features --all-targets` (0 warnings) ✓. Review #004
status updated to `resolved`.

View File

@@ -1,7 +1,7 @@
--- ---
id: vault/mnemonic-debug-redaction id: vault/mnemonic-debug-redaction
name: Replace Mnemonic derive(Debug) with redacting impl to prevent seed phrase leak name: Replace Mnemonic derive(Debug) with redacting impl to prevent seed phrase leak
status: pending status: completed
depends_on: [] depends_on: []
scope: single scope: single
risk: low risk: low
@@ -108,3 +108,11 @@ fn test_mnemonic_debug_redacts_phrase() {
> Small fix, but eliminates a latent root-of-trust leak. The same > Small fix, but eliminates a latent root-of-trust leak. The same
> pattern (custom redacting `Debug`) is already established in three > pattern (custom redacting `Debug`) is already established in three
> other places in this codebase — this task brings `Mnemonic` in line. > other places in this codebase — this task brings `Mnemonic` in line.
## Summary
Replaced `#[derive(Debug)]` on `Mnemonic` with a manual redacting impl
(`phrase: "[REDACTED]"`). Added `test_mnemonic_debug_redacts_phrase`
asserting no phrase word appears in `format!("{:?}", mnemonic)`.
Confirmed `Seed` has no `Debug` impl (derives only `Clone, Zeroize`).
`cargo test -p alknet-vault` and clippy clean.