Wire keychain status to local keychain ops

This commit is contained in:
Eric Wendland 2026-05-16 16:34:20 +02:00
commit 99f6dee26f
12 changed files with 191 additions and 20 deletions

View file

@ -131,7 +131,10 @@ pub enum ResourceCommand {
#[derive(Debug, Subcommand)]
pub enum KeychainCommand {
Init,
Init {
#[arg(long)]
admin_key: Option<PathBuf>,
},
Status,
}
@ -341,10 +344,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
command: ResourceCommand::Create { kind, name },
} => ControlRequest::ResourceCreate { kind, name },
Command::Keychain {
command: KeychainCommand::Init,
} => ControlRequest::ModuleStub {
module: "keychain".to_owned(),
command: "init".to_owned(),
command: KeychainCommand::Init { admin_key },
} => ControlRequest::KeychainInit {
admin_key_path: admin_key,
},
Command::Keychain {
command: KeychainCommand::Status,
@ -595,6 +597,12 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("devices: {}", status.devices);
println!("nodes: {}", status.nodes);
}
ControlResponse::KeychainInitialized { ops } => {
println!("initialized keychain");
for op in ops {
println!("recorded keychain op: {}", op.id);
}
}
ControlResponse::AuthExplain(explain) => {
println!("allowed: {}", explain.allowed);
println!("subject: {}", explain.subject);

View file

@ -10,6 +10,7 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
geth-auth = { path = "../geth-auth" }
geth-keychain = { path = "../geth-keychain" }
geth-resource = { path = "../geth-resource" }
geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-types = { path = "../geth-types" }

View file

@ -1,4 +1,5 @@
use geth_auth::{AuthExplanation, AuthOp};
use geth_keychain::KeychainOp;
use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
@ -31,6 +32,9 @@ pub enum ControlRequest {
hash: BlobHash,
},
CasList,
KeychainInit {
admin_key_path: Option<PathBuf>,
},
KeychainStatus,
AuthExplain {
subject: String,
@ -114,6 +118,9 @@ pub enum ControlResponse {
blobs: Vec<CasBlob>,
},
KeychainStatus(KeychainStatusResponse),
KeychainInitialized {
ops: Vec<KeychainOp>,
},
AuthExplain(AuthExplanation),
AuthOpRecorded {
op: AuthOp,

View file

@ -16,6 +16,7 @@ geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-crypto = { path = "../geth-crypto" }
geth-iroh = { path = "../geth-iroh" }
geth-keychain = { path = "../geth-keychain" }
geth-resource = { path = "../geth-resource" }
geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" }

View file

@ -9,6 +9,7 @@ use geth_control::{
};
use geth_crypto::AgentKey;
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
@ -16,12 +17,12 @@ use geth_ssh_identity::{
certificate_id, revocation_id, ssh_public_key_fingerprint,
};
use geth_store::{
Store, StoredAuthOp, StoredResource, StoredSshCertRequest, StoredSshCertificate,
StoredSshRevocation,
Store, StoredAuthOp, StoredKeychainOp, StoredResource, StoredSshCertRequest,
StoredSshCertificate, StoredSshRevocation,
};
use geth_types::{
AuthOpId, Capability, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, SshCertId,
SshCertRequestId, UnixMillis,
AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName,
SshCertId, SshCertRequestId, UnixMillis,
};
use std::path::Path;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@ -237,13 +238,40 @@ pub fn handle_request(
.collect(),
})
}
ControlRequest::KeychainInit { admin_key_path } => {
let mut ops = Vec::new();
let created_at = UnixMillis(geth_store::now_ms());
let init = KeychainOp {
id: generated_keychain_op_id("keychain-init", "local", created_at),
created_at,
kind: KeychainOpKind::KeychainInit,
};
store_keychain_op(&store, &init)?;
ops.push(init);
if let Some(admin_key_path) = admin_key_path {
let public_key = std::fs::read_to_string(admin_key_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let admin_key = KeyId::new(ssh_public_key_fingerprint(&public_key));
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 },
};
store_keychain_op(&store, &op)?;
ops.push(op);
}
Ok(ControlResponse::KeychainInitialized { ops })
}
ControlRequest::KeychainStatus => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
Ok(ControlResponse::KeychainStatus(KeychainStatusResponse {
initialized: false,
admin_keys: 0,
users: 0,
devices: 0,
nodes: 1,
initialized: view.initialized,
admin_keys: view.admin_keys.len(),
users: view.users.len(),
devices: view.devices.len(),
nodes: view.nodes.len(),
}))
}
ControlRequest::AuthExplain {
@ -518,6 +546,23 @@ fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthO
.collect()
}
fn store_keychain_op(store: &Store, op: &KeychainOp) -> Result<(), NodeError> {
store.insert_keychain_op(&StoredKeychainOp {
op_id: op.id.to_string(),
op_json: serde_json::to_string(op)?,
created_at_ms: op.created_at.0,
})?;
Ok(())
}
fn load_keychain_ops(store: &Store) -> Result<Vec<KeychainOp>, NodeError> {
store
.list_keychain_ops()?
.into_iter()
.map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from))
.collect()
}
fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
format!(
"grant:{}",
@ -539,6 +584,13 @@ fn generated_auth_op_id(
))
}
fn generated_keychain_op_id(kind: &str, stable_id: &str, created_at: UnixMillis) -> AuthOpId {
AuthOpId::new(format!(
"keychain-op:{}",
geth_crypto::blake3_hex(format!("{}\0{kind}\0{stable_id}", created_at.0).as_bytes())
))
}
fn stable_node_id(agent_id: &str) -> String {
format!("node:{agent_id}")
}

