From 1336fa38e84f647af05dcd7112af24b78513486c Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Thu, 21 May 2026 18:15:10 +0200 Subject: [PATCH] Harden signed node authorization flow --- AGENTS.md | 11 +- README.md | 21 ++- crates/geth-cli/src/lib.rs | 98 ++++++++++++- crates/geth-control/src/lib.rs | 19 +++ crates/geth-node/src/lib.rs | 190 ++++++++++++++++++++++--- crates/geth/tests/bootstrap.rs | 50 ++++++- docs/adr/0016-owner-node-enrollment.md | 11 +- docs/architecture.md | 20 +-- docs/roadmap.md | 17 +-- 9 files changed, 382 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eb26e78..605ad02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,11 +119,12 @@ Roadmap items should be actionable and checkable: users, devices, nodes, agents, and endpoint bindings. - The auth reducer builds a current permission view for resources, grants, groups, and bearer access. The daemon persists local auth grant/revoke ops - and uses them for `auth explain`. Enrollment approval signs auth ops, and - `geth auth sync ` imports only auth ops signed by currently trusted - admin keys. `kv set --subject ` enforces local KV write grants for - non-local test callers. Broader daemon-side module enforcement is still - roadmap work. + and uses them for `auth explain`. Enrollment approval, `geth node + grant/revoke-grant`, and `geth auth grant/revoke` sign auth ops through + OpenSSH when invoked through the CLI. `geth auth sync ` imports only + auth ops signed by currently trusted admin keys. `kv set --subject + ` enforces local KV write grants for non-local test callers. + Broader daemon-side module enforcement is still roadmap work. - The daemon persists local keychain ops and reduces them for `keychain status`. `geth init --admin-key --signing-key --node-name ` records signed owner/user/device/node/agent binding operations. `keychain diff --git a/README.md b/README.md index 3d84954..ffa4414 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ keychain operation log from an imported peer and imports only operations with valid OpenSSH signatures from currently trusted admin keys. `geth node list` shows the active reduced node view, and `geth node rename/revoke` require `--signing-key` so device-management changes can replicate as verified admin -statements. `geth node grant/revoke-grant` records the current resource-scoped -capability prototype. +statements. `geth node grant/revoke-grant` and `geth auth grant/revoke` also +require `--signing-key` in the CLI and store signed auth operations for +replication. SSH certificate-flow and revocation records carry agent-key signed provenance over canonical payloads, and sync import rejects new unsigned or invalidly signed records. @@ -100,8 +101,10 @@ The bootstrap implementation provides: - `geth node enroll sync ` - `geth node rename --signing-key ` - `geth node revoke --signing-key ` -- `geth node grant [--grant-id ]` -- `geth node revoke-grant ` +- `geth node endpoint-add --signing-key ` +- `geth node endpoint-revoke --signing-key ` +- `geth node grant --signing-key [--grant-id ]` +- `geth node revoke-grant --signing-key ` - `geth peer export [--out ]` - `geth peer import ` - `geth peer list` @@ -123,8 +126,8 @@ The bootstrap implementation provides: - `geth secret bearer verify --nonce --response --capability ` - `geth secret bearer revoke ` - `geth auth explain ` -- `geth auth grant [--grant-id ]` -- `geth auth revoke ` +- `geth auth grant --signing-key [--grant-id ]` +- `geth auth revoke --signing-key ` - local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`, `unpin`, `cleanup`, `providers`, `list`; remote fetch accepts `--bearer-secret ` @@ -369,7 +372,11 @@ node, not replace it. Node management is done through the reduced keychain view: ```sh geth node list geth node rename laptop work-laptop --signing-key ~/.ssh/id_ed25519_sk -geth node grant work-laptop resource:ssh-proxy:local ssh_proxy.connect +geth node endpoint-add work-laptop --signing-key ~/.ssh/id_ed25519_sk +geth node grant work-laptop resource:ssh-proxy:local ssh_proxy.connect \ + --signing-key ~/.ssh/id_ed25519_sk +geth node revoke-grant resource:ssh-proxy:local \ + --signing-key ~/.ssh/id_ed25519_sk geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk ``` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index d01ce40..f6c7f57 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -160,10 +160,30 @@ pub enum NodeCommand { capability: String, #[arg(long)] grant_id: Option, + #[arg(long)] + signing_key: PathBuf, + #[arg(long)] + admin_key: Option, }, RevokeGrant { resource: String, grant_id: String, + #[arg(long)] + signing_key: PathBuf, + #[arg(long)] + admin_key: Option, + }, + EndpointAdd { + node: String, + endpoint: String, + #[arg(long)] + signing_key: PathBuf, + }, + EndpointRevoke { + node: String, + endpoint: String, + #[arg(long)] + signing_key: PathBuf, }, } @@ -265,10 +285,18 @@ pub enum AuthCommand { capability: String, #[arg(long)] grant_id: Option, + #[arg(long)] + signing_key: PathBuf, + #[arg(long)] + admin_key: Option, }, Revoke { resource: String, grant_id: String, + #[arg(long)] + signing_key: PathBuf, + #[arg(long)] + admin_key: Option, }, } @@ -871,16 +899,55 @@ fn request_for_command(command: Command) -> Result { resource, capability, grant_id, + signing_key, + admin_key, }, } => ControlRequest::NodeGrant { node, resource, capability, grant_id, + signing_key_path: Some(signing_key), + admin_key_path: admin_key, }, Command::Node { - command: NodeCommand::RevokeGrant { resource, grant_id }, - } => ControlRequest::NodeRevokeGrant { resource, grant_id }, + command: + NodeCommand::RevokeGrant { + resource, + grant_id, + signing_key, + admin_key, + }, + } => ControlRequest::NodeRevokeGrant { + resource, + grant_id, + signing_key_path: Some(signing_key), + admin_key_path: admin_key, + }, + Command::Node { + command: + NodeCommand::EndpointAdd { + node, + endpoint, + signing_key, + }, + } => ControlRequest::NodeEndpointAdd { + node, + endpoint, + signing_key_path: Some(signing_key), + }, + Command::Node { + command: + NodeCommand::EndpointRevoke { + node, + endpoint, + signing_key, + }, + } => ControlRequest::NodeEndpointRevoke { + node, + endpoint, + signing_key_path: Some(signing_key), + }, Command::Peer { command } => match command { PeerCommand::Export { out } => ControlRequest::PeerCardExport { out }, PeerCommand::Import { path } => ControlRequest::PeerCardImport { path }, @@ -940,16 +1007,31 @@ fn request_for_command(command: Command) -> Result { resource, capability, grant_id, + signing_key, + admin_key, }, } => ControlRequest::AuthGrant { subject, resource, capability, grant_id, + signing_key_path: Some(signing_key), + admin_key_path: admin_key, }, Command::Auth { - command: AuthCommand::Revoke { resource, grant_id }, - } => ControlRequest::AuthRevoke { resource, grant_id }, + command: + AuthCommand::Revoke { + resource, + grant_id, + signing_key, + admin_key, + }, + } => ControlRequest::AuthRevoke { + resource, + grant_id, + signing_key_path: Some(signing_key), + admin_key_path: admin_key, + }, Command::Secret { command } => match command { SecretCommand::Status => ControlRequest::SecretStatus, SecretCommand::Create { resource } => ControlRequest::SecretCreate { resource }, @@ -1905,9 +1987,15 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("reason: {}", explain.reason); println!("evaluated_ops: {}", explain.evaluated_ops); } - ControlResponse::AuthOpRecorded { op } => { + ControlResponse::AuthOpRecorded { op, signatures } => { println!("recorded auth op: {}", op.id); println!("resource: {}", op.resource); + for signature in signatures { + println!( + "signed auth op: {} by {} ({})", + signature.op_id, signature.signer, signature.namespace + ); + } } ControlResponse::NodeList { nodes, note } => { if nodes.is_empty() { diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 39db834..153ccd1 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -132,10 +132,24 @@ pub enum ControlRequest { resource: String, capability: String, grant_id: Option, + signing_key_path: Option, + admin_key_path: Option, }, NodeRevokeGrant { resource: String, grant_id: String, + signing_key_path: Option, + admin_key_path: Option, + }, + NodeEndpointAdd { + node: String, + endpoint: String, + signing_key_path: Option, + }, + NodeEndpointRevoke { + node: String, + endpoint: String, + signing_key_path: Option, }, NodeEnrollRequest { node_name: String, @@ -219,10 +233,14 @@ pub enum ControlRequest { resource: String, capability: String, grant_id: Option, + signing_key_path: Option, + admin_key_path: Option, }, AuthRevoke { resource: String, grant_id: String, + signing_key_path: Option, + admin_key_path: Option, }, SshCertRequest { public_key_path: PathBuf, @@ -601,6 +619,7 @@ pub enum ControlResponse { AuthExplain(AuthExplanation), AuthOpRecorded { op: AuthOp, + signatures: Vec, }, NodeList { nodes: Vec, diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 77425f9..c57984f 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -5662,16 +5662,35 @@ pub fn handle_request( resource, capability, grant_id, + signing_key_path, + admin_key_path, } => { let target = resolve_keychain_node(&store, &target)?; - let op = record_node_grant(&store, target.as_str(), &resource, &capability, grant_id)?; + let op = node_grant_op(target.as_str(), &resource, &capability, grant_id); + let signatures = if let Some(signing_key_path) = signing_key_path { + store_and_sign_auth_ops( + &store, + node, + std::slice::from_ref(&op), + &signing_key_path, + admin_key_path.as_deref(), + )? + } else { + store_auth_op(&store, &op)?; + Vec::new() + }; Ok(ControlResponse::NodeGrantUpdated { op, - signatures: Vec::new(), - note: "recorded resource-scoped node capability grant; auth explain can show the grant path".to_owned(), + signatures, + note: "recorded signed resource-scoped node capability grant; auth explain can show the grant path".to_owned(), }) } - ControlRequest::NodeRevokeGrant { resource, grant_id } => { + ControlRequest::NodeRevokeGrant { + resource, + grant_id, + signing_key_path, + admin_key_path, + } => { let created_at = UnixMillis(geth_store::now_ms()); let op = AuthOp { id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at), @@ -5679,11 +5698,82 @@ pub fn handle_request( created_at, kind: AuthOpKind::GrantRevoke { grant_id }, }; - store_auth_op(&store, &op)?; + let signatures = if let Some(signing_key_path) = signing_key_path { + store_and_sign_auth_ops( + &store, + node, + std::slice::from_ref(&op), + &signing_key_path, + admin_key_path.as_deref(), + )? + } else { + store_auth_op(&store, &op)?; + Vec::new() + }; Ok(ControlResponse::NodeGrantUpdated { op, - signatures: Vec::new(), - note: "recorded capability grant revocation".to_owned(), + signatures, + note: "recorded signed capability grant revocation".to_owned(), + }) + } + ControlRequest::NodeEndpointAdd { + node: target, + endpoint, + signing_key_path, + } => { + let signing_key_path = signing_key_path + .ok_or_else(|| NodeError::SigningKeyRequired("node endpoint add".to_owned()))?; + let target = resolve_keychain_node(&store, &target)?; + let created_at = UnixMillis(geth_store::now_ms()); + let op = KeychainOp { + id: generated_keychain_op_id("node-endpoint-add", &endpoint, created_at), + created_at, + kind: KeychainOpKind::NodeEndpointAdd { + node: target, + endpoint, + }, + }; + let signatures = store_and_sign_keychain_ops( + &store, + node, + std::slice::from_ref(&op), + Some(signing_key_path.as_path()), + None, + )?; + Ok(ControlResponse::NodeKeychainUpdated { + ops: vec![op], + signatures, + note: "recorded signed node endpoint binding".to_owned(), + }) + } + ControlRequest::NodeEndpointRevoke { + node: target, + endpoint, + signing_key_path, + } => { + let signing_key_path = signing_key_path + .ok_or_else(|| NodeError::SigningKeyRequired("node endpoint revoke".to_owned()))?; + let target = resolve_keychain_node(&store, &target)?; + let created_at = UnixMillis(geth_store::now_ms()); + let op = KeychainOp { + id: generated_keychain_op_id("node-endpoint-revoke", &endpoint, created_at), + created_at, + kind: KeychainOpKind::NodeEndpointRevoke { + node: target, + endpoint, + }, + }; + let signatures = store_and_sign_keychain_ops( + &store, + node, + std::slice::from_ref(&op), + Some(signing_key_path.as_path()), + None, + )?; + Ok(ControlResponse::NodeKeychainUpdated { + ops: vec![op], + signatures, + note: "recorded signed node endpoint revocation".to_owned(), }) } ControlRequest::NodeEnrollRequest { @@ -5985,6 +6075,8 @@ pub fn handle_request( resource, capability, grant_id, + signing_key_path, + admin_key_path, } => { let created_at = UnixMillis(geth_store::now_ms()); let grant_id = @@ -5999,10 +6091,26 @@ pub fn handle_request( capabilities: vec![Capability::new(capability)], }, }; - store_auth_op(&store, &op)?; - Ok(ControlResponse::AuthOpRecorded { op }) + let signatures = if let Some(signing_key_path) = signing_key_path { + store_and_sign_auth_ops( + &store, + node, + std::slice::from_ref(&op), + &signing_key_path, + admin_key_path.as_deref(), + )? + } else { + store_auth_op(&store, &op)?; + Vec::new() + }; + Ok(ControlResponse::AuthOpRecorded { op, signatures }) } - ControlRequest::AuthRevoke { resource, grant_id } => { + ControlRequest::AuthRevoke { + resource, + grant_id, + signing_key_path, + admin_key_path, + } => { let created_at = UnixMillis(geth_store::now_ms()); let op = AuthOp { id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at), @@ -6010,8 +6118,19 @@ pub fn handle_request( created_at, kind: AuthOpKind::GrantRevoke { grant_id }, }; - store_auth_op(&store, &op)?; - Ok(ControlResponse::AuthOpRecorded { op }) + let signatures = if let Some(signing_key_path) = signing_key_path { + store_and_sign_auth_ops( + &store, + node, + std::slice::from_ref(&op), + &signing_key_path, + admin_key_path.as_deref(), + )? + } else { + store_auth_op(&store, &op)?; + Vec::new() + }; + Ok(ControlResponse::AuthOpRecorded { op, signatures }) } ControlRequest::SshCertRequest { public_key_path, @@ -7733,9 +7852,20 @@ fn record_node_grant( capability: &str, grant_id: Option, ) -> Result { + let op = node_grant_op(node_id, resource, capability, grant_id); + store_auth_op(store, &op)?; + Ok(op) +} + +fn node_grant_op( + node_id: &str, + resource: &str, + capability: &str, + grant_id: Option, +) -> AuthOp { let created_at = UnixMillis(geth_store::now_ms()); let grant_id = grant_id.unwrap_or_else(|| generated_grant_id(node_id, resource, capability)); - let op = AuthOp { + AuthOp { id: generated_auth_op_id("grant-create", resource, &grant_id, created_at), resource: ResourceId::new(resource.to_owned()), created_at, @@ -7744,9 +7874,7 @@ fn record_node_grant( principal: PrincipalId::new(node_id.to_owned()), capabilities: vec![Capability::new(capability.to_owned())], }, - }; - store_auth_op(store, &op)?; - Ok(op) + } } fn stable_slug(value: &str) -> String { @@ -9447,6 +9575,8 @@ mod tests { resource: "resource:cas:local".to_owned(), capability: "cas.fetch".to_owned(), grant_id: Some("grant:left-cas-fetch".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left peer"); @@ -9457,6 +9587,8 @@ mod tests { resource: "resource:cas-tree:shared".to_owned(), capability: "cas.fetch".to_owned(), grant_id: Some("grant:left-cas-tree-fetch".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left file root fetch"); @@ -9467,6 +9599,8 @@ mod tests { resource: "resource:kv:prefs".to_owned(), capability: "kv.read".to_owned(), grant_id: Some("grant:left-kv-read".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left kv read"); @@ -9477,6 +9611,8 @@ mod tests { resource: "resource:pubsub:presence/test".to_owned(), capability: "pubsub.publish".to_owned(), grant_id: Some("grant:left-pubsub-publish".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left pubsub publish"); @@ -9487,6 +9623,8 @@ mod tests { resource: "resource:pubsub:presence/test".to_owned(), capability: "pubsub.subscribe".to_owned(), grant_id: Some("grant:left-pubsub-subscribe".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left pubsub subscribe"); @@ -9497,6 +9635,8 @@ mod tests { resource: "resource:pipe:inbox".to_owned(), capability: "pipe.connect".to_owned(), grant_id: Some("grant:left-pipe-connect".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left pipe connect"); @@ -9507,6 +9647,8 @@ mod tests { resource: "resource:pipe:remote-inbox".to_owned(), capability: "pipe.listen".to_owned(), grant_id: Some("grant:left-pipe-listen".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left pipe listen"); @@ -9517,6 +9659,8 @@ mod tests { resource: "resource:ssh-proxy:local".to_owned(), capability: "ssh_proxy.connect".to_owned(), grant_id: Some("grant:left-ssh-proxy-connect".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left ssh proxy connect"); @@ -9527,6 +9671,8 @@ mod tests { resource: "resource:ssh-proxy:local".to_owned(), capability: "ssh_proxy.admin_shell".to_owned(), grant_id: Some("grant:left-ssh-admin-shell".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left ssh admin shell"); @@ -9537,6 +9683,8 @@ mod tests { resource: "resource:document:notes".to_owned(), capability: "document.read".to_owned(), grant_id: Some("grant:left-document-read".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left document read"); @@ -9547,6 +9695,8 @@ mod tests { resource: "resource:db:notes".to_owned(), capability: "db.sync".to_owned(), grant_id: Some("grant:left-db-sync".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left db sync"); @@ -9562,6 +9712,8 @@ mod tests { resource: format!("resource:pipe-tcp:{tcp_target}"), capability: "pipe.forward".to_owned(), grant_id: Some("grant:left-pipe-tcp-forward".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left pipe tcp forward"); @@ -9638,6 +9790,8 @@ mod tests { resource: format!("resource:pipe-unix:{unix_target_display}"), capability: "pipe.forward".to_owned(), grant_id: Some("grant:left-pipe-unix-forward".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left pipe unix forward"); @@ -10201,6 +10355,8 @@ mod tests { resource: "resource:ssh:certs".to_owned(), capability: "ssh_cert.sync".to_owned(), grant_id: Some("grant:left-ssh-cert-sync".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left ssh cert sync"); @@ -10211,6 +10367,8 @@ mod tests { resource: "resource:ssh:revocations".to_owned(), capability: "ssh_revocation.sync".to_owned(), grant_id: Some("grant:left-ssh-revocation-sync".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant left ssh revocation sync"); diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 70725b0..a554f11 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -786,12 +786,15 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() { resource: "resource:cas:local".to_owned(), capability: "cas.fetch".to_owned(), grant_id: Some("grant:test-fetch".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant capability"); match response { - geth_control::ControlResponse::AuthOpRecorded { op } => { + geth_control::ControlResponse::AuthOpRecorded { op, signatures } => { assert_eq!(op.resource.to_string(), "resource:cas:local"); + assert!(signatures.is_empty()); } other => panic!("unexpected response: {other:?}"), } @@ -819,6 +822,8 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() { geth_control::ControlRequest::AuthRevoke { resource: "resource:cas:local".to_owned(), grant_id: "grant:test-fetch".to_owned(), + signing_key_path: None, + admin_key_path: None, }, ) .expect("revoke grant"); @@ -1006,7 +1011,7 @@ fn init_owned_node_records_signed_owner_device_and_node() { geth_control::ControlRequest::NodeRename { node: "laptop".to_owned(), name: "work-laptop".to_owned(), - signing_key_path: Some(admin_key_path), + signing_key_path: Some(admin_key_path.clone()), }, ) .expect("rename node"); @@ -1017,6 +1022,41 @@ fn init_owned_node_records_signed_owner_device_and_node() { other => panic!("unexpected response: {other:?}"), } + let endpoint_added = geth_node::handle_request( + &node, + geth_control::ControlRequest::NodeEndpointAdd { + node: "work-laptop".to_owned(), + endpoint: "endpoint:test-rotated".to_owned(), + signing_key_path: Some(admin_key_path.clone()), + }, + ) + .expect("add endpoint"); + match endpoint_added { + geth_control::ControlResponse::NodeKeychainUpdated { signatures, .. } => { + assert_eq!(signatures.len(), 1); + } + other => panic!("unexpected response: {other:?}"), + } + + let grant = geth_node::handle_request( + &node, + geth_control::ControlRequest::NodeGrant { + node: "work-laptop".to_owned(), + resource: "resource:cas:local".to_owned(), + capability: "cas.fetch".to_owned(), + grant_id: Some("grant:work-laptop-cas-fetch".to_owned()), + signing_key_path: Some(admin_key_path.clone()), + admin_key_path: None, + }, + ) + .expect("signed node grant"); + match grant { + geth_control::ControlResponse::NodeGrantUpdated { signatures, .. } => { + assert_eq!(signatures.len(), 1); + } + other => panic!("unexpected response: {other:?}"), + } + let explained = geth_node::handle_request( &node, geth_control::ControlRequest::AuthExplain { @@ -1388,6 +1428,8 @@ fn kv_create_set_get_use_local_store() { resource: "resource:kv:prefs".to_owned(), capability: "kv.write_prefix:apps/foo/".to_owned(), grant_id: None, + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant prefix write"); @@ -2134,6 +2176,8 @@ fn ssh_cert_and_revocation_commands_check_subject_capabilities() { resource: "resource:ssh:certs".to_owned(), capability: "ssh_cert.request".to_owned(), grant_id: Some("grant:ssh-cert-request".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant cert request"); @@ -2171,6 +2215,8 @@ fn ssh_cert_and_revocation_commands_check_subject_capabilities() { resource: "resource:ssh:revocations".to_owned(), capability: "ssh_revocation.publish".to_owned(), grant_id: Some("grant:ssh-revocation-publish".to_owned()), + signing_key_path: None, + admin_key_path: None, }, ) .expect("grant revocation publish"); diff --git a/docs/adr/0016-owner-node-enrollment.md b/docs/adr/0016-owner-node-enrollment.md index 0d61f45..eaf5a2a 100644 --- a/docs/adr/0016-owner-node-enrollment.md +++ b/docs/adr/0016-owner-node-enrollment.md @@ -24,7 +24,11 @@ OpenSSH using the `geth.keychain.v1@geth.local` namespace. The active device list is the reduced keychain view, surfaced through `geth node list`. Renames and revocations are additional keychain operations. Resource permissions remain resource-scoped auth operations and can be managed with -`geth node grant` and `geth node revoke-grant`. +`geth node grant` and `geth node revoke-grant`; the CLI requires an admin +`--signing-key` so these mutations replicate as signed auth operations. +Endpoint rotation is modeled with signed `NodeEndpointAdd` and +`NodeEndpointRevoke` operations exposed as `geth node endpoint-add` and +`geth node endpoint-revoke`. New nodes can request enrollment with `geth node enroll request`. The request is signed by the requesting agent key and includes the stable node ID, agent ID, @@ -52,6 +56,5 @@ add or revoke endpoint bindings without replacing the node identity. The current sync model is a pull-based signed operation log. It is not yet a Keyhive-style convergent authority and does not implement advanced group -cryptography. Manual grant/revoke commands still need the same signing -requirement as enrollment approval before this becomes a complete authorization -workflow. +cryptography. Conflict resolution for concurrent signed operations is still the +deterministic reducer, not a richer collaborative authority protocol. diff --git a/docs/architecture.md b/docs/architecture.md index 9afeb9e..c1ec745 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -282,11 +282,13 @@ and signs them with OpenSSH under `geth.keychain.v1@geth.local`. Both keys are required when owner setup options are used, so the node does not create unsigned owner statements by accident. `geth node list` shows the active reduced node view. `geth node rename` and `geth node revoke` record signed keychain -operations and require `--signing-key`. `geth keychain sync ` pulls -keychain operations and signatures from an imported peer over Iroh and imports -only operations with a valid OpenSSH signature from a currently trusted admin key -over the canonical payload. This is currently a pull-based signed operation log, -not a CRDT or Keyhive-style convergent authority. +operations and require `--signing-key`. Endpoint rotation is explicit: +`geth node endpoint-add` and `geth node endpoint-revoke` record signed +`NodeEndpointAdd` and `NodeEndpointRevoke` keychain operations. `geth keychain +sync ` pulls keychain operations and signatures from an imported peer over +Iroh and imports only operations with a valid OpenSSH signature from a currently +trusted admin key over the canonical payload. This is currently a pull-based +signed operation log, not a CRDT or Keyhive-style convergent authority. New devices can use the node enrollment flow instead of hand-editing keychain state. `geth node enroll request` creates a canonical, agent-key-signed request @@ -303,9 +305,11 @@ grants, revocations, groups, and `auth explain`. Auth operations reduce into a current permission view for resources, grants, groups, and bearer access. The library can explain direct and group grants. The daemon persists local auth grant/revoke operations and `geth auth explain` evaluates that local operation -log. Enrollment approval and auth sync attach and verify OpenSSH admin -signatures for replicated auth operations. Broader delegated authority and -module enforcement are still future work. +log. `geth node grant`, `geth node revoke-grant`, `geth auth grant`, and `geth +auth revoke` require `--signing-key` in the CLI and store OpenSSH-signed auth +operations. Enrollment approval uses the same signed auth operation path. Auth +sync imports only auth operations signed by currently trusted admin keys. +Broader delegated authority and module enforcement are still future work. Capability evaluation supports exact matches plus explicit scoped forms. For KV, `kv.write_prefix:` grants writes requested as `kv.write_key:` only diff --git a/docs/roadmap.md b/docs/roadmap.md index 014a7e2..03f2699 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -196,6 +196,8 @@ resource-scoped capability decisions. for device/node/agent/endpoint enrollment. - `[x]` `geth node enroll sync ` pulls approved signed keychain and auth state onto the requesting node. + - `[x]` `geth node endpoint-add/revoke --signing-key` records signed endpoint + rotation operations. - `[x]` Keychain operation reducer. Acceptance criteria: @@ -204,19 +206,18 @@ resource-scoped capability decisions. - Revoked keys/devices/nodes are excluded from active views. - Tests cover add, rename, revoke, and endpoint rotation. -- `[~]` Node capability management. +- `[x]` Node capability management. Acceptance criteria: - `[x]` `geth node grant ` records a - resource-scoped capability grant for a known node. + signed resource-scoped capability grant for a known node. - `[x]` `geth node revoke-grant ` records grant - revocation. + revocation as a signed auth op. - `[x]` Node names can be used for management commands where the keychain view has a unique active node name. - `[x]` Enrollment approval signs capability grants as auth ops. - `[x]` `geth auth sync ` imports only auth ops signed by currently trusted admin keys. - - `[ ]` Future completion requires signed auth ops for every manual - grant/revoke command, not only enrollment approval and replicated imports. + - `[x]` `geth auth grant/revoke --signing-key` records signed auth ops. - `[x]` Resource auth operation reducer. Acceptance criteria: @@ -228,13 +229,13 @@ resource-scoped capability decisions. - `[~]` `auth explain` real decision path. Acceptance criteria: - - `[x]` `geth auth grant` and `geth auth revoke` persist local auth ops. + - `[x]` `geth auth grant` and `geth auth revoke` persist signed local auth + ops when run through the CLI. - `[x]` `geth auth explain ` reports allowed/denied from the local auth-op reducer when local ops exist. - `[x]` Output includes the grant ID or missing grant that caused the result. - `[x]` JSON output is stable enough for tests and scripts. - - `[ ]` Future completion requires signed-op validation before accepting - replicated auth ops. + - `[x]` Replicated auth sync requires trusted-admin signatures before import. - `[~]` Resource secrets and bearer invites. Acceptance criteria: