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 tracing::{debug, warn};
use super::abort::AbortCascade;
use super::connection::CallConnection;
use super::wire::{
CallError, EventEnvelope, FrameFramedReader, FrameFramedWriter, ResponseEnvelope,
EVENT_REQUESTED,
EVENT_ABORTED, EVENT_REQUESTED,
};
use crate::registry::context::{AbortPolicy, OperationContext, ScopedOperationEnv};
use crate::registry::env::{CompositeOperationEnv, LocalOperationEnv, OperationEnv};
@@ -207,22 +208,34 @@ impl CallAdapter {
}
};
if envelope.r#type != EVENT_REQUESTED {
debug!(event_type = %envelope.r#type, id = %envelope.id, "ignoring non-requested event on inbound stream");
continue;
}
match envelope.r#type.as_str() {
EVENT_REQUESTED => {
let request_id = envelope.id.clone();
let payload = envelope.payload.clone();
let request_id = envelope.id.clone();
let payload = envelope.payload.clone();
let response = self
.dispatch_requested(&connection, request_id.clone(), payload)
.await;
let response = self
.dispatch_requested(&connection, request_id.clone(), payload)
.await;
let event: EventEnvelope = response.into();
if let Err(err) = writer.write_frame(&event).await {
warn!(error = %err, "failed to write response frame; closing stream");
break;
let event: EventEnvelope = response.into();
if let Err(err) = writer.write_frame(&event).await {
warn!(error = %err, "failed to write response frame; closing stream");
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::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Mutex as StdMutex;
use std::time::{Duration, Instant};
struct StaticIdentityProvider {
tokens: StdMutex<HashMap<String, Identity>>,
@@ -946,4 +960,92 @@ mod tests {
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}");
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 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());
tokio::spawn(async move {
if let Err(e) = handler.handle(conn, &auth).await {
@@ -325,6 +326,25 @@ fn extract_quinn_alpn(connection: &quinn::Connection) -> Vec<u8> {
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")]
async fn run_iroh_accept_loop(
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);
tokio::spawn(async move {
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"))]
fn build_auth_context(
alpn: &[u8],
@@ -979,4 +1006,40 @@ mod tests {
let unknown = registry.get(b"alknet/unknown");
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.
/// The internal phrase is zeroized on drop.
#[derive(Debug)]
pub struct Mnemonic {
inner: Bip39Mnemonic,
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 {
/// Generate a new random mnemonic with the given word count.
///
@@ -163,4 +170,20 @@ mod tests {
assert_eq!(seed.len(), 64);
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}"
);
}
}
}