Extract reusable keychain sigchain model

This commit is contained in:
Eric Wendland 2026-05-26 18:53:20 +02:00
commit 4013c868aa
11 changed files with 938 additions and 20 deletions

1
Cargo.lock generated
View file

@ -1653,6 +1653,7 @@ dependencies = [
name = "geth-keychain"
version = "0.1.0"
dependencies = [
"blake3",
"geth-codec",
"geth-types",
"serde",

View file

@ -139,6 +139,10 @@ The bootstrap implementation provides:
- `geth resource create <kind> <name>`
- `geth keychain init [--admin-key <path>] [--signing-key <path>]`
- `geth keychain status`
- `geth keychain admin-add --admin-key <pub> --signing-key <private> [--principal <name>]`
- `geth keychain admin-revoke <key-fingerprint> --signing-key <private>`
- `geth keychain allowed-signers`
- `geth keychain verify`
- `geth keychain sync <node-id-or-name>`
- `geth auth sync <node-id-or-name>`
- `geth sync status`
@ -436,6 +440,26 @@ geth node revoke-grant resource:ssh-proxy:local <grant-id> \
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

View file

@ -635,6 +635,27 @@ pub enum KeychainCommand {
signing_key: Option<PathBuf>,
},
Status,
AdminAdd {
#[arg(long)]
admin_key: PathBuf,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
principal: Option<String>,
#[arg(long)]
valid_after_ms: Option<i64>,
#[arg(long)]
valid_before_ms: Option<i64>,
},
AdminRevoke {
key: String,
#[arg(long)]
signing_key: PathBuf,
#[arg(long)]
admin_key: Option<PathBuf>,
},
AllowedSigners,
Verify,
Sync {
node: String,
},
@ -1432,6 +1453,40 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
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,

View file

@ -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<PathBuf>,
},
KeychainStatus,
KeychainAdminAdd {
admin_key_path: PathBuf,
signing_key_path: PathBuf,
principal: Option<String>,
valid_after_ms: Option<i64>,
valid_before_ms: Option<i64>,
},
KeychainAdminRevoke {
key: String,
signing_key_path: PathBuf,
admin_key_path: Option<PathBuf>,
},
KeychainAllowedSigners,
KeychainVerify,
KeychainSync {
node: String,
},
@ -662,6 +679,19 @@ pub enum ControlResponse {
ops: Vec<KeychainOp>,
signatures: Vec<KeychainOpSignature>,
},
KeychainAdminUpdated {
op: KeychainOp,
signatures: Vec<KeychainOpSignature>,
note: String,
},
KeychainAllowedSigners {
entries: Vec<KeychainAllowedSigner>,
allowed_signers: String,
note: String,
},
KeychainVerified {
report: KeychainSigchainReport,
},
KeychainSynced {
peer_node_id: String,
peer_agent_id: String,

View file

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

View file

@ -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<i64>,
pub valid_before_ms: Option<i64>,
}
#[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<AuthOpId>,
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainSigchainEntry {
pub op: KeychainOp,
pub signatures: Vec<KeychainOpSignature>,
}
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<String>,
principal: Option<String>,
valid_after_ms: Option<i64>,
valid_before_ms: Option<i64>,
},
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<KeychainOp>) -> Vec<KeychainOp> {
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<KeychainAllowedSigner> {
let mut entries = BTreeMap::<KeyId, KeychainAllowedSigner>::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::<KeychainOp>::new();
let mut trusted_admins = BTreeSet::<KeyId>::new();
let mut rejected_ops = 0;
for op in &ops {
let op_signatures = signatures
.iter()
.filter(|signature| signature.op_id == op.id)
.collect::<Vec<_>>();
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<KeychainSigchainEntry> {
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<String, serde_json::Error> {
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<Vec<KeychainSigchainEntry>, KeychainError> {
text.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(index, line)| {
serde_json::from_str::<KeychainSigchainEntry>(line).map_err(|source| {
KeychainError::SigchainJsonl {
line: index + 1,
source,
}
})
})
.collect()
}
#[must_use]
pub fn flatten_sigchain_entries(
entries: &[KeychainSigchainEntry],
) -> (Vec<KeychainOp>, Vec<KeychainOpSignature>) {
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![

View file

@ -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<geth_keychain::KeychainSigchainReport, NodeError> {
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<Vec<KeychainOp>, 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::<KeychainOp>(&stored.op_json).map_err(NodeError::from))
.collect::<Result<Vec<_>, NodeError>>()?;
Ok(geth_keychain::sorted_keychain_ops(ops))
}
fn load_keychain_signatures(store: &Store) -> Result<Vec<KeychainOpSignature>, NodeError> {

View file

@ -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() {

View file

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

View file

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

211
docs/sigchain-keychain.md Normal file
View file

@ -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 <signing-key> <payload>
```
Verification uses:
```sh
ssh-keygen -Y verify \
-f <allowed-signers> \
-I <signer-principal> \
-n geth.keychain.v1@geth.local \
-s <signature>
```
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 <key-fingerprint> \
--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.