diff --git a/AGENTS.md b/AGENTS.md index c08a767..8162273 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,7 +123,10 @@ Roadmap items should be actionable and checkable: 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`. SSH signature capture/verification is still roadmap work. + `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. + Verification before accepting replicated keychain ops is still roadmap work. - 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/Cargo.lock b/Cargo.lock index 90a312b..e6ddcd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1233,6 +1233,7 @@ dependencies = [ "base64", "geth-auth", "geth-cas", + "geth-codec", "geth-config", "geth-control", "geth-crypto", diff --git a/README.md b/README.md index f54a9cb..11bf760 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ 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.keychain.v1@geth.local` namespace. This is the bootstrap path for +admin/YubiKey-rooted trust; signature verification for replicated keychain ops is +still future work. + The daemon can also install itself as a user service: ```sh @@ -82,7 +89,7 @@ The bootstrap implementation provides: - `geth peer auth-check ` - `geth resource list` - `geth resource create ` -- `geth keychain init [--admin-key ]` +- `geth keychain init [--admin-key ] [--signing-key ]` - `geth keychain status` - `geth secret status` - `geth secret create ` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index fa3de1e..28e2693 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -158,6 +158,8 @@ pub enum KeychainCommand { Init { #[arg(long)] admin_key: Option, + #[arg(long)] + signing_key: Option, }, Status, } @@ -548,9 +550,14 @@ fn request_for_command(command: Command) -> Result { command: ResourceCommand::Create { kind, name }, } => ControlRequest::ResourceCreate { kind, name }, Command::Keychain { - command: KeychainCommand::Init { admin_key }, + command: + KeychainCommand::Init { + admin_key, + signing_key, + }, } => ControlRequest::KeychainInit { admin_key_path: admin_key, + signing_key_path: signing_key, }, Command::Keychain { command: KeychainCommand::Status, @@ -1099,11 +1106,17 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("devices: {}", status.devices); println!("nodes: {}", status.nodes); } - ControlResponse::KeychainInitialized { ops } => { + ControlResponse::KeychainInitialized { ops, signatures } => { println!("initialized keychain"); 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 + ); + } } ControlResponse::SecretStatus { secrets } => { if secrets.is_empty() { diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index e32dab1..2feba66 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; +use geth_keychain::{KeychainOp, KeychainOpSignature}; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_pipe::{PipeConnection, PipeListener}; use geth_pubsub::PubsubMessage; @@ -99,6 +99,7 @@ pub enum ControlRequest { }, KeychainInit { admin_key_path: Option, + signing_key_path: Option, }, KeychainStatus, SecretStatus, @@ -369,6 +370,7 @@ pub enum ControlResponse { KeychainStatus(KeychainStatusResponse), KeychainInitialized { ops: Vec, + signatures: Vec, }, SecretStatus { secrets: Vec, @@ -911,6 +913,15 @@ mod tests { #[test] fn control_request_response_serialization_roundtrip() { + let request = ControlRequest::KeychainInit { + admin_key_path: Some(PathBuf::from("admin.pub")), + signing_key_path: Some(PathBuf::from("admin")), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + let request = ControlRequest::CasHas { hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), }; diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 057496a..9d9900f 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -1,4 +1,4 @@ -use geth_types::{AgentId, DeviceId, KeyId, NodeId, UnixMillis, UserId}; +use geth_types::{AgentId, AuthOpId, DeviceId, KeyId, NodeId, UnixMillis, UserId}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -28,6 +28,15 @@ pub struct KeychainOp { pub kind: KeychainOpKind, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeychainOpSignature { + pub op_id: AuthOpId, + pub signer: KeyId, + pub namespace: String, + pub signature: Vec, + pub created_at: UnixMillis, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum KeychainOpKind { diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 4593cb9..986bbf5 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -14,6 +14,7 @@ tokio.workspace = true tracing.workspace = true geth-auth = { path = "../geth-auth" } geth-cas = { path = "../geth-cas" } +geth-codec = { path = "../geth-codec" } geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } geth-crypto = { path = "../geth-crypto" } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index b40ef4d..cc02a1d 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -19,7 +19,7 @@ use geth_discovery::{ }; use geth_document::{DocumentResource, DocumentState}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; -use geth_keychain::{KeychainOp, KeychainOpKind}; +use geth_keychain::{KeychainOp, KeychainOpKind, KeychainOpSignature}; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_pipe::{PipeConnection, PipeListener}; use geth_pubsub::PubsubMessage; @@ -34,8 +34,8 @@ use geth_ssh_identity::{ use geth_ssh_proxy::SshProxyConnection; use geth_store::{ Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredFileConflict, - StoredFileRoot, StoredKeychainOp, StoredKvEntry, StoredKvStore, StoredModuleState, - StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest, + StoredFileRoot, StoredKeychainOp, StoredKeychainSignature, StoredKvEntry, StoredKvStore, + StoredModuleState, StoredPeerCard, StoredResource, StoredResourceSecret, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, }; use geth_types::{ @@ -63,6 +63,8 @@ pub enum NodeError { Db(#[from] geth_db::DbError), #[error("control error: {0}")] Control(#[from] geth_control::ControlError), + #[error("codec error: {0}")] + Codec(#[from] geth_codec::CodecError), #[error("json error: {0}")] Json(#[from] serde_json::Error), #[error("io error: {0}")] @@ -2860,7 +2862,10 @@ pub fn handle_request( conflict: file_conflict_from_stored(conflict)?, }) } - ControlRequest::KeychainInit { admin_key_path } => { + ControlRequest::KeychainInit { + admin_key_path, + signing_key_path, + } => { let mut ops = Vec::new(); let created_at = UnixMillis(geth_store::now_ms()); let init = KeychainOp { @@ -2871,7 +2876,7 @@ pub fn handle_request( store_keychain_op(&store, &init)?; ops.push(init); - if let Some(admin_key_path) = admin_key_path { + if let Some(admin_key_path) = admin_key_path.as_ref() { 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)); @@ -2884,7 +2889,25 @@ pub fn handle_request( ops.push(op); } - Ok(ControlResponse::KeychainInitialized { ops }) + let signatures = if let Some(signing_key_path) = signing_key_path { + let signer = + 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, + )?); + } + signatures + } else { + Vec::new() + }; + + Ok(ControlResponse::KeychainInitialized { ops, signatures }) } ControlRequest::KeychainStatus => { let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?); @@ -3994,6 +4017,64 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> { Ok(()) } +fn keychain_signer_from_paths( + signing_key_path: &Path, + admin_key_path: Option<&Path>, +) -> Result { + let public_key_path = admin_key_path + .map(Path::to_path_buf) + .unwrap_or_else(|| Path::new(&format!("{}.pub", signing_key_path.display())).to_path_buf()); + let public_key = std::fs::read_to_string(public_key_path)?; + Ok(KeyId::new(ssh_public_key_fingerprint(&public_key))) +} + +fn sign_keychain_op_with_ssh( + store: &Store, + node: &LocalNode, + op: &KeychainOp, + signing_key_path: &Path, + signer: &KeyId, +) -> Result { + geth_ssh_identity::ensure_ssh_keygen_available()?; + let signature_dir = node.paths.home().join("keychain-signatures"); + std::fs::create_dir_all(&signature_dir)?; + let payload_path = signature_dir.join(format!( + "{}.payload", + geth_crypto::blake3_hex(op.id.as_str().as_bytes()) + )); + std::fs::write(&payload_path, geth_keychain::keychain_signing_payload(op)?)?; + let output = geth_ssh_identity::sign_command( + signing_key_path, + geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE, + &payload_path, + ) + .output()?; + if !output.status.success() { + return Err(geth_ssh_identity::SshIdentityError::SshKeygenFailed( + String::from_utf8_lossy(&output.stderr).trim().to_owned(), + ) + .into()); + } + let signature_path = Path::new(&format!("{}.sig", payload_path.display())).to_path_buf(); + let signature_bytes = std::fs::read(signature_path)?; + let created_at = UnixMillis(geth_store::now_ms()); + let signature = KeychainOpSignature { + op_id: op.id.clone(), + signer: signer.clone(), + namespace: geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), + signature: signature_bytes, + created_at, + }; + store.insert_keychain_signature(&StoredKeychainSignature { + op_id: signature.op_id.to_string(), + signer: signature.signer.to_string(), + namespace: signature.namespace.clone(), + signature: signature.signature.clone(), + created_at_ms: signature.created_at.0, + })?; + Ok(signature) +} + fn load_keychain_ops(store: &Store) -> Result, NodeError> { store .list_keychain_ops()? diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 59ddb1d..a0909de 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -70,6 +70,14 @@ impl Store { op_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS keychain_signatures ( + op_id TEXT NOT NULL, + signer TEXT NOT NULL, + namespace TEXT NOT NULL, + signature BLOB NOT NULL, + created_at_ms INTEGER NOT NULL, + PRIMARY KEY (op_id, signer, namespace) + ); CREATE TABLE IF NOT EXISTS auth_ops ( op_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, @@ -921,6 +929,44 @@ impl Store { .map_err(StoreError::from) } + pub fn insert_keychain_signature( + &self, + signature: &StoredKeychainSignature, + ) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO keychain_signatures( + op_id, signer, namespace, signature, created_at_ms + ) + VALUES (?1, ?2, ?3, ?4, ?5)"#, + params![ + signature.op_id, + signature.signer, + signature.namespace, + signature.signature, + signature.created_at_ms + ], + )?; + Ok(()) + } + + pub fn list_keychain_signatures(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT op_id, signer, namespace, signature, created_at_ms + FROM keychain_signatures ORDER BY created_at_ms, op_id, signer, namespace"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(StoredKeychainSignature { + op_id: row.get(0)?, + signer: row.get(1)?, + namespace: row.get(2)?, + signature: row.get(3)?, + created_at_ms: row.get(4)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn insert_ssh_cert_request( &self, request: &StoredSshCertRequest, @@ -1292,6 +1338,15 @@ pub struct StoredKeychainOp { pub created_at_ms: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredKeychainSignature { + pub op_id: String, + pub signer: String, + pub namespace: String, + pub signature: Vec, + pub created_at_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredSshCertRequest { pub request_id: String, @@ -1486,6 +1541,29 @@ mod tests { ); } + #[test] + fn keychain_signatures_roundtrip() { + let store = Store::open_memory().expect("open"); + let signature = StoredKeychainSignature { + op_id: "op:keychain:1".to_owned(), + signer: "ssh:blake3:admin".to_owned(), + namespace: "geth.keychain.v1@geth.local".to_owned(), + signature: b"-----BEGIN SSH SIGNATURE-----".to_vec(), + created_at_ms: 3, + }; + + store + .insert_keychain_signature(&signature) + .expect("insert signature"); + + assert_eq!( + store + .list_keychain_signatures() + .expect("list keychain signatures"), + vec![signature] + ); + } + #[test] fn cas_pins_roundtrip() { let store = Store::open_memory().expect("open"); diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 6a6f33b..948c8cf 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -691,12 +691,14 @@ fn keychain_init_and_status_use_local_keychain_log() { &node, geth_control::ControlRequest::KeychainInit { admin_key_path: Some(admin_key_path), + signing_key_path: None, }, ) .expect("init keychain"); match response { - geth_control::ControlResponse::KeychainInitialized { ops } => { + geth_control::ControlResponse::KeychainInitialized { ops, signatures } => { assert_eq!(ops.len(), 2); + assert!(signatures.is_empty()); } other => panic!("unexpected response: {other:?}"), } @@ -713,6 +715,55 @@ fn keychain_init_and_status_use_local_keychain_log() { } } +#[test] +fn keychain_init_can_record_openssh_signatures() { + 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 node = geth_node::init_node(&paths).expect("init node"); + let admin_key_path = home.path().join("admin_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 admin ssh key"); + assert!(status.success()); + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainInit { + admin_key_path: Some(admin_key_path.with_extension("pub")), + signing_key_path: Some(admin_key_path), + }, + ) + .expect("init signed keychain"); + match response { + geth_control::ControlResponse::KeychainInitialized { ops, signatures } => { + assert_eq!(ops.len(), 2); + assert_eq!(signatures.len(), 2); + assert!(signatures.iter().all(|signature| { + signature.namespace == "geth.keychain.v1@geth.local" + && !signature.signature.is_empty() + })); + } + other => panic!("unexpected response: {other:?}"), + } + + let signatures = geth_store::Store::open(&paths.metadata_db()) + .expect("open store") + .list_keychain_signatures() + .expect("list signatures"); + assert_eq!(signatures.len(), 2); +} + #[test] fn db_add_and_status_register_local_db_metadata() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index a60ebf3..d9e60c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -235,8 +235,11 @@ 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. OpenSSH signature capture and verification for -those operations is still future work. +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. Verification and rejection of unsigned replicated +keychain operations are 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 b903395..5598ef3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -169,10 +169,17 @@ resource-scoped capability decisions. - `[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 + - `[x]` `geth keychain init --signing-key ` signs recorded keychain ops + with `ssh-keygen -Y sign`. + - `[x]` OpenSSH keychain signatures use the explicit + `geth.keychain.v1@geth.local` namespace. + - `[x]` Keychain OpenSSH signatures are stored in local SQLite. + - `[x]` Missing `ssh-keygen` or unavailable hardware keys produce clear errors during signing. + - `[x]` Tests cover signed keychain init with a generated local OpenSSH key + when `ssh-keygen` is available. + - `[ ]` Future completion verifies signatures before accepting replicated + keychain ops. - `[x]` Keychain operation reducer. Acceptance criteria: