From a05112e6a238e093fb7530e63b4f697d7890f8ff Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 16 May 2026 22:18:49 +0200 Subject: [PATCH] Add resource secret epoch metadata --- AGENTS.md | 3 + Cargo.lock | 2 + README.md | 6 +- crates/geth-cli/src/lib.rs | 23 +++++++- crates/geth-control/Cargo.toml | 1 + crates/geth-control/src/lib.rs | 14 +++++ crates/geth-node/Cargo.toml | 1 + crates/geth-node/src/lib.rs | 69 +++++++++++++++++++++- crates/geth-store/src/lib.rs | 103 +++++++++++++++++++++++++++++++++ crates/geth/tests/bootstrap.rs | 58 +++++++++++++++++++ docs/architecture.md | 4 +- docs/roadmap.md | 14 +++-- 12 files changed, 286 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 168f81c..2abf5ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,9 @@ Roadmap items should be actionable and checkable: replication and prefix-capability enforcement are still roadmap work. - Document resources can be registered locally with empty JSON state and local-only status. Automerge editing/state and sync are still roadmap work. +- Resource secret epoch metadata can be created, rotated, and listed locally. + Payload encryption, key envelopes, and bearer invite enforcement are still + roadmap work. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future diff --git a/Cargo.lock b/Cargo.lock index 0b00b02..b74b42f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,6 +1121,7 @@ dependencies = [ "geth-keychain", "geth-kv", "geth-resource", + "geth-secrets", "geth-ssh-identity", "geth-types", "serde", @@ -1221,6 +1222,7 @@ dependencies = [ "geth-keychain", "geth-kv", "geth-resource", + "geth-secrets", "geth-ssh-identity", "geth-store", "geth-types", diff --git a/README.md b/README.md index 5366667..9034e02 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ The bootstrap implementation provides: - `geth resource create ` - `geth keychain init [--admin-key ]` - `geth keychain status` +- `geth secret status` +- `geth secret create ` +- `geth secret rotate ` - `geth auth explain ` - `geth auth grant [--grant-id ]` - `geth auth revoke ` @@ -95,8 +98,7 @@ The bootstrap implementation provides: - `geth ssh revocation list` - `geth ssh revocation export --out ` -Other command groups exist as explicit stubs: `pipe`, `pubsub`, `secret`, and -`ssh`. +Other command groups exist as explicit stubs: `pipe`, `pubsub`, and `ssh`. ## Resource Modules diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index fb029e7..f807b97 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -161,6 +161,8 @@ pub enum AuthCommand { #[derive(Debug, Subcommand)] pub enum SecretCommand { Status, + Create { resource: String }, + Rotate { resource: String }, } #[derive(Debug, Subcommand)] @@ -390,9 +392,10 @@ fn request_for_command(command: Command) -> Result { Command::Auth { command: AuthCommand::Revoke { resource, grant_id }, } => ControlRequest::AuthRevoke { resource, grant_id }, - Command::Secret { command } => ControlRequest::ModuleStub { - module: "secret".to_owned(), - command: format!("{command:?}"), + Command::Secret { command } => match command { + SecretCommand::Status => ControlRequest::SecretStatus, + SecretCommand::Create { resource } => ControlRequest::SecretCreate { resource }, + SecretCommand::Rotate { resource } => ControlRequest::SecretRotate { resource }, }, Command::Cas { command } => match command { CasCommand::Add { path } => ControlRequest::CasAdd { path }, @@ -636,6 +639,20 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("recorded keychain op: {}", op.id); } } + ControlResponse::SecretStatus { secrets } => { + if secrets.is_empty() { + println!("no resource secrets"); + } else { + for secret in secrets { + println!("{}\t{}\tepoch {}", secret.id, secret.resource, secret.epoch); + } + } + } + ControlResponse::SecretCreated { secret } => { + println!("resource secret: {}", secret.id); + println!("resource: {}", secret.resource); + println!("epoch: {}", secret.epoch); + } ControlResponse::AuthExplain(explain) => { println!("allowed: {}", explain.allowed); println!("subject: {}", explain.subject); diff --git a/crates/geth-control/Cargo.toml b/crates/geth-control/Cargo.toml index 196aa31..93458ec 100644 --- a/crates/geth-control/Cargo.toml +++ b/crates/geth-control/Cargo.toml @@ -15,5 +15,6 @@ geth-document = { path = "../geth-document" } geth-keychain = { path = "../geth-keychain" } geth-kv = { path = "../geth-kv" } geth-resource = { path = "../geth-resource" } +geth-secrets = { path = "../geth-secrets" } geth-ssh-identity = { path = "../geth-ssh-identity" } geth-types = { path = "../geth-types" } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 2814e1e..4b3c061 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -4,6 +4,7 @@ use geth_document::DocumentResource; use geth_keychain::KeychainOp; use geth_kv::{KvEntry, KvResource}; use geth_resource::ResourceDescriptor; +use geth_secrets::ResourceMasterSecret; use geth_ssh_identity::{ SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, }; @@ -48,6 +49,13 @@ pub enum ControlRequest { admin_key_path: Option, }, KeychainStatus, + SecretStatus, + SecretCreate { + resource: String, + }, + SecretRotate { + resource: String, + }, AuthExplain { subject: String, resource: String, @@ -167,6 +175,12 @@ pub enum ControlResponse { KeychainInitialized { ops: Vec, }, + SecretStatus { + secrets: Vec, + }, + SecretCreated { + secret: ResourceMasterSecret, + }, AuthExplain(AuthExplanation), AuthOpRecorded { op: AuthOp, diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 41aa200..c9ccca0 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -21,6 +21,7 @@ geth-iroh = { path = "../geth-iroh" } geth-keychain = { path = "../geth-keychain" } geth-kv = { path = "../geth-kv" } geth-resource = { path = "../geth-resource" } +geth-secrets = { path = "../geth-secrets" } geth-ssh-identity = { path = "../geth-ssh-identity" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index f4a3ae0..de6b756 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -14,6 +14,7 @@ use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode} use geth_keychain::{KeychainOp, KeychainOpKind}; use geth_kv::{KvEntry, KvResource}; use geth_resource::ResourceDescriptor; +use geth_secrets::ResourceMasterSecret; use geth_ssh_identity::{ SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id, @@ -21,7 +22,8 @@ use geth_ssh_identity::{ }; use geth_store::{ Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredKeychainOp, StoredKvEntry, - StoredKvStore, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, + StoredKvStore, StoredResource, StoredResourceSecret, StoredSshCertRequest, + StoredSshCertificate, StoredSshRevocation, }; use geth_types::{ AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, @@ -67,6 +69,8 @@ pub enum NodeError { InvalidDocumentName(String), #[error("document not found: {0}")] DocumentNotFound(String), + #[error("resource not found: {0}")] + ResourceNotFound(String), #[error("invalid ssh certificate kind: {0}")] InvalidSshCertKind(String), #[error("invalid ssh certificate request status: {0}")] @@ -335,6 +339,27 @@ pub fn handle_request( nodes: view.nodes.len(), })) } + ControlRequest::SecretStatus => Ok(ControlResponse::SecretStatus { + secrets: store + .list_resource_secrets()? + .into_iter() + .map(resource_secret_from_stored) + .collect(), + }), + ControlRequest::SecretCreate { resource } => { + ensure_resource_exists(&store, &resource)?; + let secret = create_resource_secret(&store, &resource, 1)?; + Ok(ControlResponse::SecretCreated { secret }) + } + ControlRequest::SecretRotate { resource } => { + ensure_resource_exists(&store, &resource)?; + let next_epoch = store + .latest_resource_secret(&resource)? + .map(|secret| secret.epoch + 1) + .unwrap_or(1); + let secret = create_resource_secret(&store, &resource, next_epoch)?; + Ok(ControlResponse::SecretCreated { secret }) + } ControlRequest::AuthExplain { subject, resource, @@ -764,6 +789,48 @@ fn document_resource_from_stored(stored: &StoredDocumentResource) -> DocumentRes } } +fn ensure_resource_exists(store: &Store, resource_id: &str) -> Result<(), NodeError> { + if store + .list_resources()? + .into_iter() + .any(|resource| resource.resource_id == resource_id) + { + Ok(()) + } else { + Err(NodeError::ResourceNotFound(resource_id.to_owned())) + } +} + +fn create_resource_secret( + store: &Store, + resource_id: &str, + epoch: u64, +) -> Result { + let created_at = UnixMillis(geth_store::now_ms()); + let secret_id = format!( + "secret:{}", + geth_crypto::blake3_hex(format!("{resource_id}\0{epoch}\0{}", created_at.0).as_bytes()) + ); + let stored = StoredResourceSecret { + secret_id, + resource_id: resource_id.to_owned(), + epoch, + status: "active".to_owned(), + created_at_ms: created_at.0, + }; + store.insert_resource_secret(&stored)?; + Ok(resource_secret_from_stored(stored)) +} + +fn resource_secret_from_stored(stored: StoredResourceSecret) -> ResourceMasterSecret { + ResourceMasterSecret { + id: stored.secret_id.into(), + resource: stored.resource_id.into(), + epoch: stored.epoch, + created_at: UnixMillis(stored.created_at_ms), + } +} + fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> { store.insert_auth_op(&StoredAuthOp { op_id: op.id.to_string(), diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 6375844..90938d0 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -382,6 +382,65 @@ impl Store { } } + pub fn insert_resource_secret(&self, secret: &StoredResourceSecret) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO resource_secrets( + secret_id, resource_id, epoch, status, created_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5)"#, + params![ + secret.secret_id, + secret.resource_id, + secret.epoch as i64, + secret.status, + secret.created_at_ms + ], + )?; + Ok(()) + } + + pub fn list_resource_secrets(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT secret_id, resource_id, epoch, status, created_at_ms + FROM resource_secrets ORDER BY resource_id, epoch, secret_id"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(StoredResourceSecret { + secret_id: row.get(0)?, + resource_id: row.get(1)?, + epoch: row.get::<_, i64>(2)? as u64, + status: row.get(3)?, + created_at_ms: row.get(4)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + + pub fn latest_resource_secret( + &self, + resource_id: &str, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT secret_id, resource_id, epoch, status, created_at_ms + FROM resource_secrets + WHERE resource_id = ?1 + ORDER BY epoch DESC, created_at_ms DESC, secret_id DESC + LIMIT 1"#, + )?; + let mut rows = stmt.query(params![resource_id])?; + if let Some(row) = rows.next()? { + Ok(Some(StoredResourceSecret { + secret_id: row.get(0)?, + resource_id: row.get(1)?, + epoch: row.get::<_, i64>(2)? as u64, + status: row.get(3)?, + created_at_ms: row.get(4)?, + })) + } else { + Ok(None) + } + } + pub fn record_cas_object( &self, hash: &str, @@ -753,6 +812,15 @@ pub struct StoredDocumentResource { pub updated_at_ms: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredResourceSecret { + pub secret_id: String, + pub resource_id: String, + pub epoch: u64, + pub status: String, + pub created_at_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CasObject { pub hash: String, @@ -1072,4 +1140,39 @@ mod tests { Some(document) ); } + + #[test] + fn resource_secret_epochs_roundtrip() { + let store = Store::open_memory().expect("open"); + let first = StoredResourceSecret { + secret_id: "secret:1".to_owned(), + resource_id: "resource:kv:prefs".to_owned(), + epoch: 1, + status: "active".to_owned(), + created_at_ms: 1, + }; + let second = StoredResourceSecret { + secret_id: "secret:2".to_owned(), + resource_id: "resource:kv:prefs".to_owned(), + epoch: 2, + status: "active".to_owned(), + created_at_ms: 2, + }; + + store.insert_resource_secret(&first).expect("insert first"); + store + .insert_resource_secret(&second) + .expect("insert second"); + + assert_eq!( + store + .latest_resource_secret("resource:kv:prefs") + .expect("latest"), + Some(second.clone()) + ); + assert_eq!( + store.list_resource_secrets().expect("list"), + vec![first, second] + ); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index cfa0632..7e96f8e 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -609,6 +609,64 @@ fn document_create_and_status_use_local_store() { ); } +#[test] +fn secret_create_rotate_and_status_track_resource_epochs() { + 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 first = geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretCreate { + resource: "resource:cas:local".to_owned(), + }, + ) + .expect("create secret"); + match first { + geth_control::ControlResponse::SecretCreated { secret } => { + assert_eq!(secret.resource.to_string(), "resource:cas:local"); + assert_eq!(secret.epoch, 1); + } + other => panic!("unexpected response: {other:?}"), + } + + let second = geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretRotate { + resource: "resource:cas:local".to_owned(), + }, + ) + .expect("rotate secret"); + match second { + geth_control::ControlResponse::SecretCreated { secret } => { + assert_eq!(secret.resource.to_string(), "resource:cas:local"); + assert_eq!(secret.epoch, 2); + } + other => panic!("unexpected response: {other:?}"), + } + + let status = geth_node::handle_request(&node, geth_control::ControlRequest::SecretStatus) + .expect("secret status"); + match status { + geth_control::ControlResponse::SecretStatus { secrets } => { + assert_eq!(secrets.len(), 2); + assert_eq!(secrets[0].epoch, 1); + assert_eq!(secrets[1].epoch, 2); + } + other => panic!("unexpected response: {other:?}"), + } + + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretCreate { + resource: "resource:missing".to_owned(), + }, + ) + .is_err() + ); +} + #[test] fn ssh_cert_request_approval_and_revocation_export_use_local_state() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index 52a2550..5cdd04c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -139,7 +139,9 @@ for CLI/control output, but it is not the signed representation. The payload access plane is `geth-secrets`: resource master secrets, epochs, key envelopes, bearer secrets, and rotation. Revocation for private data is -modeled initially as secret epoch rotation. +modeled initially as secret epoch rotation. The daemon persists resource secret +epoch metadata through `secret create/rotate/status`, but it does not yet store +payload key material, encrypt resource data, or distribute key envelopes. ## Multi-User Direction diff --git a/docs/roadmap.md b/docs/roadmap.md index 153cca8..5aa351a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -174,12 +174,16 @@ resource-scoped capability decisions. - `[ ]` Future completion requires signed-op validation before accepting replicated auth ops. -- `[ ]` Resource secrets and bearer invites. +- `[~]` Resource secrets and bearer invites. Acceptance criteria: - - Bearer secrets grant only resource-scoped capabilities. - - Bearer principals cannot mutate trust graph state by default. - - Secret epoch rotation is represented in durable metadata. - - Tests verify bearer access does not imply node identity. + - `[x]` `geth secret create ` records resource secret epoch 1 + metadata. + - `[x]` `geth secret rotate ` records the next resource secret + epoch. + - `[x]` Secret epoch rotation is represented in durable metadata. + - `[ ]` Bearer secrets grant only resource-scoped capabilities. + - `[ ]` Bearer principals cannot mutate trust graph state by default. + - `[ ]` Tests verify bearer access does not imply node identity. - `[~]` SSH certificate and revocation lifecycle. Acceptance criteria: