From 9e5871d02a8bbe42d6878c6ba3d2b36f3093eb1e Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 18 Jul 2026 20:25:45 +0200 Subject: [PATCH] make keychain records append-only --- crates/geth-node/src/lib.rs | 48 ++++++++++++++++++++++++------- crates/geth-store/src/lib.rs | 56 +++++++++++++++++++++++++++++++++--- docs/architecture.md | 5 ++++ 3 files changed, 95 insertions(+), 14 deletions(-) diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 047a3ce..c43917f 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -9524,19 +9524,48 @@ fn import_verified_sigchain( } let existing_ops = load_keychain_ops(store)? .into_iter() - .map(|op| op.id) - .collect::>(); + .map(|op| (op.id.clone(), op)) + .collect::>(); let existing_signatures = load_keychain_signatures(store)? .into_iter() .map(|signature| { ( - signature.op_id, - signature.signer, - signature.namespace, - signature.signature, + ( + signature.op_id.clone(), + signature.signer.clone(), + signature.namespace.clone(), + ), + signature, ) }) - .collect::>(); + .collect::>(); + for op in ops { + if existing_ops + .get(&op.id) + .is_some_and(|existing| existing != op) + { + return Err(NodeError::Unauthorized(format!( + "refusing keychain operation {} because that ID already names different immutable content", + op.id + ))); + } + } + for signature in signatures { + let identity = ( + signature.op_id.clone(), + signature.signer.clone(), + signature.namespace.clone(), + ); + if existing_signatures + .get(&identity) + .is_some_and(|existing| existing != signature) + { + return Err(NodeError::Unauthorized(format!( + "refusing keychain signature for {} because its signer and namespace already name different immutable content", + signature.op_id + ))); + } + } let mut signatures_imported = 0; let mut ops_imported = 0; for op in ops { @@ -9547,7 +9576,7 @@ fn import_verified_sigchain( .collect::>(); store .insert_keychain_op_with_signatures(&stored_keychain_op_from_op(op)?, &op_signatures)?; - if !existing_ops.contains(&op.id) { + if !existing_ops.contains_key(&op.id) { ops_imported += 1; } for signature in signatures @@ -9558,9 +9587,8 @@ fn import_verified_sigchain( signature.op_id.clone(), signature.signer.clone(), signature.namespace.clone(), - signature.signature.clone(), ); - signatures_imported += usize::from(!existing_signatures.contains(&key)); + signatures_imported += usize::from(!existing_signatures.contains_key(&key)); } } Ok(geth_control::KeychainFetchImportReport { diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index e4b4113..2d7cc6f 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -1145,7 +1145,7 @@ impl Store { 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) + r#"INSERT OR IGNORE 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], )?; @@ -1196,7 +1196,7 @@ impl Store { signature: &StoredKeychainSignature, ) -> Result<(), StoreError> { self.conn.execute( - r#"INSERT OR REPLACE INTO keychain_signatures( + r#"INSERT OR IGNORE INTO keychain_signatures( op_id, signer, signer_public_key, namespace, signature, created_at_ms ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, @@ -1265,13 +1265,13 @@ impl Store { signatures: &[StoredKeychainSignature], ) -> Result<(), StoreError> { tx.execute( - r#"INSERT OR REPLACE INTO keychain_ops(op_id, op_json, created_at_ms) + r#"INSERT OR IGNORE 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], )?; for signature in signatures { tx.execute( - r#"INSERT OR REPLACE INTO keychain_signatures( + r#"INSERT OR IGNORE INTO keychain_signatures( op_id, signer, signer_public_key, namespace, signature, created_at_ms ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, params![ @@ -2331,6 +2331,54 @@ mod tests { ); } + #[test] + fn keychain_records_are_append_only_by_operation_and_signature_identity() { + let store = Store::open_memory().expect("open"); + let original = StoredKeychainOp { + op_id: "op:keychain:immutable".to_owned(), + op_json: r#"{"id":"op:keychain:immutable","name":"original"}"#.to_owned(), + created_at_ms: 1, + }; + let replacement = StoredKeychainOp { + op_id: original.op_id.clone(), + op_json: r#"{"id":"op:keychain:immutable","name":"replacement"}"#.to_owned(), + created_at_ms: 2, + }; + let original_signature = StoredKeychainSignature { + op_id: original.op_id.clone(), + signer: "key:admin".to_owned(), + signer_public_key: "ssh-ed25519 AAAA original".to_owned(), + namespace: "geth.keychain.v1@geth.local".to_owned(), + signature: b"original-signature".to_vec(), + created_at_ms: 1, + }; + let replacement_signature = StoredKeychainSignature { + signer_public_key: "ssh-ed25519 AAAA replacement".to_owned(), + signature: b"replacement-signature".to_vec(), + created_at_ms: 2, + ..original_signature.clone() + }; + + store + .insert_keychain_op_with_signatures( + &original, + std::slice::from_ref(&original_signature), + ) + .expect("insert original"); + store + .insert_keychain_op_with_signatures( + &replacement, + std::slice::from_ref(&replacement_signature), + ) + .expect("attempt replacement"); + + assert_eq!(store.list_keychain_ops().expect("list ops"), vec![original]); + assert_eq!( + store.list_keychain_signatures().expect("list signatures"), + vec![original_signature] + ); + } + #[test] fn keychain_ops_with_signatures_batch_commits_multiple_ops() { let store = Store::open_memory().expect("open"); diff --git a/docs/architecture.md b/docs/architecture.md index 7c9fd27..5e18cbe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -474,6 +474,11 @@ 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. +Keychain operation IDs and `(operation ID, signer, namespace)` signature +identities are immutable in local storage. An attempted re-import with an +existing identity but different bytes is rejected before it can replace local +trust state; idempotent repeats leave the original bytes unchanged. + The replacement static-publication design is specified in [`sshsigchain-v2.md`](sshsigchain-v2.md). Its reusable core has an explicit out-of-band `(chain ID, profile, SSHSIG namespace, root public key)` trust