Add resource secret epoch metadata

This commit is contained in:
Eric Wendland 2026-05-16 22:18:49 +02:00
commit a05112e6a2
12 changed files with 287 additions and 13 deletions

View file

@ -125,6 +125,9 @@ Roadmap items should be actionable and checkable:
replication and prefix-capability enforcement are still roadmap work. replication and prefix-capability enforcement are still roadmap work.
- Document resources can be registered locally with empty JSON state and - Document resources can be registered locally with empty JSON state and
local-only status. Automerge editing/state and sync are still roadmap work. 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, - 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

@ -1121,6 +1121,7 @@ dependencies = [
"geth-keychain", "geth-keychain",
"geth-kv", "geth-kv",
"geth-resource", "geth-resource",
"geth-secrets",
"geth-ssh-identity", "geth-ssh-identity",
"geth-types", "geth-types",
"serde", "serde",
@ -1221,6 +1222,7 @@ dependencies = [
"geth-keychain", "geth-keychain",
"geth-kv", "geth-kv",
"geth-resource", "geth-resource",
"geth-secrets",
"geth-ssh-identity", "geth-ssh-identity",
"geth-store", "geth-store",
"geth-types", "geth-types",

View file

@ -76,6 +76,9 @@ The bootstrap implementation provides:
- `geth resource create <kind> <name>` - `geth resource create <kind> <name>`
- `geth keychain init [--admin-key <path>]` - `geth keychain init [--admin-key <path>]`
- `geth keychain status` - `geth keychain status`
- `geth secret status`
- `geth secret create <resource>`
- `geth secret rotate <resource>`
- `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>]`
- `geth auth revoke <resource> <grant-id>` - `geth auth revoke <resource> <grant-id>`
@ -95,8 +98,7 @@ The bootstrap implementation provides:
- `geth ssh revocation list` - `geth ssh revocation list`
- `geth ssh revocation export --out <path>` - `geth ssh revocation export --out <path>`
Other command groups exist as explicit stubs: `pipe`, `pubsub`, `secret`, and Other command groups exist as explicit stubs: `pipe`, `pubsub`, and `ssh`.
`ssh`.
## Resource Modules ## Resource Modules

View file

@ -161,6 +161,8 @@ pub enum AuthCommand {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum SecretCommand { pub enum SecretCommand {
Status, Status,
Create { resource: String },
Rotate { resource: String },
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
@ -390,9 +392,10 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Auth { Command::Auth {
command: AuthCommand::Revoke { resource, grant_id }, command: AuthCommand::Revoke { resource, grant_id },
} => ControlRequest::AuthRevoke { resource, grant_id }, } => ControlRequest::AuthRevoke { resource, grant_id },
Command::Secret { command } => ControlRequest::ModuleStub { Command::Secret { command } => match command {
module: "secret".to_owned(), SecretCommand::Status => ControlRequest::SecretStatus,
command: format!("{command:?}"), SecretCommand::Create { resource } => ControlRequest::SecretCreate { resource },
SecretCommand::Rotate { resource } => ControlRequest::SecretRotate { resource },
}, },
Command::Cas { command } => match command { Command::Cas { command } => match command {
CasCommand::Add { path } => ControlRequest::CasAdd { path }, CasCommand::Add { path } => ControlRequest::CasAdd { path },
@ -636,6 +639,20 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("recorded keychain op: {}", op.id); 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) => { ControlResponse::AuthExplain(explain) => {
println!("allowed: {}", explain.allowed); println!("allowed: {}", explain.allowed);
println!("subject: {}", explain.subject); println!("subject: {}", explain.subject);

View file

@ -15,5 +15,6 @@ geth-document = { path = "../geth-document" }
geth-keychain = { path = "../geth-keychain" } geth-keychain = { path = "../geth-keychain" }
geth-kv = { path = "../geth-kv" } geth-kv = { path = "../geth-kv" }
geth-resource = { path = "../geth-resource" } geth-resource = { path = "../geth-resource" }
geth-secrets = { path = "../geth-secrets" }
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

@ -4,6 +4,7 @@ use geth_document::DocumentResource;
use geth_keychain::KeychainOp; use geth_keychain::KeychainOp;
use geth_kv::{KvEntry, KvResource}; use geth_kv::{KvEntry, KvResource};
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_secrets::ResourceMasterSecret;
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
}; };
@ -48,6 +49,13 @@ pub enum ControlRequest {
admin_key_path: Option<PathBuf>, admin_key_path: Option<PathBuf>,
}, },
KeychainStatus, KeychainStatus,
SecretStatus,
SecretCreate {
resource: String,
},
SecretRotate {
resource: String,
},
AuthExplain { AuthExplain {
subject: String, subject: String,
resource: String, resource: String,
@ -167,6 +175,12 @@ pub enum ControlResponse {
KeychainInitialized { KeychainInitialized {
ops: Vec<KeychainOp>, ops: Vec<KeychainOp>,
}, },
SecretStatus {
secrets: Vec<ResourceMasterSecret>,
},
SecretCreated {
secret: ResourceMasterSecret,
},
AuthExplain(AuthExplanation), AuthExplain(AuthExplanation),
AuthOpRecorded { AuthOpRecorded {
op: AuthOp, op: AuthOp,

View file

@ -21,6 +21,7 @@ geth-iroh = { path = "../geth-iroh" }
geth-keychain = { path = "../geth-keychain" } geth-keychain = { path = "../geth-keychain" }
geth-kv = { path = "../geth-kv" } geth-kv = { path = "../geth-kv" }
geth-resource = { path = "../geth-resource" } geth-resource = { path = "../geth-resource" }
geth-secrets = { path = "../geth-secrets" }
geth-ssh-identity = { path = "../geth-ssh-identity" } geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" } geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }

View file

@ -14,6 +14,7 @@ use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}
use geth_keychain::{KeychainOp, KeychainOpKind}; use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_kv::{KvEntry, KvResource}; use geth_kv::{KvEntry, KvResource};
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_secrets::ResourceMasterSecret;
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id, SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id,
@ -21,7 +22,8 @@ use geth_ssh_identity::{
}; };
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredKeychainOp, StoredKvEntry, Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredKeychainOp, StoredKvEntry,
StoredKvStore, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, StoredKvStore, StoredResource, StoredResourceSecret, StoredSshCertRequest,
StoredSshCertificate, StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName,
@ -67,6 +69,8 @@ pub enum NodeError {
InvalidDocumentName(String), InvalidDocumentName(String),
#[error("document not found: {0}")] #[error("document not found: {0}")]
DocumentNotFound(String), DocumentNotFound(String),
#[error("resource not found: {0}")]
ResourceNotFound(String),
#[error("invalid ssh certificate kind: {0}")] #[error("invalid ssh certificate kind: {0}")]
InvalidSshCertKind(String), InvalidSshCertKind(String),
#[error("invalid ssh certificate request status: {0}")] #[error("invalid ssh certificate request status: {0}")]
@ -335,6 +339,27 @@ pub fn handle_request(
nodes: view.nodes.len(), 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 { ControlRequest::AuthExplain {
subject, subject,
resource, 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<ResourceMasterSecret, NodeError> {
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> { fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
store.insert_auth_op(&StoredAuthOp { store.insert_auth_op(&StoredAuthOp {
op_id: op.id.to_string(), op_id: op.id.to_string(),

View file

@ -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<Vec<StoredResourceSecret>, 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::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn latest_resource_secret(
&self,
resource_id: &str,
) -> Result<Option<StoredResourceSecret>, 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( pub fn record_cas_object(
&self, &self,
hash: &str, hash: &str,
@ -753,6 +812,15 @@ pub struct StoredDocumentResource {
pub updated_at_ms: i64, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct CasObject { pub struct CasObject {
pub hash: String, pub hash: String,
@ -1072,4 +1140,39 @@ mod tests {
Some(document) 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]
);
}
} }

View file

@ -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] #[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

@ -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, The payload access plane is `geth-secrets`: resource master secrets, epochs,
key envelopes, bearer secrets, and rotation. Revocation for private data is 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 ## Multi-User Direction

View file

@ -174,12 +174,16 @@ resource-scoped capability decisions.
- `[ ]` Future completion requires signed-op validation before accepting - `[ ]` Future completion requires signed-op validation before accepting
replicated auth ops. replicated auth ops.
- `[ ]` Resource secrets and bearer invites. - `[~]` Resource secrets and bearer invites.
Acceptance criteria: Acceptance criteria:
- Bearer secrets grant only resource-scoped capabilities. - `[x]` `geth secret create <resource>` records resource secret epoch 1
- Bearer principals cannot mutate trust graph state by default. metadata.
- Secret epoch rotation is represented in durable metadata. - `[x]` `geth secret rotate <resource>` records the next resource secret
- Tests verify bearer access does not imply node identity. 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. - `[~]` SSH certificate and revocation lifecycle.
Acceptance criteria: Acceptance criteria: