From f7f14f6b2791ffbb470260b43268c9715fa2c35a Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Tue, 19 May 2026 18:58:07 +0200 Subject: [PATCH] Verify stored keychain SSH signatures --- AGENTS.md | 4 +- README.md | 4 +- crates/geth-cli/src/lib.rs | 2 + crates/geth-control/src/lib.rs | 2 + crates/geth-keychain/src/lib.rs | 1 + crates/geth-node/src/lib.rs | 108 ++++++++++++++++++++++++++++++-- crates/geth-store/src/lib.rs | 41 ++++++++++-- crates/geth/tests/bootstrap.rs | 5 ++ docs/architecture.md | 5 +- docs/roadmap.md | 3 + 10 files changed, 160 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8162273..edb9036 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,9 @@ Roadmap items should be actionable and checkable: `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. + `keychain status` verifies stored signatures with OpenSSH when public key + material is available. 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/README.md b/README.md index 3716059..667ce8c 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ 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. `geth keychain status` reports the number of stored -keychain signatures; signature verification for replicated keychain ops is still -future work. +keychain signatures plus how many currently verify with OpenSSH; rejecting +unsigned or invalid replicated keychain ops is still future work. The daemon can also install itself as a user service: diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 35de9ef..7fbffa2 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -1103,6 +1103,8 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("initialized: {}", status.initialized); println!("admin_keys: {}", status.admin_keys); println!("signatures: {}", status.signatures); + println!("verified_signatures: {}", status.verified_signatures); + println!("failed_signatures: {}", status.failed_signatures); println!("users: {}", status.users); println!("devices: {}", status.devices); println!("nodes: {}", status.nodes); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index e691015..539e50e 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -593,6 +593,8 @@ pub struct KeychainStatusResponse { pub initialized: bool, pub admin_keys: usize, pub signatures: usize, + pub verified_signatures: usize, + pub failed_signatures: usize, pub users: usize, pub devices: usize, pub nodes: usize, diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 9d9900f..9328e26 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -32,6 +32,7 @@ pub struct KeychainOp { pub struct KeychainOpSignature { pub op_id: AuthOpId, pub signer: KeyId, + pub signer_public_key: String, pub namespace: String, pub signature: Vec, pub created_at: UnixMillis, diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 500d25e..29d2a81 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -2890,7 +2890,7 @@ pub fn handle_request( } let signatures = if let Some(signing_key_path) = signing_key_path { - let signer = + 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 { @@ -2900,6 +2900,7 @@ pub fn handle_request( op, &signing_key_path, &signer, + &signer_public_key, )?); } signatures @@ -2911,10 +2912,13 @@ pub fn handle_request( } ControlRequest::KeychainStatus => { let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?); + let signature_status = verify_keychain_signatures(&store, node)?; Ok(ControlResponse::KeychainStatus(KeychainStatusResponse { initialized: view.initialized, admin_keys: view.admin_keys.len(), - signatures: store.list_keychain_signatures()?.len(), + signatures: signature_status.total, + verified_signatures: signature_status.verified, + failed_signatures: signature_status.failed, users: view.users.len(), devices: view.devices.len(), nodes: view.nodes.len(), @@ -4021,12 +4025,15 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> { fn keychain_signer_from_paths( signing_key_path: &Path, admin_key_path: Option<&Path>, -) -> Result { +) -> Result<(KeyId, String), NodeError> { 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))) + Ok(( + KeyId::new(ssh_public_key_fingerprint(&public_key)), + public_key, + )) } fn sign_keychain_op_with_ssh( @@ -4035,6 +4042,7 @@ fn sign_keychain_op_with_ssh( op: &KeychainOp, signing_key_path: &Path, signer: &KeyId, + signer_public_key: &str, ) -> Result { geth_ssh_identity::ensure_ssh_keygen_available()?; let signature_dir = node.paths.home().join("keychain-signatures"); @@ -4062,6 +4070,7 @@ fn sign_keychain_op_with_ssh( let signature = KeychainOpSignature { op_id: op.id.clone(), signer: signer.clone(), + signer_public_key: signer_public_key.to_owned(), namespace: geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), signature: signature_bytes, created_at, @@ -4069,6 +4078,7 @@ fn sign_keychain_op_with_ssh( 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, @@ -4076,6 +4086,96 @@ fn sign_keychain_op_with_ssh( Ok(signature) } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct KeychainSignatureStatus { + total: usize, + verified: usize, + failed: usize, +} + +fn verify_keychain_signatures( + store: &Store, + node: &LocalNode, +) -> Result { + let ops = load_keychain_ops(store)?; + let ops_by_id = ops + .into_iter() + .map(|op| (op.id.to_string(), op)) + .collect::>(); + let signatures = store.list_keychain_signatures()?; + let total = signatures.len(); + let mut verified = 0; + let mut failed = 0; + for signature in signatures { + let Some(op) = ops_by_id.get(&signature.op_id) else { + failed += 1; + continue; + }; + if verify_keychain_signature_with_ssh(node, op, &signature)? { + verified += 1; + } else { + failed += 1; + } + } + Ok(KeychainSignatureStatus { + total, + verified, + failed, + }) +} + +fn verify_keychain_signature_with_ssh( + node: &LocalNode, + op: &KeychainOp, + signature: &StoredKeychainSignature, +) -> Result { + if signature.signer_public_key.trim().is_empty() { + return Ok(false); + } + if geth_ssh_identity::ensure_ssh_keygen_available().is_err() { + return Ok(false); + } + + let verify_dir = node.paths.home().join("keychain-signatures").join("verify"); + std::fs::create_dir_all(&verify_dir)?; + let stable_id = geth_crypto::blake3_hex( + format!( + "{}\0{}\0{}\0{}", + op.id, signature.signer, signature.namespace, signature.created_at_ms + ) + .as_bytes(), + ); + let payload_path = verify_dir.join(format!("{stable_id}.payload")); + let signature_path = verify_dir.join(format!("{stable_id}.sig")); + let allowed_signers_path = verify_dir.join(format!("{stable_id}.allowed-signers")); + std::fs::write(&payload_path, geth_keychain::keychain_signing_payload(op)?)?; + std::fs::write(&signature_path, &signature.signature)?; + std::fs::write( + &allowed_signers_path, + format!( + "{} {}\n", + signature.signer, + signature.signer_public_key.trim() + ), + )?; + + let payload = std::fs::File::open(&payload_path)?; + let output = std::process::Command::new("ssh-keygen") + .arg("-Y") + .arg("verify") + .arg("-f") + .arg(&allowed_signers_path) + .arg("-I") + .arg(&signature.signer) + .arg("-n") + .arg(&signature.namespace) + .arg("-s") + .arg(&signature_path) + .stdin(std::process::Stdio::from(payload)) + .output()?; + Ok(output.status.success()) +} + 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 a0909de..3eabb4a 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -73,6 +73,7 @@ impl Store { CREATE TABLE IF NOT EXISTS keychain_signatures ( op_id TEXT NOT NULL, signer TEXT NOT NULL, + signer_public_key TEXT NOT NULL DEFAULT '', namespace TEXT NOT NULL, signature BLOB NOT NULL, created_at_ms INTEGER NOT NULL, @@ -205,6 +206,30 @@ impl Store { INSERT OR IGNORE INTO meta(key, value) VALUES ('schema_version', '1'); "#, )?; + self.add_column_if_missing( + "keychain_signatures", + "signer_public_key", + "TEXT NOT NULL DEFAULT ''", + )?; + Ok(()) + } + + fn add_column_if_missing( + &self, + table: &str, + column: &str, + definition: &str, + ) -> Result<(), StoreError> { + let mut stmt = self.conn.prepare(&format!("PRAGMA table_info({table})"))?; + let columns = stmt + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()?; + if !columns.iter().any(|name| name == column) { + self.conn.execute( + &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"), + [], + )?; + } Ok(()) } @@ -935,12 +960,13 @@ impl Store { ) -> Result<(), StoreError> { self.conn.execute( r#"INSERT OR REPLACE INTO keychain_signatures( - op_id, signer, namespace, signature, created_at_ms + op_id, signer, signer_public_key, namespace, signature, created_at_ms ) - VALUES (?1, ?2, ?3, ?4, ?5)"#, + VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, params![ signature.op_id, signature.signer, + signature.signer_public_key, signature.namespace, signature.signature, signature.created_at_ms @@ -951,16 +977,17 @@ impl Store { pub fn list_keychain_signatures(&self) -> Result, StoreError> { let mut stmt = self.conn.prepare( - r#"SELECT op_id, signer, namespace, signature, created_at_ms + r#"SELECT op_id, signer, signer_public_key, 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)?, + signer_public_key: row.get(2)?, + namespace: row.get(3)?, + signature: row.get(4)?, + created_at_ms: row.get(5)?, }) })?; rows.collect::, _>>() @@ -1342,6 +1369,7 @@ pub struct StoredKeychainOp { pub struct StoredKeychainSignature { pub op_id: String, pub signer: String, + pub signer_public_key: String, pub namespace: String, pub signature: Vec, pub created_at_ms: i64, @@ -1547,6 +1575,7 @@ mod tests { let signature = StoredKeychainSignature { op_id: "op:keychain:1".to_owned(), signer: "ssh:blake3:admin".to_owned(), + signer_public_key: "ssh-ed25519 AAAAADMIN eric@geth".to_owned(), namespace: "geth.keychain.v1@geth.local".to_owned(), signature: b"-----BEGIN SSH SIGNATURE-----".to_vec(), created_at_ms: 3, diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 995d8e1..a120cac 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -710,6 +710,8 @@ fn keychain_init_and_status_use_local_keychain_log() { assert!(status.initialized); assert_eq!(status.admin_keys, 1); assert_eq!(status.signatures, 0); + assert_eq!(status.verified_signatures, 0); + assert_eq!(status.failed_signatures, 0); assert_eq!(status.users, 0); } other => panic!("unexpected response: {other:?}"), @@ -752,6 +754,7 @@ fn keychain_init_can_record_openssh_signatures() { assert_eq!(signatures.len(), 2); assert!(signatures.iter().all(|signature| { signature.namespace == "geth.keychain.v1@geth.local" + && signature.signer_public_key.starts_with("ssh-ed25519 ") && !signature.signature.is_empty() })); } @@ -769,6 +772,8 @@ fn keychain_init_can_record_openssh_signatures() { match response { geth_control::ControlResponse::KeychainStatus(status) => { assert_eq!(status.signatures, 2); + assert_eq!(status.verified_signatures, 2); + assert_eq!(status.failed_signatures, 0); } other => panic!("unexpected response: {other:?}"), } diff --git a/docs/architecture.md b/docs/architecture.md index 18bf4e5..aaf06ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -239,8 +239,9 @@ 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. Verification and rejection of unsigned replicated keychain operations are -still future work. +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. 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 270933a..fa4aa9a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -175,10 +175,13 @@ resource-scoped capability decisions. `geth.keychain.v1@geth.local` namespace. - `[x]` Keychain OpenSSH signatures are stored in local SQLite. - `[x]` `geth keychain status` reports the stored keychain signature count. + - `[x]` `geth keychain status` verifies stored keychain signatures against + canonical payloads with OpenSSH when public key material is available. - `[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. + - `[x]` Tests cover local OpenSSH verification of stored keychain signatures. - `[ ]` Future completion verifies signatures before accepting replicated keychain ops.