diff --git a/AGENTS.md b/AGENTS.md index cac55b4..119a050 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,8 @@ Roadmap items should be actionable and checkable: types, untrusted discovery-backend trait, custom relay-map config, and Iroh local-network discovery toggle exist. - Canonical signed-operation envelopes exist for keychain/auth signature - payloads. Reducers and enforcement remain separate roadmap work. + payloads. The keychain reducer builds an active identity view for admin keys, + users, devices, nodes, agents, and endpoint bindings. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 38033d8..057496a 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -1,5 +1,6 @@ use geth_types::{AgentId, DeviceId, KeyId, NodeId, UnixMillis, UserId}; use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; pub const KEYCHAIN_SIGNATURE_NAMESPACE: &str = "geth.keychain.v1@geth.local"; @@ -93,36 +94,156 @@ pub enum KeychainOpKind { pub struct KeychainView { pub initialized: bool, pub admin_keys: Vec, - pub users: Vec, - pub devices: Vec, - pub nodes: Vec, + pub users: BTreeMap, + pub devices: BTreeMap, + pub nodes: BTreeMap, + pub agents: BTreeMap, + pub endpoints: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserRecord { + pub id: UserId, + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeviceRecord { + pub id: DeviceId, + pub user: UserId, + pub keys: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeRecord { + pub id: NodeId, + pub device: DeviceId, + pub name: String, + pub endpoints: Vec, } pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView { - let mut view = KeychainView::default(); + let mut initialized = false; + let mut admin_keys = BTreeSet::new(); + let mut users = BTreeMap::::new(); + let mut revoked_users = BTreeSet::new(); + let mut devices = BTreeMap::::new(); + let mut revoked_devices = BTreeSet::new(); + let mut nodes = BTreeMap::::new(); + let mut revoked_nodes = BTreeSet::new(); + let mut agent_bindings = BTreeMap::::new(); + for op in ops { match &op.kind { - KeychainOpKind::KeychainInit => view.initialized = true, - KeychainOpKind::AdminKeyAdd { key } if !view.admin_keys.contains(key) => { - view.admin_keys.push(key.clone()); + KeychainOpKind::KeychainInit => initialized = true, + KeychainOpKind::AdminKeyAdd { key } => { + admin_keys.insert(key.clone()); } - KeychainOpKind::AdminKeyRevoke { key } => view.admin_keys.retain(|item| item != key), - KeychainOpKind::UserAdd { user, .. } if !view.users.contains(user) => { - view.users.push(user.clone()); + KeychainOpKind::AdminKeyRevoke { key } => { + admin_keys.remove(key); } - KeychainOpKind::UserRevoke { user } => view.users.retain(|item| item != user), - KeychainOpKind::DeviceAdd { device, .. } if !view.devices.contains(device) => { - view.devices.push(device.clone()); + KeychainOpKind::UserAdd { user, name } => { + revoked_users.remove(user); + users.entry(user.clone()).or_insert_with(|| UserRecord { + id: user.clone(), + name: name.clone(), + }); } - KeychainOpKind::DeviceRevoke { device } => view.devices.retain(|item| item != device), - KeychainOpKind::NodeAdd { node, .. } if !view.nodes.contains(node) => { - view.nodes.push(node.clone()); + KeychainOpKind::UserRename { user, name } => { + if let Some(record) = users.get_mut(user) { + record.name.clone_from(name); + } + } + KeychainOpKind::UserRevoke { user } => { + revoked_users.insert(user.clone()); + } + KeychainOpKind::DeviceAdd { device, user } => { + revoked_devices.remove(device); + devices + .entry(device.clone()) + .or_insert_with(|| DeviceRecord { + id: device.clone(), + user: user.clone(), + keys: Vec::new(), + }); + } + KeychainOpKind::DeviceRevoke { device } => { + revoked_devices.insert(device.clone()); + } + KeychainOpKind::DeviceKeyAdd { device, key } => { + if let Some(record) = devices.get_mut(device) { + if !record.keys.contains(key) { + record.keys.push(key.clone()); + record.keys.sort(); + } + } + } + KeychainOpKind::DeviceKeyRevoke { device, key } => { + if let Some(record) = devices.get_mut(device) { + record.keys.retain(|item| item != key); + } + } + KeychainOpKind::NodeAdd { node, device, name } => { + revoked_nodes.remove(node); + nodes.entry(node.clone()).or_insert_with(|| NodeRecord { + id: node.clone(), + device: device.clone(), + name: name.clone(), + endpoints: Vec::new(), + }); + } + KeychainOpKind::NodeRename { node, name } => { + if let Some(record) = nodes.get_mut(node) { + record.name.clone_from(name); + } + } + KeychainOpKind::NodeRevoke { node } => { + revoked_nodes.insert(node.clone()); + } + KeychainOpKind::NodeEndpointAdd { node, endpoint } => { + if let Some(record) = nodes.get_mut(node) { + if !record.endpoints.contains(endpoint) { + record.endpoints.push(endpoint.clone()); + record.endpoints.sort(); + } + } + } + KeychainOpKind::NodeEndpointRevoke { node, endpoint } => { + if let Some(record) = nodes.get_mut(node) { + record.endpoints.retain(|item| item != endpoint); + } + } + KeychainOpKind::AgentBind { agent, node } => { + agent_bindings.insert(agent.clone(), node.clone()); } - KeychainOpKind::NodeRevoke { node } => view.nodes.retain(|item| item != node), - _ => {} } } - view + + users.retain(|user, _| !revoked_users.contains(user)); + devices.retain(|device, record| { + !revoked_devices.contains(device) && users.contains_key(&record.user) + }); + nodes.retain(|node, record| { + !revoked_nodes.contains(node) && devices.contains_key(&record.device) + }); + agent_bindings.retain(|_, node| nodes.contains_key(node)); + + let mut endpoints = BTreeMap::new(); + for (node, record) in &nodes { + for endpoint in &record.endpoints { + endpoints.insert(endpoint.clone(), node.clone()); + } + } + + KeychainView { + initialized, + admin_keys: admin_keys.into_iter().collect(), + users, + devices, + nodes, + agents: agent_bindings, + endpoints, + } } #[cfg(test)] @@ -166,4 +287,216 @@ mod tests { assert_eq!(signed.namespace(), KEYCHAIN_SIGNATURE_NAMESPACE); assert_eq!(signed.payload(), &op); } + + fn op(sequence: i64, kind: KeychainOpKind) -> KeychainOp { + KeychainOp { + id: format!("op:{sequence}").into(), + created_at: UnixMillis(sequence), + kind, + } + } + + #[test] + fn reducer_tracks_active_keychain_view() { + let ops = vec![ + op(1, KeychainOpKind::KeychainInit), + op( + 2, + KeychainOpKind::AdminKeyAdd { + key: "key:admin-a".into(), + }, + ), + op( + 3, + KeychainOpKind::AdminKeyAdd { + key: "key:admin-b".into(), + }, + ), + op( + 4, + KeychainOpKind::AdminKeyRevoke { + key: "key:admin-b".into(), + }, + ), + op( + 5, + KeychainOpKind::UserAdd { + user: "user:eric".into(), + name: "Eric".to_owned(), + }, + ), + op( + 6, + KeychainOpKind::UserRename { + user: "user:eric".into(), + name: "Eric Updated".to_owned(), + }, + ), + op( + 7, + KeychainOpKind::DeviceAdd { + device: "device:laptop".into(), + user: "user:eric".into(), + }, + ), + op( + 8, + KeychainOpKind::DeviceKeyAdd { + device: "device:laptop".into(), + key: "key:device-a".into(), + }, + ), + op( + 9, + KeychainOpKind::DeviceKeyAdd { + device: "device:laptop".into(), + key: "key:device-b".into(), + }, + ), + op( + 10, + KeychainOpKind::DeviceKeyRevoke { + device: "device:laptop".into(), + key: "key:device-b".into(), + }, + ), + op( + 11, + KeychainOpKind::NodeAdd { + node: "node:laptop".into(), + device: "device:laptop".into(), + name: "laptop".to_owned(), + }, + ), + op( + 12, + KeychainOpKind::NodeRename { + node: "node:laptop".into(), + name: "work-laptop".to_owned(), + }, + ), + op( + 13, + KeychainOpKind::NodeEndpointAdd { + node: "node:laptop".into(), + endpoint: "endpoint:old".to_owned(), + }, + ), + op( + 14, + KeychainOpKind::NodeEndpointAdd { + node: "node:laptop".into(), + endpoint: "endpoint:new".to_owned(), + }, + ), + op( + 15, + KeychainOpKind::NodeEndpointRevoke { + node: "node:laptop".into(), + endpoint: "endpoint:old".to_owned(), + }, + ), + op( + 16, + KeychainOpKind::AgentBind { + agent: "agent:daemon".into(), + node: "node:laptop".into(), + }, + ), + ]; + + let view = reduce_keychain_ops(&ops); + + assert!(view.initialized); + assert_eq!(view.admin_keys, vec![KeyId::from("key:admin-a")]); + assert_eq!( + view.users.get(&UserId::from("user:eric")), + Some(&UserRecord { + id: "user:eric".into(), + name: "Eric Updated".to_owned(), + }) + ); + assert_eq!( + view.devices.get(&DeviceId::from("device:laptop")), + Some(&DeviceRecord { + id: "device:laptop".into(), + user: "user:eric".into(), + keys: vec!["key:device-a".into()], + }) + ); + assert_eq!( + view.nodes.get(&NodeId::from("node:laptop")), + Some(&NodeRecord { + id: "node:laptop".into(), + device: "device:laptop".into(), + name: "work-laptop".to_owned(), + endpoints: vec!["endpoint:new".to_owned()], + }) + ); + assert_eq!( + view.agents.get(&AgentId::from("agent:daemon")), + Some(&NodeId::from("node:laptop")) + ); + assert_eq!( + view.endpoints.get("endpoint:new"), + Some(&NodeId::from("node:laptop")) + ); + assert!(!view.endpoints.contains_key("endpoint:old")); + } + + #[test] + fn reducer_excludes_revoked_identity_subtrees() { + let ops = vec![ + op( + 1, + KeychainOpKind::UserAdd { + user: "user:eric".into(), + name: "Eric".to_owned(), + }, + ), + op( + 2, + KeychainOpKind::DeviceAdd { + device: "device:laptop".into(), + user: "user:eric".into(), + }, + ), + op( + 3, + KeychainOpKind::NodeAdd { + node: "node:laptop".into(), + device: "device:laptop".into(), + name: "laptop".to_owned(), + }, + ), + op( + 4, + KeychainOpKind::NodeEndpointAdd { + node: "node:laptop".into(), + endpoint: "endpoint:live".to_owned(), + }, + ), + op( + 5, + KeychainOpKind::AgentBind { + agent: "agent:daemon".into(), + node: "node:laptop".into(), + }, + ), + op( + 6, + KeychainOpKind::UserRevoke { + user: "user:eric".into(), + }, + ), + ]; + + let view = reduce_keychain_ops(&ops); + + assert!(view.users.is_empty()); + assert!(view.devices.is_empty()); + assert!(view.nodes.is_empty()); + assert!(view.agents.is_empty()); + assert!(view.endpoints.is_empty()); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 4f38a91..54d5bdc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,7 +103,9 @@ generate OpenSSH KRL binaries or replicate the lists over Iroh. The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents, and endpoint bindings. Endpoint rotation must not destroy higher-level node -identity. +identity. Keychain operations reduce into an active view containing current +admin keys, users, devices, node records, agent bindings, and endpoint-to-node +bindings. Revoked identity subtrees are excluded from that active view. The authorization plane is `geth-auth`: resource-local signed operation logs, grants, revocations, groups, and `auth explain`. diff --git a/docs/roadmap.md b/docs/roadmap.md index adba058..e02182a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -144,7 +144,7 @@ resource-scoped capability decisions. - OpenSSH signature namespaces are explicit. - Missing `ssh-keygen` or unavailable hardware keys produce clear errors. -- `[ ]` Keychain operation reducer. +- `[x]` Keychain operation reducer. Acceptance criteria: - Admin keys, users, devices, nodes, agents, and endpoint bindings reduce into a current keychain view.