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 status`. `keychain init --signing-key <path>` signs recorded
keychain ops with `ssh-keygen -Y sign` under the keychain ops with `ssh-keygen -Y sign` under the
`geth.keychain.v1@geth.local` namespace and stores signatures locally. `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 - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and
`cas cleanup` evicts unpinned blobs while retaining pinned blobs. The daemon `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 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 payloads through `ssh-keygen -Y sign` using the
`geth.keychain.v1@geth.local` namespace. This is the bootstrap path for `geth.keychain.v1@geth.local` namespace. This is the bootstrap path for
admin/YubiKey-rooted trust. `geth keychain status` reports the number of stored admin/YubiKey-rooted trust. `geth keychain status` reports the number of stored
keychain signatures; signature verification for replicated keychain ops is still keychain signatures plus how many currently verify with OpenSSH; rejecting
future work. unsigned or invalid replicated keychain ops is still future work.
The daemon can also install itself as a user service: 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!("initialized: {}", status.initialized);
println!("admin_keys: {}", status.admin_keys); println!("admin_keys: {}", status.admin_keys);
println!("signatures: {}", status.signatures); println!("signatures: {}", status.signatures);
println!("verified_signatures: {}", status.verified_signatures);
println!("failed_signatures: {}", status.failed_signatures);
println!("users: {}", status.users); println!("users: {}", status.users);
println!("devices: {}", status.devices); println!("devices: {}", status.devices);
println!("nodes: {}", status.nodes); println!("nodes: {}", status.nodes);

View file

@ -593,6 +593,8 @@ pub struct KeychainStatusResponse {
pub initialized: bool, pub initialized: bool,
pub admin_keys: usize, pub admin_keys: usize,
pub signatures: usize, pub signatures: usize,
pub verified_signatures: usize,
pub failed_signatures: usize,
pub users: usize, pub users: usize,
pub devices: usize, pub devices: usize,
pub nodes: usize, pub nodes: usize,

View file

@ -32,6 +32,7 @@ pub struct KeychainOp {
pub struct KeychainOpSignature { pub struct KeychainOpSignature {
pub op_id: AuthOpId, pub op_id: AuthOpId,
pub signer: KeyId, pub signer: KeyId,
pub signer_public_key: String,
pub namespace: String, pub namespace: String,
pub signature: Vec<u8>, pub signature: Vec<u8>,
pub created_at: UnixMillis, 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 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())?; keychain_signer_from_paths(&signing_key_path, admin_key_path.as_deref())?;
let mut signatures = Vec::new(); let mut signatures = Vec::new();
for op in &ops { for op in &ops {
@ -2900,6 +2900,7 @@ pub fn handle_request(
op, op,
&signing_key_path, &signing_key_path,
&signer, &signer,
&signer_public_key,
)?); )?);
} }
signatures signatures
@ -2911,10 +2912,13 @@ pub fn handle_request(
} }
ControlRequest::KeychainStatus => { ControlRequest::KeychainStatus => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?); let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
let signature_status = verify_keychain_signatures(&store, node)?;
Ok(ControlResponse::KeychainStatus(KeychainStatusResponse { Ok(ControlResponse::KeychainStatus(KeychainStatusResponse {
initialized: view.initialized, initialized: view.initialized,
admin_keys: view.admin_keys.len(), 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(), users: view.users.len(),
devices: view.devices.len(), devices: view.devices.len(),
nodes: view.nodes.len(), nodes: view.nodes.len(),
@ -4021,12 +4025,15 @@ fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
fn keychain_signer_from_paths( fn keychain_signer_from_paths(
signing_key_path: &Path, signing_key_path: &Path,
admin_key_path: Option<&Path>, admin_key_path: Option<&Path>,
) -> Result<KeyId, NodeError> { ) -> Result<(KeyId, String), NodeError> {
let public_key_path = admin_key_path let public_key_path = admin_key_path
.map(Path::to_path_buf) .map(Path::to_path_buf)
.unwrap_or_else(|| Path::new(&format!("{}.pub", signing_key_path.display())).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)?; 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( fn sign_keychain_op_with_ssh(
@ -4035,6 +4042,7 @@ fn sign_keychain_op_with_ssh(
op: &KeychainOp, op: &KeychainOp,
signing_key_path: &Path, signing_key_path: &Path,
signer: &KeyId, signer: &KeyId,
signer_public_key: &str,
) -> Result<KeychainOpSignature, NodeError> { ) -> Result<KeychainOpSignature, NodeError> {
geth_ssh_identity::ensure_ssh_keygen_available()?; geth_ssh_identity::ensure_ssh_keygen_available()?;
let signature_dir = node.paths.home().join("keychain-signatures"); let signature_dir = node.paths.home().join("keychain-signatures");
@ -4062,6 +4070,7 @@ fn sign_keychain_op_with_ssh(
let signature = KeychainOpSignature { let signature = KeychainOpSignature {
op_id: op.id.clone(), op_id: op.id.clone(),
signer: signer.clone(), signer: signer.clone(),
signer_public_key: signer_public_key.to_owned(),
namespace: geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(), namespace: geth_keychain::KEYCHAIN_SIGNATURE_NAMESPACE.to_owned(),
signature: signature_bytes, signature: signature_bytes,
created_at, created_at,
@ -4069,6 +4078,7 @@ fn sign_keychain_op_with_ssh(
store.insert_keychain_signature(&StoredKeychainSignature { store.insert_keychain_signature(&StoredKeychainSignature {
op_id: signature.op_id.to_string(), op_id: signature.op_id.to_string(),
signer: signature.signer.to_string(), signer: signature.signer.to_string(),
signer_public_key: signature.signer_public_key.clone(),
namespace: signature.namespace.clone(), namespace: signature.namespace.clone(),
signature: signature.signature.clone(), signature: signature.signature.clone(),
created_at_ms: signature.created_at.0, created_at_ms: signature.created_at.0,
@ -4076,6 +4086,96 @@ fn sign_keychain_op_with_ssh(
Ok(signature) 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> { fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
store store
.list_keychain_ops()? .list_keychain_ops()?

View file

@ -73,6 +73,7 @@ impl Store {
CREATE TABLE IF NOT EXISTS keychain_signatures ( CREATE TABLE IF NOT EXISTS keychain_signatures (
op_id TEXT NOT NULL, op_id TEXT NOT NULL,
signer TEXT NOT NULL, signer TEXT NOT NULL,
signer_public_key TEXT NOT NULL DEFAULT '',
namespace TEXT NOT NULL, namespace TEXT NOT NULL,
signature BLOB NOT NULL, signature BLOB NOT NULL,
created_at_ms INTEGER 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'); 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(()) Ok(())
} }
@ -935,12 +960,13 @@ impl Store {
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
self.conn.execute( self.conn.execute(
r#"INSERT OR REPLACE INTO keychain_signatures( 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![ params![
signature.op_id, signature.op_id,
signature.signer, signature.signer,
signature.signer_public_key,
signature.namespace, signature.namespace,
signature.signature, signature.signature,
signature.created_at_ms signature.created_at_ms
@ -951,16 +977,17 @@ impl Store {
pub fn list_keychain_signatures(&self) -> Result<Vec<StoredKeychainSignature>, StoreError> { pub fn list_keychain_signatures(&self) -> Result<Vec<StoredKeychainSignature>, StoreError> {
let mut stmt = self.conn.prepare( 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"#, FROM keychain_signatures ORDER BY created_at_ms, op_id, signer, namespace"#,
)?; )?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
Ok(StoredKeychainSignature { Ok(StoredKeychainSignature {
op_id: row.get(0)?, op_id: row.get(0)?,
signer: row.get(1)?, signer: row.get(1)?,
namespace: row.get(2)?, signer_public_key: row.get(2)?,
signature: row.get(3)?, namespace: row.get(3)?,
created_at_ms: row.get(4)?, signature: row.get(4)?,
created_at_ms: row.get(5)?,
}) })
})?; })?;
rows.collect::<Result<Vec<_>, _>>() rows.collect::<Result<Vec<_>, _>>()
@ -1342,6 +1369,7 @@ pub struct StoredKeychainOp {
pub struct StoredKeychainSignature { pub struct StoredKeychainSignature {
pub op_id: String, pub op_id: String,
pub signer: String, pub signer: String,
pub signer_public_key: String,
pub namespace: String, pub namespace: String,
pub signature: Vec<u8>, pub signature: Vec<u8>,
pub created_at_ms: i64, pub created_at_ms: i64,
@ -1547,6 +1575,7 @@ mod tests {
let signature = StoredKeychainSignature { let signature = StoredKeychainSignature {
op_id: "op:keychain:1".to_owned(), op_id: "op:keychain:1".to_owned(),
signer: "ssh:blake3:admin".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(), namespace: "geth.keychain.v1@geth.local".to_owned(),
signature: b"-----BEGIN SSH SIGNATURE-----".to_vec(), signature: b"-----BEGIN SSH SIGNATURE-----".to_vec(),
created_at_ms: 3, created_at_ms: 3,

View file

@ -710,6 +710,8 @@ fn keychain_init_and_status_use_local_keychain_log() {
assert!(status.initialized); assert!(status.initialized);
assert_eq!(status.admin_keys, 1); assert_eq!(status.admin_keys, 1);
assert_eq!(status.signatures, 0); assert_eq!(status.signatures, 0);
assert_eq!(status.verified_signatures, 0);
assert_eq!(status.failed_signatures, 0);
assert_eq!(status.users, 0); assert_eq!(status.users, 0);
} }
other => panic!("unexpected response: {other:?}"), other => panic!("unexpected response: {other:?}"),
@ -752,6 +754,7 @@ fn keychain_init_can_record_openssh_signatures() {
assert_eq!(signatures.len(), 2); assert_eq!(signatures.len(), 2);
assert!(signatures.iter().all(|signature| { assert!(signatures.iter().all(|signature| {
signature.namespace == "geth.keychain.v1@geth.local" signature.namespace == "geth.keychain.v1@geth.local"
&& signature.signer_public_key.starts_with("ssh-ed25519 ")
&& !signature.signature.is_empty() && !signature.signature.is_empty()
})); }));
} }
@ -769,6 +772,8 @@ fn keychain_init_can_record_openssh_signatures() {
match response { match response {
geth_control::ControlResponse::KeychainStatus(status) => { geth_control::ControlResponse::KeychainStatus(status) => {
assert_eq!(status.signatures, 2); assert_eq!(status.signatures, 2);
assert_eq!(status.verified_signatures, 2);
assert_eq!(status.failed_signatures, 0);
} }
other => panic!("unexpected response: {other:?}"), 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 canonical keychain signing payloads, runs `ssh-keygen -Y sign` with the explicit
`geth.keychain.v1@geth.local` namespace, and stores the resulting OpenSSH `geth.keychain.v1@geth.local` namespace, and stores the resulting OpenSSH
signatures in local SQLite. `keychain status` reports the stored signature signatures in local SQLite. `keychain status` reports the stored signature
count. Verification and rejection of unsigned replicated keychain operations are count and verifies stored signatures against their canonical payloads with
still future work. 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, The authorization plane is `geth-auth`: resource-local signed operation logs,
grants, revocations, groups, and `auth explain`. Auth operations reduce into a 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. `geth.keychain.v1@geth.local` namespace.
- `[x]` Keychain OpenSSH signatures are stored in local SQLite. - `[x]` Keychain OpenSSH signatures are stored in local SQLite.
- `[x]` `geth keychain status` reports the stored keychain signature count. - `[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 - `[x]` Missing `ssh-keygen` or unavailable hardware keys produce clear
errors during signing. errors during signing.
- `[x]` Tests cover signed keychain init with a generated local OpenSSH key - `[x]` Tests cover signed keychain init with a generated local OpenSSH key
when `ssh-keygen` is available. when `ssh-keygen` is available.
- `[x]` Tests cover local OpenSSH verification of stored keychain signatures.
- `[ ]` Future completion verifies signatures before accepting replicated - `[ ]` Future completion verifies signatures before accepting replicated
keychain ops. keychain ops.