make keychain records append-only

This commit is contained in:
Eric Wendland 2026-07-18 20:25:45 +02:00
commit 9e5871d02a
3 changed files with 95 additions and 14 deletions

View file

@ -9524,19 +9524,48 @@ fn import_verified_sigchain(
}
let existing_ops = load_keychain_ops(store)?
.into_iter()
.map(|op| op.id)
.collect::<BTreeSet<_>>();
.map(|op| (op.id.clone(), op))
.collect::<BTreeMap<_, _>>();
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::<BTreeSet<_>>();
.collect::<BTreeMap<_, _>>();
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::<Vec<_>>();
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 {

View file

@ -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");

View file

@ -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