diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index c061983..6e70e5b 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -4328,32 +4328,49 @@ fn explain_auth_for_operator( } let peer_card = store.get_peer_card(subject)?; - let keychain_view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(store)?); + let keychain_ops = load_keychain_ops(store)?; + let keychain_view = geth_keychain::reduce_keychain_ops(&keychain_ops); let subject_node = NodeId::new(subject.to_owned()); let trusted_node = keychain_view.nodes.get(&subject_node); + let revoked_node = keychain_node_is_currently_revoked(&keychain_ops, &subject_node); match (trusted_node, peer_card) { (None, Some(_)) => { - explanation.add_diagnostic("subject:discovered-only"); + if revoked_node { + explanation.add_diagnostic("subject:revoked-node"); + } else { + explanation.add_diagnostic("subject:discovered-only"); + } explanation.add_diagnostic("trust:missing"); if !explanation .reason .contains("discovery does not grant trust or authorization") { + let subject_state = if revoked_node { + "subject is a revoked node with only a discovered peer card remaining" + } else { + "subject is a discovered peer candidate only" + }; explanation.reason = format!( - "subject is a discovered peer candidate only; discovery does not grant trust or authorization; {}", + "{subject_state}; discovery does not grant trust or authorization; {}", explanation.reason ); } } (None, None) => { - explanation.add_diagnostic("subject:unknown"); + if revoked_node { + explanation.add_diagnostic("subject:revoked-node"); + } else { + explanation.add_diagnostic("subject:unknown"); + } explanation.add_diagnostic("trust:missing"); if !explanation.allowed { - explanation.reason = format!( - "subject is not present in the keychain and has no discovered peer card; {}", - explanation.reason - ); + let subject_state = if revoked_node { + "subject was revoked from the active keychain" + } else { + "subject is not present in the keychain and has no discovered peer card" + }; + explanation.reason = format!("{subject_state}; {}", explanation.reason); } } (Some(node), None) => { @@ -4406,6 +4423,18 @@ fn explain_auth_for_operator( Ok(explanation) } +fn keychain_node_is_currently_revoked(ops: &[KeychainOp], node: &NodeId) -> bool { + let mut revoked = false; + for op in ops { + match &op.kind { + KeychainOpKind::NodeAdd { node: op_node, .. } if op_node == node => revoked = false, + KeychainOpKind::NodeRevoke { node: op_node } if op_node == node => revoked = true, + _ => {} + } + } + revoked +} + fn bearer_proof( bearer_secret: Option, resource: &str, diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 58d29fc..adbc425 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -2616,9 +2616,45 @@ fn auth_explain_distinguishes_endpoint_binding_state() { other => panic!("unexpected response: {other:?}"), } + let mismatched_endpoint_op = geth_keychain::KeychainOp { + id: "op:node:endpoint:mismatch".into(), + created_at: geth_types::UnixMillis(created_at + 5), + kind: geth_keychain::KeychainOpKind::NodeEndpointAdd { + node: "node:trusted".into(), + endpoint: "endpoint:not-in-card".to_owned(), + }, + }; + store + .insert_keychain_op(&geth_store::StoredKeychainOp { + op_id: mismatched_endpoint_op.id.to_string(), + op_json: serde_json::to_string(&mismatched_endpoint_op).expect("endpoint op json"), + created_at_ms: mismatched_endpoint_op.created_at.0, + }) + .expect("insert mismatched endpoint op"); + let mismatched = geth_node::handle_request( + &node, + geth_control::ControlRequest::AuthExplain { + subject: "node:trusted".to_owned(), + resource: "resource:cas:local".to_owned(), + capability: "cas.fetch".to_owned(), + }, + ) + .expect("explain mismatched endpoint binding"); + match mismatched { + geth_control::ControlResponse::AuthExplain(explanation) => { + assert!(!explanation.allowed); + assert!( + explanation + .diagnostics + .contains(&"endpoint-binding:missing-for-peer-card".to_owned()) + ); + } + other => panic!("unexpected response: {other:?}"), + } + let endpoint_op = geth_keychain::KeychainOp { id: "op:node:endpoint:add".into(), - created_at: geth_types::UnixMillis(created_at + 5), + created_at: geth_types::UnixMillis(created_at + 6), kind: geth_keychain::KeychainOpKind::NodeEndpointAdd { node: "node:trusted".into(), endpoint: "endpoint:card-only".to_owned(), @@ -2651,6 +2687,42 @@ fn auth_explain_distinguishes_endpoint_binding_state() { } other => panic!("unexpected response: {other:?}"), } + + let revoke_op = geth_keychain::KeychainOp { + id: "op:node:revoke".into(), + created_at: geth_types::UnixMillis(created_at + 7), + kind: geth_keychain::KeychainOpKind::NodeRevoke { + node: "node:trusted".into(), + }, + }; + store + .insert_keychain_op(&geth_store::StoredKeychainOp { + op_id: revoke_op.id.to_string(), + op_json: serde_json::to_string(&revoke_op).expect("revoke op json"), + created_at_ms: revoke_op.created_at.0, + }) + .expect("insert revoke op"); + let revoked = geth_node::handle_request( + &node, + geth_control::ControlRequest::AuthExplain { + subject: "node:trusted".to_owned(), + resource: "resource:cas:local".to_owned(), + capability: "cas.fetch".to_owned(), + }, + ) + .expect("explain revoked node"); + match revoked { + geth_control::ControlResponse::AuthExplain(explanation) => { + assert!(!explanation.allowed); + assert!( + explanation + .diagnostics + .contains(&"subject:revoked-node".to_owned()) + ); + assert!(explanation.reason.contains("revoked node")); + } + other => panic!("unexpected response: {other:?}"), + } } #[test] diff --git a/docs/production-readiness-roadmap.md b/docs/production-readiness-roadmap.md index b192cea..92a8bec 100644 --- a/docs/production-readiness-roadmap.md +++ b/docs/production-readiness-roadmap.md @@ -154,22 +154,22 @@ it. Goal: finish the authorization and remote-input audit before deployment. -- `[~]` Complete remote authorization matrix coverage. +- `[x]` Complete remote authorization matrix coverage. Acceptance criteria: - - `[ ]` Denied and allowed paths are tested for CAS, KV, DB, document, + - `[x]` Denied and allowed paths are tested for CAS, KV, DB, document, pubsub, pipe, SSH proxy/admin shell, SSH cert metadata, and revocations. - `[x]` Every remote mutating or service-opening operation has an explicit resource capability check before mutation or host access. - `[x]` The matrix fails tests when a new remote operation lacks a guard. -- `[ ]` Add adversarial identity and bearer tests. +- `[x]` Add adversarial identity and bearer tests. Acceptance criteria: - - `[ ]` Tests cover discovered-only peers. - - `[ ]` Tests cover wrong endpoint bindings. - - `[ ]` Tests cover revoked nodes. - - `[ ]` Tests cover stale or mismatched peer cards. - - `[ ]` Tests cover bearer secrets with wrong capabilities. - - `[ ]` Tests cover bearer attempts to mutate trust graph state. + - `[x]` Tests cover discovered-only peers. + - `[x]` Tests cover wrong endpoint bindings. + - `[x]` Tests cover revoked nodes. + - `[x]` Tests cover stale or mismatched peer cards. + - `[x]` Tests cover bearer secrets with wrong capabilities. + - `[x]` Tests cover bearer attempts to mutate trust graph state. - `[x]` Bound all remote input paths. Acceptance criteria: @@ -342,7 +342,7 @@ Goal: prove the system works as an actual base layer before broader use. 2. `[~]` Refactor `geth-node` into daemon subsystems. 3. `[x]` Add stable contract and golden JSON tests. 4. `[x]` Harden store migrations and backup. -5. `[ ]` Complete security-boundary test coverage. +5. `[x]` Complete security-boundary test coverage. 6. `[x]` Replace prototype private CAS cryptography. 7. `[ ]` Add fault-injection sync tests. 8. `[ ]` Improve automation commands and JSON errors.