diff --git a/Cargo.lock b/Cargo.lock index a5d5156..1bbb99e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1653,6 +1653,7 @@ dependencies = [ name = "geth-keychain" version = "0.1.0" dependencies = [ + "blake3", "geth-codec", "geth-types", "serde", diff --git a/README.md b/README.md index 8fb5825..a4d98dc 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,10 @@ The bootstrap implementation provides: - `geth resource create ` - `geth keychain init [--admin-key ] [--signing-key ]` - `geth keychain status` +- `geth keychain admin-add --admin-key --signing-key [--principal ]` +- `geth keychain admin-revoke --signing-key ` +- `geth keychain allowed-signers` +- `geth keychain verify` - `geth keychain sync ` - `geth auth sync ` - `geth sync status` @@ -436,6 +440,26 @@ geth node revoke-grant resource:ssh-proxy:local \ geth node revoke work-laptop --signing-key ~/.ssh/id_ed25519_sk ``` +Admin SSH keys are managed through the same signed keychain log: + +```sh +geth keychain admin-add \ + --admin-key ~/.ssh/new_admin.pub \ + --signing-key ~/.ssh/id_ed25519_sk \ + --principal admin +geth keychain allowed-signers > /tmp/geth.allowed_signers +geth keychain verify +``` + +The keychain follows a sigchain model documented in +`docs/sigchain-keychain.md`: each keychain operation is accepted only if it is +signed by an admin key from the previously accepted reduced view. This is the +geth analogue of verifying `git-skm` allowed-signers changes from a prior +trusted state. The reusable mechanics live in the `geth-keychain` crate, +including allowed-signers projection, replay verification, and an appendable +JSONL sigchain file format suitable for static hosting with HTTP caching/range +requests. + The enrollment flow for a new node is: ```sh diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 219d81d..edf4f9d 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -635,6 +635,27 @@ pub enum KeychainCommand { signing_key: Option, }, Status, + AdminAdd { + #[arg(long)] + admin_key: PathBuf, + #[arg(long)] + signing_key: PathBuf, + #[arg(long)] + principal: Option, + #[arg(long)] + valid_after_ms: Option, + #[arg(long)] + valid_before_ms: Option, + }, + AdminRevoke { + key: String, + #[arg(long)] + signing_key: PathBuf, + #[arg(long)] + admin_key: Option, + }, + AllowedSigners, + Verify, Sync { node: String, }, @@ -1432,6 +1453,40 @@ fn request_for_command(command: Command) -> Result { Command::Keychain { command: KeychainCommand::Status, } => ControlRequest::KeychainStatus, + Command::Keychain { + command: + KeychainCommand::AdminAdd { + admin_key, + signing_key, + principal, + valid_after_ms, + valid_before_ms, + }, + } => ControlRequest::KeychainAdminAdd { + admin_key_path: admin_key, + signing_key_path: signing_key, + principal, + valid_after_ms, + valid_before_ms, + }, + Command::Keychain { + command: + KeychainCommand::AdminRevoke { + key, + signing_key, + admin_key, + }, + } => ControlRequest::KeychainAdminRevoke { + key, + signing_key_path: signing_key, + admin_key_path: admin_key, + }, + Command::Keychain { + command: KeychainCommand::AllowedSigners, + } => ControlRequest::KeychainAllowedSigners, + Command::Keychain { + command: KeychainCommand::Verify, + } => ControlRequest::KeychainVerify, Command::Keychain { command: KeychainCommand::Sync { node }, } => ControlRequest::KeychainSync { node }, @@ -2458,6 +2513,47 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { ); } } + ControlResponse::KeychainAdminUpdated { + op, + signatures, + note, + } => { + 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::KeychainAllowedSigners { + allowed_signers, + note, + .. + } => { + print!("{allowed_signers}"); + if allowed_signers.is_empty() { + println!("no active admin public keys available"); + } + eprintln!("note: {note}"); + } + ControlResponse::KeychainVerified { report } => { + println!("ops: {}", report.ops); + println!("signatures: {}", report.signatures); + println!("accepted_ops: {}", report.accepted_ops); + println!("rejected_ops: {}", report.rejected_ops); + println!("active_admin_keys: {}", report.active_admin_keys); + println!( + "accepted_head: {}", + report + .accepted_head + .as_ref() + .map(|head| head.as_str()) + .unwrap_or("none") + ); + println!("note: {}", report.note); + } ControlResponse::KeychainSynced { peer_node_id, peer_agent_id, diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index d87766c..856fb09 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -3,7 +3,10 @@ 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, NodeEnrollmentRequest, NodeRecord}; +use geth_keychain::{ + KeychainAllowedSigner, KeychainOp, KeychainOpSignature, KeychainSigchainReport, + NodeEnrollmentRequest, NodeRecord, +}; use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_overlay::{ OverlayInterfacePlan, OverlayJoinPlan, OverlayNetworkStatus, OverlayPacket, OverlayPlan, @@ -226,6 +229,20 @@ pub enum ControlRequest { signing_key_path: Option, }, KeychainStatus, + KeychainAdminAdd { + admin_key_path: PathBuf, + signing_key_path: PathBuf, + principal: Option, + valid_after_ms: Option, + valid_before_ms: Option, + }, + KeychainAdminRevoke { + key: String, + signing_key_path: PathBuf, + admin_key_path: Option, + }, + KeychainAllowedSigners, + KeychainVerify, KeychainSync { node: String, }, @@ -662,6 +679,19 @@ pub enum ControlResponse { ops: Vec, signatures: Vec, }, + KeychainAdminUpdated { + op: KeychainOp, + signatures: Vec, + note: String, + }, + KeychainAllowedSigners { + entries: Vec, + allowed_signers: String, + note: String, + }, + KeychainVerified { + report: KeychainSigchainReport, + }, KeychainSynced { peer_node_id: String, peer_agent_id: String, diff --git a/crates/geth-keychain/Cargo.toml b/crates/geth-keychain/Cargo.toml index 1aa7c50..1ca14d5 100644 --- a/crates/geth-keychain/Cargo.toml +++ b/crates/geth-keychain/Cargo.toml @@ -6,10 +6,9 @@ rust-version.workspace = true license.workspace = true [dependencies] +blake3.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true geth-codec = { path = "../geth-codec" } geth-types = { path = "../geth-types" } - -[dev-dependencies] -serde_json.workspace = true diff --git a/crates/geth-keychain/src/lib.rs b/crates/geth-keychain/src/lib.rs index 1b2064b..a511cb3 100644 --- a/crates/geth-keychain/src/lib.rs +++ b/crates/geth-keychain/src/lib.rs @@ -41,6 +41,34 @@ pub struct KeychainOpSignature { pub created_at: UnixMillis, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeychainAllowedSigner { + pub key: KeyId, + pub principal: String, + pub public_key: String, + pub valid_after_ms: Option, + pub valid_before_ms: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeychainSigchainReport { + pub ops: usize, + pub signatures: usize, + pub accepted_ops: usize, + pub rejected_ops: usize, + pub active_admin_keys: usize, + pub accepted_head: Option, + pub note: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeychainSigchainEntry { + pub op: KeychainOp, + pub signatures: Vec, +} + +pub type KeychainSignatureVerifier<'a> = dyn Fn(&KeychainOp, &KeychainOpSignature) -> bool + 'a; + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct NodeEnrollmentRequest { pub id: AuthOpId, @@ -127,6 +155,11 @@ pub struct NodeEnrollmentRequestSigningPayload { pub enum KeychainError { #[error("invalid node enrollment status: {0}")] InvalidEnrollmentStatus(String), + #[error("sigchain JSONL line {line}: {source}")] + SigchainJsonl { + line: usize, + source: serde_json::Error, + }, } #[must_use] @@ -152,6 +185,10 @@ pub enum KeychainOpKind { KeychainInit, AdminKeyAdd { key: KeyId, + public_key: Option, + principal: Option, + valid_after_ms: Option, + valid_before_ms: Option, }, AdminKeyRevoke { key: KeyId, @@ -254,7 +291,7 @@ pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView { for op in ops { match &op.kind { KeychainOpKind::KeychainInit => initialized = true, - KeychainOpKind::AdminKeyAdd { key } => { + KeychainOpKind::AdminKeyAdd { key, .. } => { admin_keys.insert(key.clone()); } KeychainOpKind::AdminKeyRevoke { key } => { @@ -364,6 +401,204 @@ pub fn reduce_keychain_ops(ops: &[KeychainOp]) -> KeychainView { } } +pub fn sorted_keychain_ops(mut ops: Vec) -> Vec { + ops.sort_by(|left, right| { + ( + left.created_at.0, + keychain_op_order(&left.kind), + left.id.to_string(), + ) + .cmp(&( + right.created_at.0, + keychain_op_order(&right.kind), + right.id.to_string(), + )) + }); + ops +} + +#[must_use] +pub fn keychain_op_order(kind: &KeychainOpKind) -> u8 { + match kind { + KeychainOpKind::KeychainInit => 0, + KeychainOpKind::AdminKeyAdd { .. } => 1, + KeychainOpKind::AdminKeyRevoke { .. } => 2, + _ => 10, + } +} + +#[must_use] +pub fn allowed_signers( + ops: &[KeychainOp], + signatures: &[KeychainOpSignature], +) -> Vec { + let mut entries = BTreeMap::::new(); + for op in sorted_keychain_ops(ops.to_vec()) { + match op.kind { + KeychainOpKind::AdminKeyAdd { + key, + public_key, + principal, + valid_after_ms, + valid_before_ms, + } => { + let public_key = public_key + .or_else(|| { + signatures + .iter() + .find(|signature| signature.signer == key) + .map(|signature| signature.signer_public_key.trim().to_owned()) + }) + .unwrap_or_default(); + if !public_key.is_empty() { + entries.insert( + key.clone(), + KeychainAllowedSigner { + key, + principal: principal.unwrap_or_else(|| "admin".to_owned()), + public_key, + valid_after_ms, + valid_before_ms, + }, + ); + } + } + KeychainOpKind::AdminKeyRevoke { key } => { + entries.remove(&key); + } + _ => {} + } + } + entries.into_values().collect() +} + +#[must_use] +pub fn render_allowed_signers(entries: &[KeychainAllowedSigner]) -> String { + let mut text = String::new(); + for entry in entries { + text.push_str(&format!( + "{} {}\n", + entry.principal, + entry.public_key.trim() + )); + } + text +} + +pub fn verify_sigchain( + ops: &[KeychainOp], + signatures: &[KeychainOpSignature], + verifier: &KeychainSignatureVerifier<'_>, +) -> KeychainSigchainReport { + let ops = sorted_keychain_ops(ops.to_vec()); + let mut accepted = Vec::::new(); + let mut trusted_admins = BTreeSet::::new(); + let mut rejected_ops = 0; + for op in &ops { + let op_signatures = signatures + .iter() + .filter(|signature| signature.op_id == op.id) + .collect::>(); + let bootstrap_init = accepted.is_empty() && matches!(op.kind, KeychainOpKind::KeychainInit); + let bootstrap_admin = accepted.len() == 1 + && matches!(accepted[0].kind, KeychainOpKind::KeychainInit) + && matches!(&op.kind, KeychainOpKind::AdminKeyAdd { .. }); + let valid = bootstrap_init + || bootstrap_admin + || op_signatures.iter().any(|signature| { + let signer_is_authorized = trusted_admins.contains(&signature.signer); + signer_is_authorized + && signature_uses_claimed_key(signature) + && verifier(op, signature) + }); + if valid { + accepted.push(op.clone()); + trusted_admins = reduce_keychain_ops(&accepted) + .admin_keys + .into_iter() + .collect(); + } else { + rejected_ops += 1; + } + } + let view = reduce_keychain_ops(&accepted); + KeychainSigchainReport { + ops: ops.len(), + signatures: signatures.len(), + accepted_ops: accepted.len(), + rejected_ops, + active_admin_keys: view.admin_keys.len(), + accepted_head: accepted.last().map(|op| op.id.clone()), + note: "verified by replaying keychain operations against the previously accepted admin-key view, similar to git-skm's parent allowed_signers verification".to_owned(), + } +} + +#[must_use] +pub fn signature_uses_claimed_key(signature: &KeychainOpSignature) -> bool { + KeyId::new(admin_key_fingerprint(&signature.signer_public_key)) == signature.signer +} + +#[must_use] +pub fn admin_key_fingerprint(public_key: &str) -> String { + format!("ssh:blake3:{}", blake3::hash(public_key.trim().as_bytes())) +} + +#[must_use] +pub fn sigchain_entries( + ops: &[KeychainOp], + signatures: &[KeychainOpSignature], +) -> Vec { + sorted_keychain_ops(ops.to_vec()) + .into_iter() + .map(|op| KeychainSigchainEntry { + signatures: signatures + .iter() + .filter(|signature| signature.op_id == op.id) + .cloned() + .collect(), + op, + }) + .collect() +} + +pub fn encode_sigchain_jsonl( + entries: &[KeychainSigchainEntry], +) -> Result { + let mut text = String::new(); + for entry in entries { + text.push_str(&serde_json::to_string(entry)?); + text.push('\n'); + } + Ok(text) +} + +pub fn decode_sigchain_jsonl(text: &str) -> Result, KeychainError> { + text.lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(index, line)| { + serde_json::from_str::(line).map_err(|source| { + KeychainError::SigchainJsonl { + line: index + 1, + source, + } + }) + }) + .collect() +} + +#[must_use] +pub fn flatten_sigchain_entries( + entries: &[KeychainSigchainEntry], +) -> (Vec, Vec) { + let ops = entries.iter().map(|entry| entry.op.clone()).collect(); + let signatures = entries + .iter() + .flat_map(|entry| entry.signatures.clone()) + .collect(); + (ops, signatures) +} + #[cfg(test)] mod tests { use super::*; @@ -390,6 +625,10 @@ mod tests { created_at: UnixMillis(1), kind: KeychainOpKind::AdminKeyAdd { key: "key:admin".into(), + public_key: Some("ssh-ed25519 AAAA test@example".to_owned()), + principal: Some("admin".to_owned()), + valid_after_ms: None, + valid_before_ms: None, }, }; assert_eq!( @@ -422,12 +661,20 @@ mod tests { 2, KeychainOpKind::AdminKeyAdd { key: "key:admin-a".into(), + public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()), + principal: Some("admin-a".to_owned()), + valid_after_ms: None, + valid_before_ms: None, }, ), op( 3, KeychainOpKind::AdminKeyAdd { key: "key:admin-b".into(), + public_key: Some("ssh-ed25519 AAAA admin-b".to_owned()), + principal: Some("admin-b".to_owned()), + valid_after_ms: None, + valid_before_ms: None, }, ), op( @@ -562,6 +809,110 @@ mod tests { assert!(!view.endpoints.contains_key("endpoint:old")); } + #[test] + fn allowed_signers_and_sigchain_jsonl_are_portable() { + let ops = vec![ + op(1, KeychainOpKind::KeychainInit), + op( + 2, + KeychainOpKind::AdminKeyAdd { + key: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(), + public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()), + principal: Some("admin-a".to_owned()), + valid_after_ms: None, + valid_before_ms: None, + }, + ), + ]; + let signatures = vec![KeychainOpSignature { + op_id: ops[1].id.clone(), + signer: admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(), + signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(), + namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), + signature: vec![1], + created_at: UnixMillis(2), + }]; + let allowed = allowed_signers(&ops, &signatures); + assert_eq!(allowed.len(), 1); + assert!(render_allowed_signers(&allowed).contains("admin-a ssh-ed25519")); + + let entries = sigchain_entries(&ops, &signatures); + let jsonl = encode_sigchain_jsonl(&entries).expect("encode jsonl"); + assert_eq!(jsonl.lines().count(), 2); + let decoded = decode_sigchain_jsonl(&jsonl).expect("decode jsonl"); + assert_eq!(decoded, entries); + let (decoded_ops, decoded_signatures) = flatten_sigchain_entries(&decoded); + assert_eq!(decoded_ops, sorted_keychain_ops(ops)); + assert_eq!(decoded_signatures, signatures); + } + + #[test] + fn sigchain_verification_replays_against_prior_admin_view() { + let admin_a: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-a").into(); + let admin_b: KeyId = admin_key_fingerprint("ssh-ed25519 AAAA admin-b").into(); + let ops = vec![ + op(1, KeychainOpKind::KeychainInit), + op( + 2, + KeychainOpKind::AdminKeyAdd { + key: admin_a.clone(), + public_key: Some("ssh-ed25519 AAAA admin-a".to_owned()), + principal: Some("admin-a".to_owned()), + valid_after_ms: None, + valid_before_ms: None, + }, + ), + op( + 3, + KeychainOpKind::AdminKeyAdd { + key: admin_b.clone(), + public_key: Some("ssh-ed25519 AAAA admin-b".to_owned()), + principal: Some("admin-b".to_owned()), + valid_after_ms: None, + valid_before_ms: None, + }, + ), + op( + 4, + KeychainOpKind::AdminKeyRevoke { + key: admin_a.clone(), + }, + ), + ]; + let signatures = vec![ + KeychainOpSignature { + op_id: ops[1].id.clone(), + signer: admin_a.clone(), + signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(), + namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), + signature: vec![1], + created_at: UnixMillis(2), + }, + KeychainOpSignature { + op_id: ops[2].id.clone(), + signer: admin_a.clone(), + signer_public_key: "ssh-ed25519 AAAA admin-a".to_owned(), + namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), + signature: vec![1], + created_at: UnixMillis(3), + }, + KeychainOpSignature { + op_id: ops[3].id.clone(), + signer: admin_b, + signer_public_key: "ssh-ed25519 AAAA admin-b".to_owned(), + namespace: KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), + signature: vec![1], + created_at: UnixMillis(4), + }, + ]; + let report = verify_sigchain(&ops, &signatures, &|_, signature| { + signature.signature == vec![1] + }); + assert_eq!(report.accepted_ops, 4); + assert_eq!(report.rejected_ops, 0); + assert_eq!(report.active_admin_keys, 1); + } + #[test] fn reducer_excludes_revoked_identity_subtrees() { let ops = vec![ diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index fb0a3cc..035b293 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -403,7 +403,13 @@ fn initialize_owner_keychain( 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 }, + kind: KeychainOpKind::AdminKeyAdd { + key: admin_key, + public_key: Some(public_key.trim().to_owned()), + principal: Some("admin".to_owned()), + valid_after_ms: None, + valid_before_ms: None, + }, }); } @@ -3389,7 +3395,7 @@ async fn keychain_sync_from_peer_since( .iter() .filter(|signature| { if !trusted_admins.contains(&signature.signer) - || !keychain_signature_uses_claimed_key(signature) + || !geth_keychain::signature_uses_claimed_key(signature) { return false; } @@ -7578,7 +7584,13 @@ pub fn handle_request( 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 }, + kind: KeychainOpKind::AdminKeyAdd { + key: admin_key, + public_key: Some(public_key.trim().to_owned()), + principal: Some("admin".to_owned()), + valid_after_ms: None, + valid_before_ms: None, + }, }; store_keychain_op(&store, &op)?; ops.push(op); @@ -7608,6 +7620,81 @@ pub fn handle_request( nodes: view.nodes.len(), })) } + ControlRequest::KeychainAdminAdd { + admin_key_path, + signing_key_path, + principal, + valid_after_ms, + valid_before_ms, + } => { + let public_key = std::fs::read_to_string(&admin_key_path)?; + let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key)); + let created_at = UnixMillis(geth_store::now_ms()); + 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, + public_key: Some(public_key.trim().to_owned()), + principal: principal.or_else(|| Some("admin".to_owned())), + valid_after_ms, + valid_before_ms, + }, + }; + let signatures = store_and_sign_keychain_ops( + &store, + node, + std::slice::from_ref(&op), + Some(&signing_key_path), + None, + )?; + Ok(ControlResponse::KeychainAdminUpdated { + op, + signatures, + note: "recorded signed admin key addition in the keychain sigchain".to_owned(), + }) + } + ControlRequest::KeychainAdminRevoke { + key, + signing_key_path, + admin_key_path, + } => { + let created_at = UnixMillis(geth_store::now_ms()); + let op = KeychainOp { + id: generated_keychain_op_id("admin-key-revoke", &key, created_at), + created_at, + kind: KeychainOpKind::AdminKeyRevoke { + key: KeyId::new(key), + }, + }; + let signatures = store_and_sign_keychain_ops( + &store, + node, + std::slice::from_ref(&op), + Some(&signing_key_path), + admin_key_path.as_deref(), + )?; + Ok(ControlResponse::KeychainAdminUpdated { + op, + signatures, + note: "recorded signed admin key revocation in the keychain sigchain".to_owned(), + }) + } + ControlRequest::KeychainAllowedSigners => { + let entries = geth_keychain::allowed_signers( + &load_keychain_ops(&store)?, + &load_keychain_signatures(&store)?, + ); + let allowed_signers = geth_keychain::render_allowed_signers(&entries); + Ok(ControlResponse::KeychainAllowedSigners { + entries, + allowed_signers, + note: "derived from active AdminKeyAdd/AdminKeyRevoke operations; compatible with ssh-keygen -Y allowed_signers format".to_owned(), + }) + } + ControlRequest::KeychainVerify => Ok(ControlResponse::KeychainVerified { + report: verify_keychain_sigchain_with_ssh(&store, node)?, + }), ControlRequest::NodeList => { let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?); Ok(ControlResponse::NodeList { @@ -10414,6 +10501,26 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> { Ok(()) } +fn verify_keychain_sigchain_with_ssh( + store: &Store, + node: &LocalNode, +) -> Result { + let ops = load_keychain_ops(store)?; + let signatures = load_keychain_signatures(store)?; + Ok(geth_keychain::verify_sigchain( + &ops, + &signatures, + &|op, signature| { + verify_keychain_signature_with_ssh( + node, + op, + &stored_keychain_signature_from_signature(signature), + ) + .unwrap_or(false) + }, + )) +} + fn store_and_sign_keychain_ops( store: &Store, node: &LocalNode, @@ -10519,10 +10626,6 @@ fn stored_keychain_signature_from_signature( } } -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, @@ -10614,11 +10717,12 @@ fn verify_keychain_signature_with_ssh( } fn load_keychain_ops(store: &Store) -> Result, NodeError> { - store + let ops = store .list_keychain_ops()? .into_iter() - .map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from)) - .collect() + .map(|stored| serde_json::from_str::(&stored.op_json).map_err(NodeError::from)) + .collect::, NodeError>>()?; + Ok(geth_keychain::sorted_keychain_ops(ops)) } fn load_keychain_signatures(store: &Store) -> Result, NodeError> { diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 5fece6d..682b4aa 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -2575,6 +2575,85 @@ fn keychain_init_can_record_openssh_signatures() { } } +#[test] +fn keychain_admin_sigchain_exports_allowed_signers_and_verifies() { + 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"); + generate_ssh_key(&admin_key_path); + + 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.clone()), + }, + ) + .expect("init signed keychain"); + + let second_admin_key_path = home.path().join("second_admin_ed25519"); + generate_ssh_key(&second_admin_key_path); + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::KeychainAdminAdd { + admin_key_path: second_admin_key_path.with_extension("pub"), + signing_key_path: admin_key_path.clone(), + principal: Some("second-admin".to_owned()), + valid_after_ms: None, + valid_before_ms: None, + }, + ) + .expect("admin add"); + match response { + geth_control::ControlResponse::KeychainAdminUpdated { op, signatures, .. } => { + assert_eq!(signatures.len(), 1); + match op.kind { + geth_keychain::KeychainOpKind::AdminKeyAdd { + public_key, + principal, + .. + } => { + assert!(public_key.expect("public key").starts_with("ssh-ed25519 ")); + assert_eq!(principal.as_deref(), Some("second-admin")); + } + other => panic!("unexpected op kind: {other:?}"), + } + } + other => panic!("unexpected response: {other:?}"), + } + + let response = + geth_node::handle_request(&node, geth_control::ControlRequest::KeychainAllowedSigners) + .expect("allowed signers"); + match response { + geth_control::ControlResponse::KeychainAllowedSigners { + entries, + allowed_signers, + .. + } => { + assert_eq!(entries.len(), 2); + assert!(allowed_signers.contains("second-admin ssh-ed25519 ")); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainVerify) + .expect("verify sigchain"); + match response { + geth_control::ControlResponse::KeychainVerified { report } => { + assert_eq!(report.rejected_ops, 0); + assert_eq!(report.active_admin_keys, 2); + assert!(report.note.contains("git-skm")); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn init_owned_node_records_signed_owner_device_and_node() { if Command::new("ssh-keygen").arg("-?").output().is_err() { diff --git a/docs/architecture.md b/docs/architecture.md index 8334267..b869bae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -347,11 +347,18 @@ 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`. Endpoint rotation is explicit: `geth node endpoint-add` and `geth node endpoint-revoke` record signed -`NodeEndpointAdd` and `NodeEndpointRevoke` keychain operations. `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. +`NodeEndpointAdd` and `NodeEndpointRevoke` keychain operations. Admin SSH keys +are updated with signed `geth keychain admin-add` and `geth keychain +admin-revoke` operations; `AdminKeyAdd` carries the public key material needed +to reconstruct an OpenSSH `allowed_signers` view. `geth keychain verify` replays +the log against the previously accepted admin-key view, mirroring the `git-skm` +pattern of verifying key-registry changes from a prior trusted state. `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. See +`docs/sigchain-keychain.md` for the detailed sigchain design. This is currently +a pull-based signed operation log, not a CRDT or Keyhive-style convergent +authority. New devices can use the node enrollment flow instead of hand-editing keychain state. `geth node enroll request` creates a canonical, agent-key-signed request diff --git a/docs/roadmap.md b/docs/roadmap.md index c99a5c2..63217d1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -318,6 +318,22 @@ resource-scoped capability decisions. - `[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]` `AdminKeyAdd` carries OpenSSH public key material, principal, and + optional validity metadata so the admin key registry is reconstructable + from the signed log itself. + - `[x]` `geth keychain admin-add` and `geth keychain admin-revoke` append + signed admin-key registry operations. + - `[x]` `geth keychain allowed-signers` exports the active admin key view in + OpenSSH `allowed_signers` format. + - `[x]` `geth keychain verify` replays the keychain sigchain against the + previously accepted admin-key view. + - `[x]` Reusable sigchain mechanics live in `geth-keychain`, not in daemon + orchestration code. + - `[x]` `geth-keychain` exposes transport-neutral allowed-signers projection, + replay verification with an injected verifier, and appendable JSONL + sigchain encode/decode helpers for static hosting or alternate transports. + - `[x]` `docs/sigchain-keychain.md` documents the sigchain data model, + verification algorithm, commands, and current security limits. - `[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 diff --git a/docs/sigchain-keychain.md b/docs/sigchain-keychain.md new file mode 100644 index 0000000..933f2f8 --- /dev/null +++ b/docs/sigchain-keychain.md @@ -0,0 +1,211 @@ +# Geth Keychain Sigchain Design + +## Purpose + +The geth keychain is a signed operation log for mesh identity state. It is the +source of truth for admin SSH keys, users, devices, nodes, agents, and endpoint +bindings. Peers do not trust a mutable keychain snapshot. They replay signed +operations and reduce the accepted log into the current keychain view. + +This is intentionally similar to the `git-skm` pattern: + +- `git-skm` treats a committed `allowed_signers` file as trusted only if every + commit that changed it can be verified from a prior trusted state. +- geth treats a keychain operation as trusted only if it is signed by an admin + key that was trusted in the previously accepted keychain view. + +## Data Model + +The reusable model lives in the `geth-keychain` crate. The daemon stores it in +SQLite and syncs it over Iroh, but the crate does not depend on SQLite, Iroh, or +the geth daemon. Other projects can publish the same keychain as a static +sigchain file, append it to object storage, embed it in a document, or transport +it by any other mechanism. + +The durable log is `KeychainOp[]` plus `KeychainOpSignature[]`. Each operation +has a deterministic canonical signing payload under the +`geth.keychain.v1@geth.local` namespace. + +Important operation kinds: + +- `KeychainInit` +- `AdminKeyAdd` +- `AdminKeyRevoke` +- `UserAdd`, `UserRename`, `UserRevoke` +- `DeviceAdd`, `DeviceRevoke` +- `DeviceKeyAdd`, `DeviceKeyRevoke` +- `NodeAdd`, `NodeRename`, `NodeRevoke` +- `NodeEndpointAdd`, `NodeEndpointRevoke` +- `AgentBind` + +`AdminKeyAdd` records the admin key fingerprint and, for new operations, the +OpenSSH public key material, optional principal, and optional validity metadata. +The public key is part of the signed operation so the key registry can be +reconstructed from the sigchain itself. Older local data may only have the +fingerprint; geth can use stored signature public-key material as a fallback +when exporting the current allowed signers view. + +## Signature Rules + +Each signed operation has a `KeychainOpSignature`: + +- `op_id` +- signer key fingerprint +- signer OpenSSH public key +- namespace +- OpenSSH signature bytes +- creation time + +Signatures are produced with: + +```sh +ssh-keygen -Y sign -n geth.keychain.v1@geth.local -f +``` + +Verification uses: + +```sh +ssh-keygen -Y verify \ + -f \ + -I \ + -n geth.keychain.v1@geth.local \ + -s +``` + +The signed payload is canonical binary encoding, not JSON. + +## Verification Algorithm + +For a candidate log ordered by `(created_at_ms, op_id)`: + +1. Start from a local trust anchor. In the bootstrap implementation this is a + locally initialized `KeychainInit` plus an admin key added through + `geth init --admin-key ... --signing-key ...` or `geth keychain init + --admin-key ...`. +2. Maintain an accepted operation prefix and reduce it into the current + keychain view. +3. For each next operation, collect its signatures. +4. Accept the operation only if at least one signature: + - is from a key fingerprint present in the previous accepted view, + - carries public key material that hashes to that fingerprint, + - verifies over the canonical operation payload with OpenSSH, and + - uses the keychain namespace. +5. After accepting the operation, append it to the accepted prefix and reduce + again. This lets a valid `AdminKeyAdd` authorize later operations, and a + valid `AdminKeyRevoke` stop later authorization by that key. +6. Reject unsigned, invalidly signed, conflicting, or out-of-authority + operations. + +This mirrors `git-skm`'s "verify from the previously trusted +allowed_signers" model, but the transport and storage are geth/Iroh/SQLite +instead of Git commits. + +The `geth-keychain` crate exposes this as a transport-neutral verifier. Callers +provide: + +- ordered or unordered `KeychainOp[]` +- `KeychainOpSignature[]` +- a signature verification callback + +The callback is responsible for the cryptographic backend, such as +`ssh-keygen -Y verify`, WebCrypto, an HSM, or a test verifier. The crate owns +the replay order, bootstrap rule, previous-view authorization rule, +`allowed_signers` projection, and reduced keychain view. + +## Static Sigchain File + +For static hosting and range-friendly distribution, `geth-keychain` defines a +JSONL sigchain representation: + +```json +{"op":{...},"signatures":[...]} +{"op":{...},"signatures":[...]} +``` + +Each line is a complete `KeychainSigchainEntry`. This keeps the file appendable +and cacheable: + +- A publisher can append new entries to the end of the file. +- HTTP clients can use `ETag`, `Last-Modified`, and byte range requests to fetch + only new bytes. +- A client can decode complete trailing lines, ignore a partial final line until + the next fetch, and replay verification from its last accepted checkpoint. +- The canonical signed payload remains the `KeychainOp`; JSONL is only the + publication container. + +The crate provides helpers to encode/decode JSONL and flatten entries back into +`KeychainOp[]` plus `KeychainOpSignature[]`. + +Future work should add explicit checkpoint records containing the accepted head +ID, byte offset, reduced view hash, and log hash. That would let static clients +resume verification without replaying the entire file while still detecting +rollback or truncation. + +## Commands + +Owner bootstrap: + +```sh +geth init \ + --admin-key ~/.ssh/id_ed25519_sk.pub \ + --signing-key ~/.ssh/id_ed25519_sk \ + --node-name laptop +``` + +Add a new admin key: + +```sh +geth keychain admin-add \ + --admin-key ~/.ssh/new_admin.pub \ + --signing-key ~/.ssh/current_admin_sk \ + --principal admin +``` + +Revoke an admin key: + +```sh +geth keychain admin-revoke \ + --signing-key ~/.ssh/current_admin_sk +``` + +Export the reduced active admin key registry in OpenSSH `allowed_signers` +format: + +```sh +geth keychain allowed-signers > allowed_signers +``` + +Replay and verify the local sigchain: + +```sh +geth keychain verify +``` + +## Differences From Git-SKM + +`git-skm` uses Git commit history as the append-only log and a trusted commit +hash as the checkpoint. geth uses `KeychainOp[]` as the append-only log and the +locally initialized owner/admin key as the bootstrap trust anchor. + +The current geth prototype does not yet provide: + +- signed checkpoint objects equivalent to `skm.last-verified-commit` +- transparency-log style append proofs +- anti-rollback protection beyond local state, HTTP cache validators, and sync + conflict checks +- delegated admin scopes or threshold admin signatures +- strong compromise-recovery semantics + +Those are future hardening items. The current prototype is intended to make the +key registry self-describing, replayable, and testable. + +## Security Invariants + +- The admin SSH private key is never copied into geth state. +- Admin key changes are keychain operations, not mutable ACL edits. +- Public key material needed to reconstruct active admin signers is stored in + the signed operation log. +- Import from peers accepts only operations signed by currently trusted admin + keys. +- A discovered peer card or Iroh EndpointID never grants keychain authority. +- Bearer secrets do not grant keychain mutation rights.