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

@ -114,6 +114,8 @@ Roadmap items should be actionable and checkable:
groups, and bearer access. The daemon persists local auth grant/revoke ops groups, and bearer access. The daemon persists local auth grant/revoke ops
and uses them for `auth explain`. Signature validation and daemon-side module and uses them for `auth explain`. Signature validation and daemon-side module
enforcement are still roadmap work. enforcement are still roadmap work.
- The daemon persists local keychain init/admin-key ops and reduces them for
`keychain status`. SSH signature capture/verification is still roadmap work.
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future

2
Cargo.lock generated
View file

@ -1115,6 +1115,7 @@ name = "geth-control"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"geth-auth", "geth-auth",
"geth-keychain",
"geth-resource", "geth-resource",
"geth-ssh-identity", "geth-ssh-identity",
"geth-types", "geth-types",
@ -1205,6 +1206,7 @@ dependencies = [
"geth-control", "geth-control",
"geth-crypto", "geth-crypto",
"geth-iroh", "geth-iroh",
"geth-keychain",
"geth-resource", "geth-resource",
"geth-ssh-identity", "geth-ssh-identity",
"geth-store", "geth-store",

View file

@ -74,6 +74,7 @@ The bootstrap implementation provides:
- `geth node id` - `geth node id`
- `geth resource list` - `geth resource list`
- `geth resource create <kind> <name>` - `geth resource create <kind> <name>`
- `geth keychain init [--admin-key <path>]`
- `geth keychain status` - `geth keychain status`
- `geth auth explain <subject> <resource> <capability>` - `geth auth explain <subject> <resource> <capability>`
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]` - `geth auth grant <subject> <resource> <capability> [--grant-id <id>]`

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@ use geth_control::{
}; };
use geth_crypto::AgentKey; use geth_crypto::AgentKey;
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
@ -16,12 +17,12 @@ use geth_ssh_identity::{
certificate_id, revocation_id, ssh_public_key_fingerprint, certificate_id, revocation_id, ssh_public_key_fingerprint,
}; };
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredResource, StoredSshCertRequest, StoredSshCertificate, Store, StoredAuthOp, StoredKeychainOp, StoredResource, StoredSshCertRequest,
StoredSshRevocation, StoredSshCertificate, StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
AuthOpId, Capability, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, SshCertId, AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName,
SshCertRequestId, UnixMillis, SshCertId, SshCertRequestId, UnixMillis,
}; };
use std::path::Path; use std::path::Path;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@ -237,13 +238,40 @@ pub fn handle_request(
.collect(), .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 => { ControlRequest::KeychainStatus => {
let view = geth_keychain::reduce_keychain_ops(&load_keychain_ops(&store)?);
Ok(ControlResponse::KeychainStatus(KeychainStatusResponse { Ok(ControlResponse::KeychainStatus(KeychainStatusResponse {
initialized: false, initialized: view.initialized,
admin_keys: 0, admin_keys: view.admin_keys.len(),
users: 0, users: view.users.len(),
devices: 0, devices: view.devices.len(),
nodes: 1, nodes: view.nodes.len(),
})) }))
} }
ControlRequest::AuthExplain { ControlRequest::AuthExplain {
@ -518,6 +546,23 @@ fn load_auth_ops_for_resource(store: &Store, resource: &str) -> Result<Vec<AuthO
.collect() .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 { fn generated_grant_id(subject: &str, resource: &str, capability: &str) -> String {
format!( format!(
"grant:{}", "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 { fn stable_node_id(agent_id: &str) -> String {
format!("node:{agent_id}") format!("node:{agent_id}")
} }

View file

@ -318,6 +318,31 @@ impl Store {
.map_err(StoreError::from) .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( pub fn insert_ssh_cert_request(
&self, &self,
request: &StoredSshCertRequest, request: &StoredSshCertRequest,
@ -513,6 +538,13 @@ pub struct StoredAuthOp {
pub created_at_ms: i64, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSshCertRequest { pub struct StoredSshCertRequest {
pub request_id: String, pub request_id: String,
@ -653,4 +685,27 @@ mod tests {
vec![first] 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] #[test]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() { fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");

View file

@ -105,7 +105,10 @@ The identity plane is `geth-keychain`: admin keys, users, devices, nodes, agents
and endpoint bindings. Endpoint rotation must not destroy higher-level node and endpoint bindings. Endpoint rotation must not destroy higher-level node
identity. Keychain operations reduce into an active view containing current identity. Keychain operations reduce into an active view containing current
admin keys, users, devices, node records, agent bindings, and endpoint-to-node admin keys, users, devices, node records, agent bindings, and endpoint-to-node
bindings. Revoked identity subtrees are excluded from that active view. bindings. Revoked identity subtrees are excluded from that active view. The
daemon persists local keychain init/admin-key operations and `keychain status`
reports the reduced local view. OpenSSH signature capture and verification for
those 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

@ -138,11 +138,16 @@ resource-scoped capability decisions.
- JSON is not used as the signed representation. - JSON is not used as the signed representation.
- Tests verify equivalent operations hash/sign identically across runs. - Tests verify equivalent operations hash/sign identically across runs.
- `[ ]` SSH-admin-rooted keychain initialization. - `[~]` SSH-admin-rooted keychain initialization.
Acceptance criteria: Acceptance criteria:
- `geth keychain init` records a signed `KeychainInit`. - `[x]` `geth keychain init` records a local `KeychainInit`.
- OpenSSH signature namespaces are explicit. - `[x]` `geth keychain init --admin-key <path>` records an admin SSH public
- Missing `ssh-keygen` or unavailable hardware keys produce clear errors. key fingerprint.
- `[x]` `geth keychain status` reports the reduced local keychain view.
- `[ ]` Future completion records signed `KeychainInit` operations.
- `[ ]` OpenSSH signature namespaces are explicit in the signing flow.
- `[ ]` Missing `ssh-keygen` or unavailable hardware keys produce clear
errors during signing.
- `[x]` Keychain operation reducer. - `[x]` Keychain operation reducer.
Acceptance criteria: Acceptance criteria: