diff --git a/AGENTS.md b/AGENTS.md index 2abf5ab..0362252 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,8 +126,9 @@ Roadmap items should be actionable and checkable: - 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. + Bearer access metadata can be created/listed/revoked as resource-scoped auth + ops and must not allow trust graph mutation capabilities. Payload encryption, + key envelopes, and bearer challenge-response 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/README.md b/README.md index 9034e02..c04438d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ The bootstrap implementation provides: - `geth secret status` - `geth secret create ` - `geth secret rotate ` +- `geth secret bearer create --capability ` +- `geth secret bearer list` +- `geth secret bearer revoke ` - `geth auth explain ` - `geth auth grant [--grant-id ]` - `geth auth revoke ` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index f807b97..1f5b981 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -161,8 +161,32 @@ pub enum AuthCommand { #[derive(Debug, Subcommand)] pub enum SecretCommand { Status, - Create { resource: String }, - Rotate { resource: String }, + Create { + resource: String, + }, + Rotate { + resource: String, + }, + Bearer { + #[command(subcommand)] + command: SecretBearerCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub enum SecretBearerCommand { + Create { + resource: String, + #[arg(long = "capability", required = true)] + capabilities: Vec, + #[arg(long)] + expires_at_ms: Option, + }, + List, + Revoke { + resource: String, + secret: String, + }, } #[derive(Debug, Subcommand)] @@ -396,6 +420,21 @@ fn request_for_command(command: Command) -> Result { SecretCommand::Status => ControlRequest::SecretStatus, SecretCommand::Create { resource } => ControlRequest::SecretCreate { resource }, SecretCommand::Rotate { resource } => ControlRequest::SecretRotate { resource }, + SecretCommand::Bearer { command } => match command { + SecretBearerCommand::Create { + resource, + capabilities, + expires_at_ms, + } => ControlRequest::SecretBearerCreate { + resource, + capabilities, + expires_at_ms, + }, + SecretBearerCommand::List => ControlRequest::SecretBearerList, + SecretBearerCommand::Revoke { resource, secret } => { + ControlRequest::SecretBearerRevoke { resource, secret } + } + }, }, Command::Cas { command } => match command { CasCommand::Add { path } => ControlRequest::CasAdd { path }, @@ -653,6 +692,46 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("resource: {}", secret.resource); println!("epoch: {}", secret.epoch); } + ControlResponse::SecretBearerCreated { access } => { + println!("bearer secret: {}", access.secret); + println!("resource: {}", access.resource); + println!( + "capabilities: {}", + access + .capabilities + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") + ); + if let Some(expires_at) = access.expires_at { + println!("expires_at_ms: {}", expires_at.0); + } + println!("may_delegate: {}", access.may_delegate); + } + ControlResponse::SecretBearerList { access } => { + if access.is_empty() { + println!("no bearer access"); + } else { + for item in access { + println!( + "{}\t{}\t{}\tmay_delegate={}", + item.secret, + item.resource, + item.capabilities + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + item.may_delegate + ); + } + } + } + ControlResponse::SecretBearerRevoked { resource, secret } => { + println!("revoked bearer secret: {secret}"); + println!("resource: {resource}"); + } ControlResponse::AuthExplain(explain) => { println!("allowed: {}", explain.allowed); println!("subject: {}", explain.subject); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 4b3c061..7bb12a4 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -4,7 +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_secrets::{BearerAccess, ResourceMasterSecret}; use geth_ssh_identity::{ SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, }; @@ -56,6 +56,16 @@ pub enum ControlRequest { SecretRotate { resource: String, }, + SecretBearerCreate { + resource: String, + capabilities: Vec, + expires_at_ms: Option, + }, + SecretBearerList, + SecretBearerRevoke { + resource: String, + secret: String, + }, AuthExplain { subject: String, resource: String, @@ -181,6 +191,16 @@ pub enum ControlResponse { SecretCreated { secret: ResourceMasterSecret, }, + SecretBearerCreated { + access: BearerAccess, + }, + SecretBearerList { + access: Vec, + }, + SecretBearerRevoked { + resource: String, + secret: String, + }, AuthExplain(AuthExplanation), AuthOpRecorded { op: AuthOp, diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index de6b756..aa1a127 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -14,7 +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_secrets::{BearerAccess, ResourceMasterSecret}; use geth_ssh_identity::{ SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id, @@ -71,6 +71,8 @@ pub enum NodeError { DocumentNotFound(String), #[error("resource not found: {0}")] ResourceNotFound(String), + #[error("secrets error: {0}")] + Secrets(#[from] geth_secrets::SecretsError), #[error("invalid ssh certificate kind: {0}")] InvalidSshCertKind(String), #[error("invalid ssh certificate request status: {0}")] @@ -360,6 +362,73 @@ pub fn handle_request( let secret = create_resource_secret(&store, &resource, next_epoch)?; Ok(ControlResponse::SecretCreated { secret }) } + ControlRequest::SecretBearerCreate { + resource, + capabilities, + expires_at_ms, + } => { + ensure_resource_exists(&store, &resource)?; + let capabilities = capabilities + .into_iter() + .map(Capability::new) + .collect::>(); + geth_secrets::validate_bearer_capabilities(&capabilities)?; + let created_at = UnixMillis(geth_store::now_ms()); + let secret = geth_types::SecretId::new(format!( + "bearer:{}", + geth_crypto::blake3_hex( + format!( + "{resource}\0{}\0{}", + capabilities + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + created_at.0 + ) + .as_bytes() + ) + )); + let access = BearerAccess::resource_scoped( + secret.clone(), + ResourceId::new(resource.clone()), + capabilities.clone(), + ); + let op = AuthOp { + id: generated_auth_op_id("bearer-create", &resource, secret.as_str(), created_at), + resource: ResourceId::new(resource), + created_at, + kind: AuthOpKind::BearerAccessCreate { + secret, + capabilities, + expires_at: expires_at_ms.map(UnixMillis), + }, + }; + store_auth_op(&store, &op)?; + Ok(ControlResponse::SecretBearerCreated { + access: BearerAccess { + expires_at: expires_at_ms.map(UnixMillis), + ..access + }, + }) + } + ControlRequest::SecretBearerList => Ok(ControlResponse::SecretBearerList { + access: load_bearer_access(&store)?, + }), + ControlRequest::SecretBearerRevoke { resource, secret } => { + ensure_resource_exists(&store, &resource)?; + let created_at = UnixMillis(geth_store::now_ms()); + let op = AuthOp { + id: generated_auth_op_id("bearer-revoke", &resource, &secret, created_at), + resource: ResourceId::new(resource.clone()), + created_at, + kind: AuthOpKind::BearerAccessRevoke { + secret: secret.clone().into(), + }, + }; + store_auth_op(&store, &op)?; + Ok(ControlResponse::SecretBearerRevoked { resource, secret }) + } ControlRequest::AuthExplain { subject, resource, @@ -831,6 +900,26 @@ fn resource_secret_from_stored(stored: StoredResourceSecret) -> ResourceMasterSe } } +fn load_bearer_access(store: &Store) -> Result, NodeError> { + let ops = store + .list_auth_ops()? + .into_iter() + .map(|stored| serde_json::from_str(&stored.op_json).map_err(NodeError::from)) + .collect::, NodeError>>()?; + let view = geth_auth::reduce_auth_ops(&ops); + Ok(view + .bearer_access + .into_values() + .map(|record| BearerAccess { + secret: record.secret, + resource: record.resource, + capabilities: record.capabilities, + expires_at: record.expires_at, + may_delegate: false, + }) + .collect()) +} + 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-secrets/src/lib.rs b/crates/geth-secrets/src/lib.rs index a40034e..8b1859c 100644 --- a/crates/geth-secrets/src/lib.rs +++ b/crates/geth-secrets/src/lib.rs @@ -45,3 +45,55 @@ impl BearerAccess { } } } + +#[derive(Debug, thiserror::Error)] +pub enum SecretsError { + #[error("bearer access must grant at least one capability")] + EmptyBearerCapabilities, + #[error("capability is not allowed for bearer access: {0}")] + ForbiddenBearerCapability(String), +} + +pub fn validate_bearer_capabilities(capabilities: &[Capability]) -> Result<(), SecretsError> { + if capabilities.is_empty() { + return Err(SecretsError::EmptyBearerCapabilities); + } + for capability in capabilities { + let capability = capability.as_str(); + if matches!( + capability, + "auth.delegate" + | "auth.revoke" + | "trust.modify" + | "ssh_proxy.admin_shell" + | "node.enroll" + ) { + return Err(SecretsError::ForbiddenBearerCapability( + capability.to_owned(), + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bearer_capabilities_reject_trust_mutation() { + assert!(validate_bearer_capabilities(&["kv.read".into(), "kv.write".into()]).is_ok()); + assert!(matches!( + validate_bearer_capabilities(&[]), + Err(SecretsError::EmptyBearerCapabilities) + )); + assert!(matches!( + validate_bearer_capabilities(&["auth.delegate".into()]), + Err(SecretsError::ForbiddenBearerCapability(_)) + )); + assert!(matches!( + validate_bearer_capabilities(&["node.enroll".into()]), + Err(SecretsError::ForbiddenBearerCapability(_)) + )); + } +} diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 90938d0..409c525 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -582,6 +582,23 @@ impl Store { .map_err(StoreError::from) } + pub fn list_auth_ops(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT op_id, resource_id, op_json, created_at_ms + FROM auth_ops ORDER BY resource_id, created_at_ms, op_id"#, + )?; + let rows = stmt.query_map([], |row| { + Ok(StoredAuthOp { + op_id: row.get(0)?, + resource_id: row.get(1)?, + op_json: row.get(2)?, + created_at_ms: row.get(3)?, + }) + })?; + rows.collect::, _>>() + .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) @@ -995,6 +1012,7 @@ mod tests { .expect("list auth ops"), vec![first] ); + assert_eq!(store.list_auth_ops().expect("list all auth ops").len(), 2); } #[test] diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 7e96f8e..6b74f56 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -667,6 +667,73 @@ fn secret_create_rotate_and_status_track_resource_epochs() { ); } +#[test] +fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { + 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 response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretBearerCreate { + resource: "resource:cas:local".to_owned(), + capabilities: vec!["cas.fetch".to_owned(), "cas.pin".to_owned()], + expires_at_ms: Some(1234), + }, + ) + .expect("create bearer"); + let secret = match response { + geth_control::ControlResponse::SecretBearerCreated { access } => { + assert_eq!(access.resource.to_string(), "resource:cas:local"); + assert_eq!(access.capabilities.len(), 2); + assert_eq!(access.expires_at.map(|expires_at| expires_at.0), Some(1234)); + assert!(!access.may_delegate); + access.secret.to_string() + } + other => panic!("unexpected response: {other:?}"), + }; + + let response = geth_node::handle_request(&node, geth_control::ControlRequest::SecretBearerList) + .expect("list bearer"); + match response { + geth_control::ControlResponse::SecretBearerList { access } => { + assert_eq!(access.len(), 1); + assert_eq!(access[0].secret.to_string(), secret); + assert!(!access[0].may_delegate); + } + other => panic!("unexpected response: {other:?}"), + } + + geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretBearerRevoke { + resource: "resource:cas:local".to_owned(), + secret: secret.clone(), + }, + ) + .expect("revoke bearer"); + let response = geth_node::handle_request(&node, geth_control::ControlRequest::SecretBearerList) + .expect("list revoked bearer"); + match response { + geth_control::ControlResponse::SecretBearerList { access } => { + assert!(access.is_empty()); + } + other => panic!("unexpected response: {other:?}"), + } + + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretBearerCreate { + resource: "resource:cas:local".to_owned(), + capabilities: vec!["auth.delegate".to_owned()], + expires_at_ms: None, + }, + ) + .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 5cdd04c..97fe3a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,8 +140,10 @@ 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. 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. +epoch metadata through `secret create/rotate/status`. Bearer access is recorded +as resource-scoped auth operations and rejects trust-mutation capabilities such +as `auth.delegate`, `auth.revoke`, and `node.enroll`. The daemon 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 5aa351a..ae69de6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -181,9 +181,11 @@ resource-scoped capability decisions. - `[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. + - `[x]` Bearer secrets grant only resource-scoped capabilities. + - `[x]` Bearer principals cannot mutate trust graph state by default. + - `[x]` Tests verify bearer access does not imply node identity. + - `[ ]` Future completion requires bearer challenge-response proof instead + of metadata-only local records. - `[~]` SSH certificate and revocation lifecycle. Acceptance criteria: