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