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

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