diff --git a/AGENTS.md b/AGENTS.md index bd70982..bfa4cef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,13 +122,16 @@ Roadmap items should be actionable and checkable: and uses them for `auth explain`. `kv set --subject ` enforces local KV write grants for non-local test callers. Signature validation and broader daemon-side module enforcement are still roadmap work. -- The daemon persists local keychain init/admin-key ops and reduces them for - `keychain status`. `keychain init --signing-key ` signs recorded - keychain ops with `ssh-keygen -Y sign` under the - `geth.keychain.v1@geth.local` namespace and stores signatures locally. - `keychain status` verifies stored signatures with OpenSSH when public key - material is available. Verification before accepting replicated keychain ops - 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 + init --signing-key ` signs recorded keychain ops with `ssh-keygen -Y + sign` under the `geth.keychain.v1@geth.local` namespace and stores signatures + locally. `keychain status` verifies stored signatures with OpenSSH when public + key material is available. `geth keychain sync ` imports only keychain + ops with valid OpenSSH signatures from currently trusted admin keys. `geth + node list/rename/revoke/grant` are the current node-management surface over + that reduced keychain/auth view; rename and revoke require `--signing-key`. - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and `cas cleanup` evicts unpinned blobs while retaining pinned blobs. The daemon can fetch CAS blobs from an imported signed peer card over Iroh when the peer diff --git a/README.md b/README.md index bf5c5a8..643700a 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,17 @@ registry, module router, local metadata store, and synchronized data structures. Most non-daemon commands talk to the daemon through a local Unix socket at `$GETH_HOME/run/geth.sock`. -`geth keychain init --admin-key --signing-key ` records -the keychain initialization/admin-key operations and signs their canonical -payloads through `ssh-keygen -Y sign` using the +`geth init --admin-key --signing-key --node-name +` records an owner/admin keychain, the local user/device/node binding, and +signs canonical keychain payloads through `ssh-keygen -Y sign` using the `geth.keychain.v1@geth.local` namespace. This is the bootstrap path for -admin/YubiKey-rooted trust. `geth keychain status` reports the number of stored -keychain signatures plus how many currently verify with OpenSSH; rejecting -unsigned or invalid replicated keychain ops is still future work. +admin/YubiKey-rooted trust. `geth keychain sync ` pulls the signed +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. SSH certificate-flow and revocation records carry agent-key signed provenance over canonical payloads, and sync import rejects new unsigned or invalidly signed records. @@ -82,10 +86,16 @@ metadata from an authorized peer over Iroh. The bootstrap implementation provides: - `geth init` +- `geth init --admin-key --signing-key --node-name ` - `geth daemon run` - `geth daemon service install|uninstall|start|stop|status|print` - `geth status` - `geth node id` +- `geth node list` +- `geth node rename --signing-key ` +- `geth node revoke --signing-key ` +- `geth node grant [--grant-id ]` +- `geth node revoke-grant ` - `geth peer export [--out ]` - `geth peer import ` - `geth peer list` @@ -95,6 +105,7 @@ The bootstrap implementation provides: - `geth resource create ` - `geth keychain init [--admin-key ] [--signing-key ]` - `geth keychain status` +- `geth keychain sync ` - `geth secret status` - `geth secret create ` - `geth secret rotate ` @@ -327,6 +338,40 @@ cargo run -p geth -- cas add /tmp/hello-geth.txt cargo run -p geth -- cas list ``` +## Owner And Node Management + +The intended owner setup is SSH-admin-rooted: + +```sh +geth init \ + --admin-key ~/.ssh/id_ed25519_sk.pub \ + --signing-key ~/.ssh/id_ed25519_sk \ + --node-name laptop \ + --capability resource:ssh-proxy:local=ssh_proxy.admin_shell +``` + +When any owner setup option is used, both `--admin-key` and `--signing-key` are +required. This prevents accidentally creating an unsigned owner/device/node +statement that cannot be accepted by another node during keychain sync. + +This records signed keychain operations for `KeychainInit`, `AdminKeyAdd`, +`UserAdd`, `DeviceAdd`, `NodeAdd`, and `AgentBind`. The current node identity is +stable above endpoint rotation: future endpoint bindings should attach to the +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 revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk +``` + +`geth keychain sync ` pulls signed keychain operations from an imported +peer over Iroh and rejects operations that do not have a valid OpenSSH signature +from a currently trusted admin key over the canonical keychain payload. This is +the current replicated device-management substrate. It is still a pull-based +operation log, not yet a CRDT or Keyhive-style convergent authority. + ## Authorization Direction The MVP defines the split between: diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 8ed762d..11a1696 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -20,7 +20,18 @@ pub struct Cli { #[derive(Debug, Subcommand)] pub enum Command { - Init, + Init { + #[arg(long)] + admin_key: Option, + #[arg(long)] + signing_key: Option, + #[arg(long, default_value = "owner")] + owner: String, + #[arg(long, default_value = "local")] + node_name: String, + #[arg(long = "capability")] + capabilities: Vec, + }, Daemon { #[command(subcommand)] command: DaemonCommand, @@ -127,6 +138,29 @@ pub enum ServiceCommand { pub enum NodeCommand { Id, Status, + List, + Rename { + node: String, + name: String, + #[arg(long)] + signing_key: Option, + }, + Revoke { + node: String, + #[arg(long)] + signing_key: Option, + }, + Grant { + node: String, + resource: String, + capability: String, + #[arg(long)] + grant_id: Option, + }, + RevokeGrant { + resource: String, + grant_id: String, + }, } #[derive(Debug, Subcommand)] @@ -164,6 +198,9 @@ pub enum KeychainCommand { signing_key: Option, }, Status, + Sync { + node: String, + }, } #[derive(Debug, Subcommand)] @@ -616,8 +653,24 @@ pub async fn run() -> Result<()> { let cli = Cli::parse(); let paths = GethPaths::resolve().context("resolve geth paths")?; match cli.command { - Command::Init => { - let node = geth_node::init_node(&paths).context("initialize geth node")?; + Command::Init { + admin_key, + signing_key, + owner, + node_name, + capabilities, + } => { + let node = geth_node::init_owned_node( + &paths, + geth_node::InitOwnerOptions { + admin_key_path: admin_key, + signing_key_path: signing_key, + owner_name: owner, + node_name, + capabilities, + }, + ) + .context("initialize geth node")?; println!("initialized geth home: {}", node.paths.home().display()); println!("agent: {}", node.agent_id); println!("node: {}", node.node_id); @@ -701,6 +754,44 @@ fn request_for_command(command: Command) -> Result { Command::Node { command: NodeCommand::Status, } => ControlRequest::Status, + Command::Node { + command: NodeCommand::List, + } => ControlRequest::NodeList, + Command::Node { + command: + NodeCommand::Rename { + node, + name, + signing_key, + }, + } => ControlRequest::NodeRename { + node, + name, + signing_key_path: signing_key, + }, + Command::Node { + command: NodeCommand::Revoke { node, signing_key }, + } => ControlRequest::NodeRevoke { + node, + signing_key_path: signing_key, + }, + Command::Node { + command: + NodeCommand::Grant { + node, + resource, + capability, + grant_id, + }, + } => ControlRequest::NodeGrant { + node, + resource, + capability, + grant_id, + }, + Command::Node { + command: NodeCommand::RevokeGrant { resource, grant_id }, + } => ControlRequest::NodeRevokeGrant { resource, grant_id }, Command::Peer { command } => match command { PeerCommand::Export { out } => ControlRequest::PeerCardExport { out }, PeerCommand::Import { path } => ControlRequest::PeerCardImport { path }, @@ -735,6 +826,9 @@ fn request_for_command(command: Command) -> Result { Command::Keychain { command: KeychainCommand::Status, } => ControlRequest::KeychainStatus, + Command::Keychain { + command: KeychainCommand::Sync { node }, + } => ControlRequest::KeychainSync { node }, Command::Auth { command: AuthCommand::Explain { @@ -1163,7 +1257,7 @@ fn request_for_command(command: Command) -> Result { }, }, }, - Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), + Command::Init { .. } | Command::Daemon { .. } => bail!("command is handled directly"), }) } @@ -1576,6 +1670,23 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { ); } } + ControlResponse::KeychainSynced { + peer_node_id, + peer_agent_id, + endpoint_id, + ops_imported, + signatures_imported, + invalid_ops_rejected, + note, + } => { + println!("synced keychain from: {peer_node_id}"); + println!("agent: {peer_agent_id}"); + println!("endpoint: {endpoint_id}"); + println!("ops_imported: {ops_imported}"); + println!("signatures_imported: {signatures_imported}"); + println!("invalid_ops_rejected: {invalid_ops_rejected}"); + println!("note: {note}"); + } ControlResponse::SecretStatus { secrets } => { if secrets.is_empty() { println!("no resource secrets"); @@ -1689,6 +1800,43 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("recorded auth op: {}", op.id); println!("resource: {}", op.resource); } + ControlResponse::NodeList { nodes, note } => { + if nodes.is_empty() { + println!("no enrolled nodes"); + } else { + for node in nodes { + println!( + "{}\t{}\tdevice {}\t{} endpoints", + node.name, + node.id, + node.device, + node.endpoints.len() + ); + } + } + println!("note: {note}"); + } + ControlResponse::NodeKeychainUpdated { + ops, + signatures, + note, + } => { + for op in ops { + println!("recorded keychain op: {}", op.id); + } + for signature in signatures { + println!( + "signed keychain op: {} by {} ({})", + signature.op_id, signature.signer, signature.namespace + ); + } + println!("note: {note}"); + } + ControlResponse::NodeGrantUpdated { op, note } => { + println!("recorded auth op: {}", op.id); + println!("resource: {}", op.resource); + println!("note: {note}"); + } ControlResponse::SshCertRequested { request } => { println!("ssh cert request: {}", request.id); println!("status: {}", request.status); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 0fc28fd..99dadf6 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -3,7 +3,7 @@ use geth_cas::{FileConflict, FileRoot, FileRootScan}; use geth_db::{CrSqliteChangeBatch, DbResource}; use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_document::{DocumentResource, DocumentState}; -use geth_keychain::{KeychainOp, KeychainOpSignature}; +use geth_keychain::{KeychainOp, KeychainOpSignature, NodeRecord}; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_pipe::{PipeConnection, PipeListener, PipeMessage}; use geth_pubsub::PubsubMessage; @@ -117,11 +117,34 @@ pub enum ControlRequest { resolution: String, note: Option, }, + NodeList, + NodeRename { + node: String, + name: String, + signing_key_path: Option, + }, + NodeRevoke { + node: String, + signing_key_path: Option, + }, + NodeGrant { + node: String, + resource: String, + capability: String, + grant_id: Option, + }, + NodeRevokeGrant { + resource: String, + grant_id: String, + }, KeychainInit { admin_key_path: Option, signing_key_path: Option, }, KeychainStatus, + KeychainSync { + node: String, + }, SecretStatus, SecretCreate { resource: String, @@ -498,6 +521,15 @@ pub enum ControlResponse { ops: Vec, signatures: Vec, }, + KeychainSynced { + peer_node_id: String, + peer_agent_id: String, + endpoint_id: String, + ops_imported: usize, + signatures_imported: usize, + invalid_ops_rejected: usize, + note: String, + }, SecretStatus { secrets: Vec, }, @@ -531,6 +563,19 @@ pub enum ControlResponse { AuthOpRecorded { op: AuthOp, }, + NodeList { + nodes: Vec, + note: String, + }, + NodeKeychainUpdated { + ops: Vec, + signatures: Vec, + note: String, + }, + NodeGrantUpdated { + op: AuthOp, + note: String, + }, SshCertRequested { request: SshCertRequest, }, @@ -811,6 +856,10 @@ pub enum PeerControlRequest { peer_card: PeerCard, nonce: String, }, + KeychainSync { + peer_card: PeerCard, + nonce: String, + }, CasFetch { peer_card: PeerCard, hash: BlobHash, @@ -929,6 +978,16 @@ pub enum PeerControlResponse { nonce: String, note: String, }, + KeychainSynced { + node_id: String, + agent_id: String, + endpoint_id: String, + remote_endpoint_id: String, + ops: Vec, + signatures: Vec, + nonce: String, + note: String, + }, CasFetched { node_id: String, agent_id: String, @@ -1249,6 +1308,24 @@ mod tests { request ); + let request = ControlRequest::NodeRename { + node: "laptop".to_owned(), + name: "work-laptop".to_owned(), + signing_key_path: Some(PathBuf::from("admin")), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + + let request = ControlRequest::KeychainSync { + node: "work-laptop".to_owned(), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + let request = ControlRequest::CasHas { hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), }; @@ -1266,6 +1343,34 @@ mod tests { response ); + let response = ControlResponse::NodeList { + nodes: vec![NodeRecord { + id: geth_types::NodeId::new("node:local"), + device: geth_types::DeviceId::new("device:local"), + name: "work-laptop".to_owned(), + endpoints: Vec::new(), + }], + note: "reduced keychain view".to_owned(), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + + let response = ControlResponse::KeychainSynced { + peer_node_id: "node:peer".to_owned(), + peer_agent_id: "agent:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + ops_imported: 2, + signatures_imported: 2, + invalid_ops_rejected: 1, + note: "trusted admin signatures only".to_owned(), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + let request = ControlRequest::SecretBearerVerify { secret: "bearer:test".to_owned(), resource: "resource:cas:local".to_owned(), diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 807a6f1..6d7b698 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -43,8 +43,8 @@ use geth_store::{ StoredSshCertificate, StoredSshRevocation, }; use geth_types::{ - AuthOpId, BlobHash, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, - ResourceName, SshCertId, SshCertRequestId, UnixMillis, + AuthOpId, BlobHash, Capability, DeviceId, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, + ResourceName, SshCertId, SshCertRequestId, UnixMillis, UserId, }; use std::collections::{BTreeMap, VecDeque}; use std::path::{Component, Path, PathBuf}; @@ -75,6 +75,16 @@ pub enum NodeError { Io(#[from] std::io::Error), #[error("invalid resource kind: {0}")] InvalidResourceKind(String), + #[error("owner init requires --admin-key so the owner trust anchor is recorded")] + OwnerInitRequiresAdminKey, + #[error("owner init requires --signing-key so the owner statement is signed")] + OwnerInitRequiresSigningKey, + #[error( + "{0} requires --signing-key so the keychain operation can replicate as a verified admin statement" + )] + SigningKeyRequired(String), + #[error("invalid init capability grant, expected =: {0}")] + InvalidInitGrant(String), #[error("invalid db resource name: {0}")] InvalidDbName(String), #[error("db path does not exist or is not a file: {0}")] @@ -180,6 +190,37 @@ struct PipeUnixConnectWire { bearer_proof: Option, } +#[derive(Clone, Debug)] +pub struct InitOwnerOptions { + pub admin_key_path: Option, + pub signing_key_path: Option, + pub owner_name: String, + pub node_name: String, + pub capabilities: Vec, +} + +impl Default for InitOwnerOptions { + fn default() -> Self { + Self { + admin_key_path: None, + signing_key_path: None, + owner_name: "owner".to_owned(), + node_name: "local".to_owned(), + capabilities: Vec::new(), + } + } +} + +impl InitOwnerOptions { + fn requests_owner_keychain(&self) -> bool { + self.admin_key_path.is_some() + || self.signing_key_path.is_some() + || self.node_name != "local" + || self.owner_name != "owner" + || !self.capabilities.is_empty() + } +} + pub fn init_node(paths: &GethPaths) -> Result { paths.ensure_base_dirs()?; if !paths.config_file().exists() { @@ -210,10 +251,121 @@ pub fn init_node(paths: &GethPaths) -> Result { }) } +pub fn init_owned_node( + paths: &GethPaths, + options: InitOwnerOptions, +) -> Result { + let initialize_owner = options.requests_owner_keychain(); + if initialize_owner && options.admin_key_path.is_none() { + return Err(NodeError::OwnerInitRequiresAdminKey); + } + if initialize_owner && options.signing_key_path.is_none() { + return Err(NodeError::OwnerInitRequiresSigningKey); + } + + let node = init_node(paths)?; + if initialize_owner { + initialize_owner_keychain(&node, options)?; + } + Ok(node) +} + pub fn open_node(paths: &GethPaths) -> Result { init_node(paths) } +fn initialize_owner_keychain( + node: &LocalNode, + options: InitOwnerOptions, +) -> Result, NodeError> { + let store = Store::open(&node.paths.metadata_db())?; + let owner = UserId::new(format!("user:{}", stable_slug(&options.owner_name))); + let device = DeviceId::new(format!("device:{}", node.node_id)); + let node_id = NodeId::new(node.node_id.clone()); + let agent = node.agent_id.clone().into(); + let now = UnixMillis(geth_store::now_ms()); + let mut ops = vec![ + KeychainOp { + id: generated_keychain_op_id("keychain-init", "owner", now), + created_at: now, + kind: KeychainOpKind::KeychainInit, + }, + KeychainOp { + id: generated_keychain_op_id("user-add", owner.as_str(), now), + created_at: now, + kind: KeychainOpKind::UserAdd { + user: owner.clone(), + name: options.owner_name.clone(), + }, + }, + KeychainOp { + id: generated_keychain_op_id("device-add", device.as_str(), now), + created_at: now, + kind: KeychainOpKind::DeviceAdd { + device: device.clone(), + user: owner.clone(), + }, + }, + KeychainOp { + id: generated_keychain_op_id("node-add", node_id.as_str(), now), + created_at: now, + kind: KeychainOpKind::NodeAdd { + node: node_id.clone(), + device: device.clone(), + name: options.node_name.clone(), + }, + }, + KeychainOp { + id: generated_keychain_op_id("agent-bind", &node.agent_id, now), + created_at: now, + kind: KeychainOpKind::AgentBind { + agent, + node: node_id.clone(), + }, + }, + ]; + if let Some(admin_key_path) = options.admin_key_path.as_ref() { + let public_key = std::fs::read_to_string(admin_key_path)?; + let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key)); + ops.push(KeychainOp { + id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), now), + created_at: now, + kind: KeychainOpKind::AdminKeyAdd { key: admin_key }, + }); + } + + let signatures = store_and_sign_keychain_ops( + &store, + node, + &ops, + options.signing_key_path.as_deref(), + options.admin_key_path.as_deref(), + )?; + tracing::debug!( + ops = ops.len(), + signatures = signatures.len(), + "initialized owner keychain" + ); + + for grant in options.capabilities { + let (resource, capability) = grant + .split_once('=') + .ok_or_else(|| NodeError::InvalidInitGrant(grant.clone()))?; + record_node_grant( + &store, + node_id.as_str(), + resource, + capability, + Some(format!( + "grant:init:{}:{}", + stable_slug(node_id.as_str()), + stable_slug(capability) + )), + )?; + } + Ok(ops) +} + pub async fn run_daemon(paths: GethPaths) -> Result<(), NodeError> { let mut node = init_node(&paths)?; let _iroh_endpoint = start_daemon_iroh_endpoint(&mut node).await?; @@ -486,6 +638,9 @@ pub async fn handle_request_async( resource, capability, } => peer_auth_check(node, &peer_node, resource, capability).await, + ControlRequest::KeychainSync { node: peer_node } => { + keychain_sync_from_peer(node, &peer_node).await + } ControlRequest::CasFetch { node: peer_node, hash, @@ -864,6 +1019,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result Result { + let response = request_peer_control(node, peer_node, "keychain-sync", |peer_card, nonce| { + PeerControlRequest::KeychainSync { peer_card, nonce } + }) + .await?; + match response { + PeerControlResponse::KeychainSynced { + node_id, + agent_id, + endpoint_id, + ops, + signatures, + note, + .. + } => { + let store = Store::open(&node.paths.metadata_db())?; + let mut local_ops = load_keychain_ops(&store)?; + let mut trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).admin_keys; + let mut ops_imported = 0; + let mut signatures_imported = 0; + let mut invalid_ops_rejected = 0; + for op in ops { + let op_signatures = signatures + .iter() + .filter(|signature| signature.op_id == op.id) + .cloned() + .collect::>(); + if op_signatures.is_empty() { + invalid_ops_rejected += 1; + continue; + } + let valid_signatures = op_signatures + .iter() + .filter(|signature| { + if !trusted_admins.contains(&signature.signer) + || !keychain_signature_uses_claimed_key(signature) + { + return false; + } + let stored = stored_keychain_signature_from_signature(signature); + verify_keychain_signature_with_ssh(node, &op, &stored).unwrap_or(false) + }) + .cloned() + .collect::>(); + if valid_signatures.is_empty() { + invalid_ops_rejected += 1; + continue; + } + let existing = load_keychain_ops(&store)? + .into_iter() + .find(|existing| existing.id == op.id); + if existing.as_ref().is_some_and(|existing| existing != &op) { + invalid_ops_rejected += 1; + continue; + } + store_keychain_op(&store, &op)?; + if existing.is_none() { + local_ops.push(op.clone()); + trusted_admins = geth_keychain::reduce_keychain_ops(&local_ops).admin_keys; + ops_imported += 1; + } + for signature in valid_signatures { + store.insert_keychain_signature(&StoredKeychainSignature { + op_id: signature.op_id.to_string(), + signer: signature.signer.to_string(), + signer_public_key: signature.signer_public_key.clone(), + namespace: signature.namespace.clone(), + signature: signature.signature.clone(), + created_at_ms: signature.created_at.0, + })?; + signatures_imported += 1; + } + } + Ok(ControlResponse::KeychainSynced { + peer_node_id: node_id, + peer_agent_id: agent_id, + endpoint_id, + ops_imported, + signatures_imported, + invalid_ops_rejected, + note: format!( + "{note}; imported only keychain ops signed by currently trusted admin SSH keys" + ), + }) + } + PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), + _ => Err(NodeError::IrohPeer( + "peer returned wrong response type to keychain sync".to_owned(), + )), + } +} + async fn db_sync_from_peer( node: &LocalNode, peer_node: &str, @@ -2789,9 +3042,10 @@ async fn request_peer_control( build_request: impl FnOnce(PeerCard, String) -> PeerControlRequest, ) -> Result { let store = Store::open(&node.paths.metadata_db())?; + let peer_node = resolve_peer_node_for_control(&store, peer_node); let stored = store - .get_peer_card(peer_node)? - .ok_or_else(|| NodeError::PeerNotFound(peer_node.to_owned()))?; + .get_peer_card(&peer_node)? + .ok_or_else(|| NodeError::PeerNotFound(peer_node.clone()))?; let peer_card: PeerCard = serde_json::from_str(&stored.card_json)?; peer_card.validate_candidate()?; let candidate = peer_card @@ -2844,6 +3098,10 @@ async fn request_peer_control( nonce: response_nonce, .. } + | PeerControlResponse::KeychainSynced { + nonce: response_nonce, + .. + } | PeerControlResponse::SshRevocationSynced { nonce: response_nonce, .. @@ -3174,6 +3432,31 @@ async fn handle_iroh_control_connection( note: "sync status authenticated endpoint/card binding and returns only streams for capabilities already granted to the caller".to_owned(), } } + PeerControlRequest::KeychainSync { peer_card, nonce } => { + peer_card.validate_candidate()?; + ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; + let discovered = DiscoveredPeer::candidate( + peer_card.clone(), + UnixMillis(geth_store::now_ms()), + DiscoverySource::PeerExchange, + )?; + let store = Store::open(&node.paths.metadata_db())?; + store.upsert_peer_card(&StoredPeerCard { + peer_id: peer_card.node_id.to_string(), + card_json: serde_json::to_string(&peer_card)?, + updated_at_ms: discovered.discovered_at.0, + })?; + PeerControlResponse::KeychainSynced { + node_id: node.node_id.clone(), + agent_id: node.agent_id.clone(), + endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(), + remote_endpoint_id, + ops: load_keychain_ops(&store)?, + signatures: load_keychain_signatures(&store)?, + nonce, + note: "keychain sync returns signed operation-log data; receiver must verify OpenSSH signatures before import".to_owned(), + } + } PeerControlRequest::AuthCheck { peer_card, resource, @@ -5011,24 +5294,13 @@ pub fn handle_request( ops.push(op); } - let signatures = if let Some(signing_key_path) = signing_key_path { - let (signer, signer_public_key) = - keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?; - let mut signatures = Vec::new(); - for op in &ops { - signatures.push(sign_keychain_op_with_ssh( - &store, - node, - op, - &signing_key_path, - &signer, - &signer_public_key, - )?); - } - signatures - } else { - Vec::new() - }; + let signatures = store_and_sign_keychain_ops( + &store, + node, + &ops, + signing_key_path.as_deref(), + admin_key_path.as_deref(), + )?; Ok(ControlResponse::KeychainInitialized { ops, signatures }) } @@ -5046,6 +5318,93 @@ pub fn handle_request( nodes: view.nodes.len(), })) } + ControlRequest::NodeList => { + let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?); + Ok(ControlResponse::NodeList { + nodes: view.nodes.into_values().collect(), + note: "nodes are reduced from the signed keychain operation log; revoked devices and nodes are omitted".to_owned(), + }) + } + ControlRequest::NodeRename { + node: target, + name, + signing_key_path, + } => { + let signing_key_path = signing_key_path + .ok_or_else(|| NodeError::SigningKeyRequired("node rename".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-rename", target.as_str(), created_at), + created_at, + kind: KeychainOpKind::NodeRename { node: target, name }, + }; + 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 rename as a keychain operation".to_owned(), + }) + } + ControlRequest::NodeRevoke { + node: target, + signing_key_path, + } => { + let signing_key_path = signing_key_path + .ok_or_else(|| NodeError::SigningKeyRequired("node 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-revoke", target.as_str(), created_at), + created_at, + kind: KeychainOpKind::NodeRevoke { node: target }, + }; + 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 node revocation as a keychain operation; replicated peers will omit the node after verified keychain sync".to_owned(), + }) + } + ControlRequest::NodeGrant { + node: target, + resource, + capability, + grant_id, + } => { + let target = resolve_keychain_node(&store, &target)?; + let op = record_node_grant(&store, target.as_str(), &resource, &capability, grant_id)?; + Ok(ControlResponse::NodeGrantUpdated { + op, + note: "recorded resource-scoped node capability grant; auth explain can show the grant path".to_owned(), + }) + } + ControlRequest::NodeRevokeGrant { resource, grant_id } => { + let created_at = UnixMillis(geth_store::now_ms()); + let op = AuthOp { + id: generated_auth_op_id("grant-revoke", &resource, &grant_id, created_at), + resource: ResourceId::new(resource), + created_at, + kind: AuthOpKind::GrantRevoke { grant_id }, + }; + store_auth_op(&store, &op)?; + Ok(ControlResponse::NodeGrantUpdated { + op, + note: "recorded capability grant revocation".to_owned(), + }) + } ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus { secrets: store .list_resource_secrets()? @@ -5945,7 +6304,8 @@ pub fn handle_request( } ControlRequest::SshProxyConnect { .. } | ControlRequest::SshProxyStream { .. } - | ControlRequest::SshAdminShell { .. } => Err(NodeError::IrohEndpointUnavailable), + | ControlRequest::SshAdminShell { .. } + | ControlRequest::KeychainSync { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::ModuleStub { module, command } => { Ok(ControlResponse::NotImplemented { module, command }) } @@ -6625,6 +6985,34 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> { Ok(()) } +fn store_and_sign_keychain_ops( + store: &Store, + node: &LocalNode, + ops: &[KeychainOp], + signing_key_path: Option<&Path>, + admin_key_path: Option<&Path>, +) -> Result, NodeError> { + for op in ops { + store_keychain_op(store, op)?; + } + let Some(signing_key_path) = signing_key_path else { + return Ok(Vec::new()); + }; + let (signer, signer_public_key) = keychain_signer_from_paths(signing_key_path, admin_key_path)?; + ops.iter() + .map(|op| { + sign_keychain_op_with_ssh( + store, + node, + op, + signing_key_path, + &signer, + &signer_public_key, + ) + }) + .collect() +} + fn keychain_signer_from_paths( signing_key_path: &Path, admin_key_path: Option<&Path>, @@ -6689,6 +7077,23 @@ fn sign_keychain_op_with_ssh( Ok(signature) } +fn stored_keychain_signature_from_signature( + signature: &KeychainOpSignature, +) -> StoredKeychainSignature { + StoredKeychainSignature { + op_id: signature.op_id.to_string(), + signer: signature.signer.to_string(), + signer_public_key: signature.signer_public_key.clone(), + namespace: signature.namespace.clone(), + signature: signature.signature.clone(), + created_at_ms: signature.created_at.0, + } +} + +fn keychain_signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool { + KeyId::new(ssh_public_key_fingerprint(&signature.signer_public_key)) == signature.signer +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct KeychainSignatureStatus { total: usize, @@ -6787,6 +7192,83 @@ fn load_keychain_ops(store: &Store) -> Result, NodeError> { .collect() } +fn load_keychain_signatures(store: &Store) -> Result, NodeError> { + Ok(store + .list_keychain_signatures()? + .into_iter() + .map(|stored| KeychainOpSignature { + op_id: AuthOpId::new(stored.op_id), + signer: KeyId::new(stored.signer), + signer_public_key: stored.signer_public_key, + namespace: stored.namespace, + signature: stored.signature, + created_at: UnixMillis(stored.created_at_ms), + }) + .collect()) +} + +fn resolve_keychain_node(store: &Store, node_or_name: &str) -> Result { + let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(store)?); + if let Some((node_id, _)) = view + .nodes + .iter() + .find(|(node_id, record)| node_id.as_str() == node_or_name || record.name == node_or_name) + { + Ok(node_id.clone()) + } else { + Err(NodeError::ResourceNotFound(format!("node:{node_or_name}"))) + } +} + +fn resolve_peer_node_for_control(store: &Store, node_or_name: &str) -> String { + resolve_keychain_node(store, node_or_name) + .map(|node| node.to_string()) + .unwrap_or_else(|_| node_or_name.to_owned()) +} + +fn record_node_grant( + store: &Store, + node_id: &str, + resource: &str, + capability: &str, + grant_id: Option, +) -> Result { + 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 { + id: generated_auth_op_id("grant-create", resource, &grant_id, created_at), + resource: ResourceId::new(resource.to_owned()), + created_at, + kind: AuthOpKind::GrantCreate { + grant_id, + 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 { + let slug = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_owned(); + if slug.is_empty() { + "unnamed".to_owned() + } else { + slug + } +} + fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String { format!( "grant:{}", diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 4981e25..3d844e6 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -71,6 +71,19 @@ fn geth_init_in_temp_home() { ); } +#[test] +fn owner_init_requires_admin_key_and_signing_key() { + let home = tempfile::tempdir().expect("tempdir"); + let output = run_geth(home.path(), &["init", "--node-name", "laptop"]); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("owner init requires --admin-key"), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!home.path().join("geth.sqlite").exists()); +} + #[test] fn geth_status_against_running_daemon() { let home = tempfile::tempdir().expect("tempdir"); @@ -929,6 +942,99 @@ fn keychain_init_can_record_openssh_signatures() { } } +#[test] +fn init_owned_node_records_signed_owner_device_and_node() { + if Command::new("ssh-keygen").arg("-?").output().is_err() { + return; + } + + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let admin_key_path = home.path().join("owner_ed25519"); + let status = Command::new("ssh-keygen") + .arg("-q") + .arg("-t") + .arg("ed25519") + .arg("-N") + .arg("") + .arg("-f") + .arg(&admin_key_path) + .status() + .expect("generate owner ssh key"); + assert!(status.success()); + + let node = geth_node::init_owned_node( + &paths, + geth_node::InitOwnerOptions { + admin_key_path: Some(admin_key_path.with_extension("pub")), + signing_key_path: Some(admin_key_path.clone()), + owner_name: "Eric".to_owned(), + node_name: "laptop".to_owned(), + capabilities: vec!["resource:ssh-proxy:local=ssh_proxy.admin_shell".to_owned()], + }, + ) + .expect("init owned node"); + + match geth_node::handle_request(&node, geth_control::ControlRequest::KeychainStatus) + .expect("keychain status") + { + geth_control::ControlResponse::KeychainStatus(status) => { + assert!(status.initialized); + assert_eq!(status.admin_keys, 1); + assert_eq!(status.users, 1); + assert_eq!(status.devices, 1); + assert_eq!(status.nodes, 1); + assert_eq!(status.verified_signatures, status.signatures); + assert!(status.signatures >= 6); + } + other => panic!("unexpected response: {other:?}"), + } + + match geth_node::handle_request(&node, geth_control::ControlRequest::NodeList) + .expect("node list") + { + geth_control::ControlResponse::NodeList { nodes, .. } => { + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].name, "laptop"); + assert_eq!(nodes[0].id.as_str(), node.node_id); + } + other => panic!("unexpected response: {other:?}"), + } + + let renamed = geth_node::handle_request( + &node, + geth_control::ControlRequest::NodeRename { + node: "laptop".to_owned(), + name: "work-laptop".to_owned(), + signing_key_path: Some(admin_key_path), + }, + ) + .expect("rename node"); + match renamed { + geth_control::ControlResponse::NodeKeychainUpdated { signatures, .. } => { + assert_eq!(signatures.len(), 1); + } + other => panic!("unexpected response: {other:?}"), + } + + let explained = geth_node::handle_request( + &node, + geth_control::ControlRequest::AuthExplain { + subject: node.node_id.clone(), + resource: "resource:ssh-proxy:local".to_owned(), + capability: "ssh_proxy.admin_shell".to_owned(), + }, + ) + .expect("explain init grant"); + match explained { + geth_control::ControlResponse::AuthExplain(explanation) => { + assert!(explanation.allowed); + assert!(explanation.reason.contains("direct grant")); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn db_add_and_status_register_local_db_metadata() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/adr/0016-owner-node-enrollment.md b/docs/adr/0016-owner-node-enrollment.md new file mode 100644 index 0000000..a08f90c --- /dev/null +++ b/docs/adr/0016-owner-node-enrollment.md @@ -0,0 +1,42 @@ +# ADR 0016: Owner-Rooted Node Enrollment + +## Status + +Accepted. + +## Context + +Geth needs a practical device-management flow: initialize a mesh with an +admin/owner identity, bind local daemon instances to named nodes/devices, list +devices by name, grant resource capabilities, and revoke devices. This must not +be a mutable device table because the project needs to evolve toward replicated +local-first authorization. + +## Decision + +Owner and node management is represented as signed keychain operations. `geth +init --admin-key --signing-key --node-name ` records +`KeychainInit`, `AdminKeyAdd`, `UserAdd`, `DeviceAdd`, `NodeAdd`, and +`AgentBind` operations. When owner setup options are used, both `--admin-key` +and `--signing-key` are required. Geth signs canonical keychain payloads with +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 keychain sync ` pulls keychain operations and signatures over Iroh +from an imported peer. The receiver imports only operations with valid OpenSSH +signatures from currently trusted admin keys over the canonical keychain +payload. Discovery and peer cards still grant no trust by themselves. + +## Consequences + +Node names are convenience labels over stable node IDs. Endpoint rotation should +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, does not implement advanced group +cryptography, and does not yet sign/verify replicated auth operations. Those are +future roadmap items. diff --git a/docs/architecture.md b/docs/architecture.md index 11ee5b3..6b5c23c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -275,15 +275,18 @@ The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents and endpoint bindings. Endpoint rotation must not destroy higher-level node 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 -daemon persists local keychain init/admin-key operations and `keychain status` -reports the reduced local view. `keychain init --signing-key ` writes the -canonical keychain signing payloads, runs `ssh-keygen -Y sign` with the explicit -`geth.keychain.v1@geth.local` namespace, and stores the resulting OpenSSH -signatures in local SQLite. `keychain status` reports the stored signature -count and verifies stored signatures against their canonical payloads with -OpenSSH when possible. Rejection of unsigned or invalid replicated keychain -operations is still future work. +bindings. Revoked identity subtrees are excluded from that active view. `geth +init --admin-key --signing-key --node-name ` records an +owner/admin key, user, device, node, and agent binding as keychain operations +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. The authorization plane is `geth-auth`: resource-local signed operation logs, grants, revocations, groups, and `auth explain`. Auth operations reduce into a diff --git a/docs/roadmap.md b/docs/roadmap.md index 76435b2..28143a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -182,8 +182,12 @@ resource-scoped capability decisions. - `[x]` Tests cover signed keychain init with a generated local OpenSSH key when `ssh-keygen` is available. - `[x]` Tests cover local OpenSSH verification of stored keychain signatures. - - `[ ]` Future completion verifies signatures before accepting replicated - keychain ops. + - `[x]` `geth init --admin-key --signing-key --node-name` records signed + owner/user/device/node/agent binding operations. + - `[x]` `geth node list/rename/revoke` operate on the reduced keychain view. + - `[x]` `geth node rename/revoke` require an admin signing key. + - `[x]` `geth keychain sync ` verifies signatures from currently + trusted admin keys before accepting keychain ops. - `[x]` Keychain operation reducer. Acceptance criteria: @@ -192,6 +196,17 @@ resource-scoped capability decisions. - Revoked keys/devices/nodes are excluded from active views. - Tests cover add, rename, revoke, and endpoint rotation. +- `[~]` Node capability management. + Acceptance criteria: + - `[x]` `geth node grant ` records a + resource-scoped capability grant for a known node. + - `[x]` `geth node revoke-grant ` records grant + revocation. + - `[x]` Node names can be used for management commands where the keychain view + has a unique active node name. + - `[ ]` Future completion signs auth ops and verifies signed auth ops before + accepting replicated permission changes. + - `[x]` Resource auth operation reducer. Acceptance criteria: - Resource create, authority set, grants, revocations, and groups reduce into