Verify stored keychain SSH signatures

This commit is contained in:
Eric Wendland 2026-05-19 18:58:07 +02:00
commit f7f14f6b27
10 changed files with 160 additions and 15 deletions

View file

@ -126,7 +126,9 @@ Roadmap items should be actionable and checkable:
`keychain status`. `keychain init --signing-key <path>` 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

View file

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

View file

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

View file

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

View file

@ -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<u8>,
pub created_at: UnixMillis,

View file

@ -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<KeyId, NodeError> {
) -> 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<KeychainOpSignature, NodeError> {
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<KeychainSignatureStatus, NodeError> {
let ops = load_keychain_ops(store)?;
let ops_by_id = ops
.into_iter()
.map(|op| (op.id.to_string(), op))
.collect::<BTreeMap<_, _>>();
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<bool, NodeError> {
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<Vec<KeychainOp>, NodeError> {
store
.list_keychain_ops()?

View file

@ -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::<Result<Vec<_>, _>>()?;
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<Vec<StoredKeychainSignature>, 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::<Result<Vec<_>, _>>()
@ -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<u8>,
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,

View file

@ -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:?}"),
}

View file

@ -239,8 +239,9 @@ reports the reduced local view. `keychain init --signing-key <path>` 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

View file

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