View file

@ -318,6 +318,31 @@ impl Store {
.map_err(StoreError::from)
}
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)
VALUES (?1, ?2, ?3)"#,
params![op.op_id, op.op_json, op.created_at_ms],
)?;
Ok(())
}
pub fn list_keychain_ops(&self) -> Result<Vec<StoredKeychainOp>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT op_id, op_json, created_at_ms
FROM keychain_ops ORDER BY created_at_ms, op_id"#,
)?;
let rows = stmt.query_map([], |row| {
Ok(StoredKeychainOp {
op_id: row.get(0)?,
op_json: row.get(1)?,
created_at_ms: row.get(2)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_ssh_cert_request(
&self,
request: &StoredSshCertRequest,
@ -513,6 +538,13 @@ pub struct StoredAuthOp {
pub created_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredKeychainOp {
pub op_id: String,
pub op_json: String,
pub created_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest {
pub request_id: String,
@ -653,4 +685,27 @@ mod tests {
vec![first]
);
}
#[test]
fn keychain_ops_roundtrip() {
let store = Store::open_memory().expect("open");
let first = StoredKeychainOp {
op_id: "op:keychain:1".to_owned(),
op_json: r#"{"id":"op:keychain:1"}"#.to_owned(),
created_at_ms: 1,
};
let second = StoredKeychainOp {
op_id: "op:keychain:2".to_owned(),
op_json: r#"{"id":"op:keychain:2"}"#.to_owned(),
created_at_ms: 2,
};
store.insert_keychain_op(&second).expect("insert second");
store.insert_keychain_op(&first).expect("insert first");
assert_eq!(
store.list_keychain_ops().expect("list keychain ops"),
vec![first, second]
);
}
}

View file

@ -223,6 +223,40 @@ fn auth_grant_revoke_and_explain_use_local_auth_log() {
}
}
#[test]
fn keychain_init_and_status_use_local_keychain_log() {
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.pub");
std::fs::write(&admin_key_path, "ssh-ed25519 AAAAADMIN eric@geth\n").expect("write admin key");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::KeychainInit {
admin_key_path: Some(admin_key_path),
},
)
.expect("init keychain");
match response {
geth_control::ControlResponse::KeychainInitialized { ops } => {
assert_eq!(ops.len(), 2);
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(&node, geth_control::ControlRequest::KeychainStatus)
.expect("keychain status");
match response {
geth_control::ControlResponse::KeychainStatus(status) => {
assert!(status.initialized);
assert_eq!(status.admin_keys, 1);
assert_eq!(status.users, 0);
}
other => panic!("unexpected response: {other:?}"),
}
}
#[test]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
let home = tempfile::tempdir().expect("tempdir");