From 99f6dee26f26298ef80e15d527da0a4c3c3fa2fe Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 16 May 2026 16:34:20 +0200 Subject: [PATCH] Wire keychain status to local keychain ops --- AGENTS.md | 2 + Cargo.lock | 2 + README.md | 1 + crates/geth-cli/src/lib.rs | 18 ++++++--- crates/geth-control/Cargo.toml | 1 + crates/geth-control/src/lib.rs | 7 ++++ crates/geth-node/Cargo.toml | 1 + crates/geth-node/src/lib.rs | 70 +++++++++++++++++++++++++++++----- crates/geth-store/src/lib.rs | 55 ++++++++++++++++++++++++++ crates/geth/tests/bootstrap.rs | 34 +++++++++++++++++ docs/architecture.md | 5 ++- docs/roadmap.md | 13 +++++-- 12 files changed, 190 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8c017fb..ac0b926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,8 @@ Roadmap items should be actionable and checkable: groups, and bearer access. The daemon persists local auth grant/revoke ops and uses them for `auth explain`. Signature validation and daemon-side module enforcement are still roadmap work. +- The daemon persists local keychain init/admin-key ops and reduces them for + `keychain status`. SSH signature capture/verification is still roadmap work. - 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/Cargo.lock b/Cargo.lock index bd2262c..cf176ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1115,6 +1115,7 @@ name = "geth-control" version = "0.1.0" dependencies = [ "geth-auth", + "geth-keychain", "geth-resource", "geth-ssh-identity", "geth-types", @@ -1205,6 +1206,7 @@ dependencies = [ "geth-control", "geth-crypto", "geth-iroh", + "geth-keychain", "geth-resource", "geth-ssh-identity", "geth-store", diff --git a/README.md b/README.md index 35960ed..181d18b 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ The bootstrap implementation provides: - `geth node id` - `geth resource list` - `geth resource create ` +- `geth keychain init [--admin-key ]` - `geth keychain status` - `geth auth explain ` - `geth auth grant [--grant-id ]` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index b5f6d18..5786187 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -131,7 +131,10 @@ pub enum ResourceCommand { #[derive(Debug, Subcommand)] pub enum KeychainCommand { - Init, + Init { + #[arg(long)] + admin_key: Option, + }, Status, } @@ -341,10 +344,9 @@ fn request_for_command(command: Command) -> Result { command: ResourceCommand::Create { kind, name }, } => ControlRequest::ResourceCreate { kind, name }, Command::Keychain { - command: KeychainCommand::Init, - } => ControlRequest::ModuleStub { - module: "keychain".to_owned(), - command: "init".to_owned(), + command: KeychainCommand::Init { admin_key }, + } => ControlRequest::KeychainInit { + admin_key_path: admin_key, }, Command::Keychain { command: KeychainCommand::Status, @@ -595,6 +597,12 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("devices: {}", status.devices); println!("nodes: {}", status.nodes); } + ControlResponse::KeychainInitialized { ops } => { + println!("initialized keychain"); + for op in ops { + println!("recorded keychain op: {}", op.id); + } + } ControlResponse::AuthExplain(explain) => { println!("allowed: {}", explain.allowed); println!("subject: {}", explain.subject); diff --git a/crates/geth-control/Cargo.toml b/crates/geth-control/Cargo.toml index e134aec..bc062f9 100644 --- a/crates/geth-control/Cargo.toml +++ b/crates/geth-control/Cargo.toml @@ -10,6 +10,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true geth-auth = { path = "../geth-auth" } +geth-keychain = { path = "../geth-keychain" } geth-resource = { path = "../geth-resource" } geth-ssh-identity = { path = "../geth-ssh-identity" } geth-types = { path = "../geth-types" } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index f77c744..ce63961 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1,4 +1,5 @@ use geth_auth::{AuthExplanation, AuthOp}; +use geth_keychain::KeychainOp; use geth_resource::ResourceDescriptor; use geth_ssh_identity::{ SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, @@ -31,6 +32,9 @@ pub enum ControlRequest { hash: BlobHash, }, CasList, + KeychainInit { + admin_key_path: Option, + }, KeychainStatus, AuthExplain { subject: String, @@ -114,6 +118,9 @@ pub enum ControlResponse { blobs: Vec, }, KeychainStatus(KeychainStatusResponse), + KeychainInitialized { + ops: Vec, + }, AuthExplain(AuthExplanation), AuthOpRecorded { op: AuthOp, diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 8f07f40..a2cd1d5 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -16,6 +16,7 @@ geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } geth-crypto = { path = "../geth-crypto" } geth-iroh = { path = "../geth-iroh" } +geth-keychain = { path = "../geth-keychain" } geth-resource = { path = "../geth-resource" } geth-ssh-identity = { path = "../geth-ssh-identity" } geth-store = { path = "../geth-store" } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index fb5e708..397be5e 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -9,6 +9,7 @@ use geth_control::{ }; use geth_crypto::AgentKey; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; +use geth_keychain::{KeychainOp, KeychainOpKind}; use geth_resource::ResourceDescriptor; use geth_ssh_identity::{ SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, @@ -16,12 +17,12 @@ use geth_ssh_identity::{ certificate_id, revocation_id, ssh_public_key_fingerprint, }; use geth_store::{ - Store, StoredAuthOp, StoredResource, StoredSshCertRequest, StoredSshCertificate, - StoredSshRevocation, + Store, StoredAuthOp, StoredKeychainOp, StoredResource, StoredSshCertRequest, + StoredSshCertificate, StoredSshRevocation, }; use geth_types::{ - AuthOpId, Capability, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, SshCertId, - SshCertRequestId, UnixMillis, + AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, + SshCertId, SshCertRequestId, UnixMillis, }; use std::path::Path; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -237,13 +238,40 @@ pub fn handle_request( .collect(), }) } + ControlRequest::KeychainInit { admin_key_path } => { + let mut ops = Vec::new(); + let created_at = UnixMillis(geth_store::now_ms()); + let init = KeychainOp { + id: generated_keychain_op_id("keychain-init", "local", created_at), + created_at, + kind: KeychainOpKind::KeychainInit, + }; + store_keychain_op(&store, &init)?; + ops.push(init); + + if let Some(admin_key_path) = admin_key_path { + let public_key = std::fs::read_to_string(admin_key_path)?; + let created_at = UnixMillis(geth_store::now_ms()); + let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key)); + let op = KeychainOp { + id: generated_keychain_op_id("admin-key-add", admin_key.as_str(), created_at), + created_at, + kind: KeychainOpKind::AdminKeyAdd { key: admin_key }, + }; + store_keychain_op(&store, &op)?; + ops.push(op); + } + + Ok(ControlResponse::KeychainInitialized { ops }) + } ControlRequest::KeychainStatus => { + let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?); Ok(ControlResponse::KeychainStatus(KeychainStatusResponse { - initialized: false, - admin_keys: 0, - users: 0, - devices: 0, - nodes: 1, + initialized: view.initialized, + admin_keys: view.admin_keys.len(), + users: view.users.len(), + devices: view.devices.len(), + nodes: view.nodes.len(), })) } ControlRequest::AuthExplain { @@ -518,6 +546,23 @@ fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result Result<(), NodeError> { + store.insert_keychain_op(&StoredKeychainOp { + op_id: op.id.to_string(), + op_json: serde_json::to_string(op)?, + created_at_ms: op.created_at.0, + })?; + Ok(()) +} + +fn load_keychain_ops(store: &Store) -> Result, NodeError> { + store + .list_keychain_ops()? + .into_iter() + .map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from)) + .collect() +} + fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String { format!( "grant:{}", @@ -539,6 +584,13 @@ fn generated_auth_op_id( )) } +fn generated_keychain_op_id(kind: &str, stable_id: &str, created_at: UnixMillis) -> AuthOpId { + AuthOpId::new(format!( + "keychain-op:{}", + geth_crypto::blake3_hex(format!("{}\0{kind}\0{stable_id}", created_at.0).as_bytes()) + )) +} + fn stable_node_id(agent_id: &str) -> String { format!("node:{agent_id}") } diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index ae1dbb9..a129b7e 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -318,6 +318,31 @@ impl Store { .map_err(StoreError::from) } + pub fn insert_keychain_op(&self, op: &StoredKeychainOp) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO keychain_ops(op_id, op_json, created_at_ms) + VALUES (?1, ?2, ?3)"#, + params![op.op_id, op.op_json, op.created_at_ms], + )?; + Ok(()) + } + + pub fn list_keychain_ops(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT op_id, op_json, created_at_ms + FROM keychain_ops ORDER BY created_at_ms, op_id"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(StoredKeychainOp { + op_id: row.get(0)?, + op_json: row.get(1)?, + created_at_ms: row.get(2)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn insert_ssh_cert_request( &self, request: &StoredSshCertRequest, @@ -513,6 +538,13 @@ pub struct StoredAuthOp { pub created_at_ms: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredKeychainOp { + pub op_id: String, + pub op_json: String, + pub created_at_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredSshCertRequest { pub request_id: String, @@ -653,4 +685,27 @@ mod tests { vec![first] ); } + + #[test] + fn keychain_ops_roundtrip() { + let store = Store::open_memory().expect("open"); + let first = StoredKeychainOp { + op_id: "op:keychain:1".to_owned(), + op_json: r#"{"id":"op:keychain:1"}"#.to_owned(), + created_at_ms: 1, + }; + let second = StoredKeychainOp { + op_id: "op:keychain:2".to_owned(), + op_json: r#"{"id":"op:keychain:2"}"#.to_owned(), + created_at_ms: 2, + }; + + store.insert_keychain_op(&second).expect("insert second"); + store.insert_keychain_op(&first).expect("insert first"); + + assert_eq!( + store.list_keychain_ops().expect("list keychain ops"), + vec![first, second] + ); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index cd85880..4412251 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -223,6 +223,40 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() { } } +#[test] +fn keychain_init_and_status_use_local_keychain_log() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + let admin_key_path = home.path().join("admin.pub"); + std::fs::write(&admin_key_path, "ssh-ed25519 AAAAADMIN eric@geth\n").expect("write admin key"); + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainInit { + admin_key_path: Some(admin_key_path), + }, + ) + .expect("init keychain"); + match response { + geth_control::ControlResponse::KeychainInitialized { ops } => { + assert_eq!(ops.len(), 2); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainStatus) + .expect("keychain status"); + match response { + geth_control::ControlResponse::KeychainStatus(status) => { + assert!(status.initialized); + assert_eq!(status.admin_keys, 1); + assert_eq!(status.users, 0); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn ssh_cert_request_approval_and_revocation_export_use_local_state() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index 5b8ec04..fe70c9f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,7 +105,10 @@ 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. +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. OpenSSH signature capture and verification for +those operations is still future work. 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 81c49b8..56802ed 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -138,11 +138,16 @@ resource-scoped capability decisions. - JSON is not used as the signed representation. - Tests verify equivalent operations hash/sign identically across runs. -- `[ ]` SSH-admin-rooted keychain initialization. +- `[~]` SSH-admin-rooted keychain initialization. Acceptance criteria: - - `geth keychain init` records a signed `KeychainInit`. - - OpenSSH signature namespaces are explicit. - - Missing `ssh-keygen` or unavailable hardware keys produce clear errors. + - `[x]` `geth keychain init` records a local `KeychainInit`. + - `[x]` `geth keychain init --admin-key ` records an admin SSH public + key fingerprint. + - `[x]` `geth keychain status` reports the reduced local keychain view. + - `[ ]` Future completion records signed `KeychainInit` operations. + - `[ ]` OpenSSH signature namespaces are explicit in the signing flow. + - `[ ]` Missing `ssh-keygen` or unavailable hardware keys produce clear + errors during signing. - `[x]` Keychain operation reducer. Acceptance criteria: