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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user