diff --git a/AGENTS.md b/AGENTS.md index 9d2e267..f110957 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,8 +177,10 @@ Roadmap items should be actionable and checkable: SSH bytes or connect to sshd/admin shell yet. - Resource secret epoch metadata can be created, rotated, and listed locally. 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. + ops and must not allow trust graph mutation capabilities. Bearer + challenge/proof/verify commands exist for resource-scoped possession checks. + Payload encryption, key envelopes, and wiring bearer proofs into remote module + authorization are still roadmap work. - SSH revocations can be exported as JSONL, OpenSSH KRL specification text, or binary OpenSSH KRL files generated through `ssh-keygen`. JSONL and OpenSSH KRL specification imports are supported; binary KRL import is unsupported because diff --git a/Cargo.lock b/Cargo.lock index e6ddcd1..df67807 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1294,6 +1294,7 @@ dependencies = [ name = "geth-secrets" version = "0.1.0" dependencies = [ + "blake3", "geth-types", "serde", "thiserror 2.0.18", diff --git a/README.md b/README.md index b18ac52..c33e85e 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ The bootstrap implementation provides: - `geth secret rotate ` - `geth secret bearer create --capability ` - `geth secret bearer list` +- `geth secret bearer challenge --capability ` +- `geth secret bearer prove --nonce --capability ` +- `geth secret bearer verify --nonce --response --capability ` - `geth secret bearer revoke ` - `geth auth explain ` - `geth auth grant [--grant-id ]` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index ffab433..ac1e595 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -209,6 +209,29 @@ pub enum SecretBearerCommand { expires_at_ms: Option, }, List, + Challenge { + resource: String, + #[arg(long = "capability", required = true)] + capabilities: Vec, + }, + Prove { + secret: String, + resource: String, + #[arg(long)] + nonce: String, + #[arg(long = "capability", required = true)] + capabilities: Vec, + }, + Verify { + secret: String, + resource: String, + #[arg(long)] + nonce: String, + #[arg(long)] + response: String, + #[arg(long = "capability", required = true)] + capabilities: Vec, + }, Revoke { resource: String, secret: String, @@ -606,6 +629,37 @@ fn request_for_command(command: Command) -> Result { expires_at_ms, }, SecretBearerCommand::List => ControlRequest::SecretBearerList, + SecretBearerCommand::Challenge { + resource, + capabilities, + } => ControlRequest::SecretBearerChallenge { + resource, + capabilities, + }, + SecretBearerCommand::Prove { + secret, + resource, + capabilities, + nonce, + } => ControlRequest::SecretBearerProve { + secret, + resource, + capabilities, + nonce, + }, + SecretBearerCommand::Verify { + secret, + resource, + capabilities, + nonce, + response, + } => ControlRequest::SecretBearerVerify { + secret, + resource, + capabilities, + nonce, + response, + }, SecretBearerCommand::Revoke { resource, secret } => { ControlRequest::SecretBearerRevoke { resource, secret } } @@ -1171,6 +1225,50 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } } } + ControlResponse::SecretBearerChallenge { challenge } => { + println!("bearer challenge"); + println!("resource: {}", challenge.resource); + println!("nonce: {}", challenge.nonce); + println!("issued_at_ms: {}", challenge.issued_at.0); + println!( + "capabilities: {}", + challenge + .capabilities + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") + ); + } + ControlResponse::SecretBearerProof { proof } => { + println!("bearer proof"); + println!("secret: {}", proof.secret); + println!("resource: {}", proof.resource); + println!("nonce: {}", proof.nonce); + println!("response: {}", proof.response); + println!( + "capabilities: {}", + proof + .capabilities + .iter() + .map(ToString::to_string) + .collect::>() + .join(",") + ); + } + ControlResponse::SecretBearerVerified { + secret, + resource, + capabilities, + verified, + reason, + } => { + println!("bearer verified: {verified}"); + println!("secret: {secret}"); + println!("resource: {resource}"); + println!("capabilities: {}", capabilities.join(",")); + println!("reason: {reason}"); + } ControlResponse::SecretBearerRevoked { resource, secret } => { println!("revoked bearer secret: {secret}"); println!("resource: {resource}"); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index df9f17e..113ffbe 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -8,7 +8,7 @@ use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_pipe::{PipeConnection, PipeListener}; use geth_pubsub::PubsubMessage; use geth_resource::ResourceDescriptor; -use geth_secrets::{BearerAccess, ResourceMasterSecret}; +use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret}; use geth_ssh_identity::{ SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, }; @@ -115,6 +115,23 @@ pub enum ControlRequest { expires_at_ms: Option, }, SecretBearerList, + SecretBearerChallenge { + resource: String, + capabilities: Vec, + }, + SecretBearerProve { + secret: String, + resource: String, + capabilities: Vec, + nonce: String, + }, + SecretBearerVerify { + secret: String, + resource: String, + capabilities: Vec, + nonce: String, + response: String, + }, SecretBearerRevoke { resource: String, secret: String, @@ -384,6 +401,19 @@ pub enum ControlResponse { SecretBearerList { access: Vec, }, + SecretBearerChallenge { + challenge: BearerChallenge, + }, + SecretBearerProof { + proof: BearerProof, + }, + SecretBearerVerified { + secret: String, + resource: String, + capabilities: Vec, + verified: bool, + reason: String, + }, SecretBearerRevoked { resource: String, secret: String, @@ -943,6 +973,30 @@ mod tests { response ); + let request = ControlRequest::SecretBearerVerify { + secret: "bearer:test".to_owned(), + resource: "resource:cas:local".to_owned(), + capabilities: vec!["cas.fetch".to_owned()], + nonce: "nonce".to_owned(), + response: "response".to_owned(), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + + let response = ControlResponse::SecretBearerVerified { + secret: "bearer:test".to_owned(), + resource: "resource:cas:local".to_owned(), + capabilities: vec!["cas.fetch".to_owned()], + verified: true, + reason: "ok".to_owned(), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + let request = ControlRequest::CasFetch { node: "node:peer".to_owned(), hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 0475c1f..9d9cff8 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -24,7 +24,7 @@ use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_pipe::{PipeConnection, PipeListener}; use geth_pubsub::PubsubMessage; use geth_resource::ResourceDescriptor; -use geth_secrets::{BearerAccess, ResourceMasterSecret}; +use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret}; use geth_ssh_identity::{ SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, build_ssh_cert_sign_command, @@ -3011,6 +3011,114 @@ pub fn handle_request( ControlRequest::SecretBearerList => Ok(ControlResponse::SecretBearerList { access: load_bearer_access(&store)?, }), + ControlRequest::SecretBearerChallenge { + resource, + capabilities, + } => { + ensure_resource_exists(&store, &resource)?; + let capabilities = capabilities + .into_iter() + .map(Capability::new) + .collect::>(); + geth_secrets::validate_bearer_capabilities(&capabilities)?; + let issued_at = UnixMillis(geth_store::now_ms()); + let nonce = geth_crypto::blake3_hex( + format!("{}\0{}\0{}", node.agent_id, resource, issued_at.0).as_bytes(), + ); + Ok(ControlResponse::SecretBearerChallenge { + challenge: BearerChallenge { + resource: ResourceId::new(resource), + capabilities, + nonce, + issued_at, + }, + }) + } + ControlRequest::SecretBearerProve { + secret, + resource, + capabilities, + nonce, + } => { + ensure_resource_exists(&store, &resource)?; + let capabilities = capabilities + .into_iter() + .map(Capability::new) + .collect::>(); + geth_secrets::validate_bearer_capabilities(&capabilities)?; + let secret = geth_types::SecretId::new(secret); + let resource = ResourceId::new(resource); + let response = geth_secrets::bearer_response(&secret, &resource, &capabilities, &nonce); + Ok(ControlResponse::SecretBearerProof { + proof: BearerProof { + secret, + resource, + capabilities, + nonce, + response, + }, + }) + } + ControlRequest::SecretBearerVerify { + secret, + resource, + capabilities, + nonce, + response, + } => { + ensure_resource_exists(&store, &resource)?; + let requested_capabilities = capabilities + .iter() + .cloned() + .map(Capability::new) + .collect::>(); + geth_secrets::validate_bearer_capabilities(&requested_capabilities)?; + let secret_id = geth_types::SecretId::new(secret.clone()); + let resource_id = ResourceId::new(resource.clone()); + let access = load_bearer_access(&store)?.into_iter().find(|access| { + access.secret == secret_id + && access.resource == resource_id + && access + .expires_at + .is_none_or(|expires_at| expires_at.0 >= geth_store::now_ms()) + }); + let (verified, reason) = if let Some(access) = access { + let has_capabilities = requested_capabilities + .iter() + .all(|capability| access.capabilities.contains(capability)); + if !has_capabilities { + ( + false, + "bearer secret lacks requested capabilities".to_owned(), + ) + } else if geth_secrets::verify_bearer_response( + &secret_id, + &resource_id, + &requested_capabilities, + &nonce, + &response, + ) { + ( + true, + "bearer proof verified for resource-scoped capabilities".to_owned(), + ) + } else { + ( + false, + "bearer proof response did not match challenge".to_owned(), + ) + } + } else { + (false, "bearer access not found or expired".to_owned()) + }; + Ok(ControlResponse::SecretBearerVerified { + secret, + resource, + capabilities, + verified, + reason, + }) + } ControlRequest::SecretBearerRevoke { resource, secret } => { ensure_resource_exists(&store, &resource)?; let created_at = UnixMillis(geth_store::now_ms()); diff --git a/crates/geth-secrets/Cargo.toml b/crates/geth-secrets/Cargo.toml index 85f08af..2789d5c 100644 --- a/crates/geth-secrets/Cargo.toml +++ b/crates/geth-secrets/Cargo.toml @@ -6,6 +6,7 @@ rust-version.workspace = true license.workspace = true [dependencies] +blake3.workspace = true serde.workspace = true thiserror.workspace = true geth-types = { path = "../geth-types" } diff --git a/crates/geth-secrets/src/lib.rs b/crates/geth-secrets/src/lib.rs index 8b1859c..80e00b1 100644 --- a/crates/geth-secrets/src/lib.rs +++ b/crates/geth-secrets/src/lib.rs @@ -29,6 +29,23 @@ pub struct BearerAccess { pub may_delegate: bool, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BearerChallenge { + pub resource: ResourceId, + pub capabilities: Vec, + pub nonce: String, + pub issued_at: UnixMillis, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BearerProof { + pub secret: SecretId, + pub resource: ResourceId, + pub capabilities: Vec, + pub nonce: String, + pub response: String, +} + impl BearerAccess { #[must_use] pub fn resource_scoped( @@ -76,6 +93,43 @@ pub fn validate_bearer_capabilities(capabilities: &[Capability]) -> Result<(), S Ok(()) } +#[must_use] +pub fn bearer_response( + secret: &SecretId, + resource: &ResourceId, + capabilities: &[Capability], + nonce: &str, +) -> String { + let key = *blake3::hash(secret.as_str().as_bytes()).as_bytes(); + let mut capability_strings = capabilities + .iter() + .map(|capability| capability.as_str()) + .collect::>(); + capability_strings.sort_unstable(); + let mut message = String::new(); + message.push_str(resource.as_str()); + message.push('\0'); + message.push_str(nonce); + for capability in capability_strings { + message.push('\0'); + message.push_str(capability); + } + blake3::keyed_hash(&key, message.as_bytes()) + .to_hex() + .to_string() +} + +#[must_use] +pub fn verify_bearer_response( + secret: &SecretId, + resource: &ResourceId, + capabilities: &[Capability], + nonce: &str, + response: &str, +) -> bool { + bearer_response(secret, resource, capabilities, nonce) == response +} + #[cfg(test)] mod tests { use super::*; @@ -96,4 +150,35 @@ mod tests { Err(SecretsError::ForbiddenBearerCapability(_)) )); } + + #[test] + fn bearer_response_is_resource_scoped_and_capability_scoped() { + let secret = SecretId::new("bearer:test"); + let resource = ResourceId::new("resource:kv:prefs"); + let capabilities = vec![Capability::new("kv.read"), Capability::new("kv.write")]; + + let response = bearer_response(&secret, &resource, &capabilities, "nonce"); + + assert!(verify_bearer_response( + &secret, + &resource, + &capabilities, + "nonce", + &response + )); + assert!(!verify_bearer_response( + &secret, + &ResourceId::new("resource:kv:other"), + &capabilities, + "nonce", + &response + )); + assert!(!verify_bearer_response( + &secret, + &resource, + &[Capability::new("kv.read")], + "nonce", + &response + )); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index a120cac..1cdc8dc 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -1224,7 +1224,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { 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), + expires_at_ms: Some(4_102_444_800_000), }, ) .expect("create bearer"); @@ -1232,7 +1232,10 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { 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_eq!( + access.expires_at.map(|expires_at| expires_at.0), + Some(4_102_444_800_000) + ); assert!(!access.may_delegate); access.secret.to_string() } @@ -1250,6 +1253,79 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { other => panic!("unexpected response: {other:?}"), } + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretBearerChallenge { + resource: "resource:cas:local".to_owned(), + capabilities: vec!["cas.fetch".to_owned()], + }, + ) + .expect("create bearer challenge"); + let nonce = match response { + geth_control::ControlResponse::SecretBearerChallenge { challenge } => { + assert_eq!(challenge.resource.to_string(), "resource:cas:local"); + assert_eq!(challenge.capabilities[0].to_string(), "cas.fetch"); + challenge.nonce + } + other => panic!("unexpected response: {other:?}"), + }; + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretBearerProve { + secret: secret.clone(), + resource: "resource:cas:local".to_owned(), + capabilities: vec!["cas.fetch".to_owned()], + nonce: nonce.clone(), + }, + ) + .expect("prove bearer challenge"); + let proof_response = match response { + geth_control::ControlResponse::SecretBearerProof { proof } => { + assert_eq!(proof.secret.to_string(), secret); + assert_eq!(proof.resource.to_string(), "resource:cas:local"); + assert!(!proof.response.is_empty()); + proof.response + } + other => panic!("unexpected response: {other:?}"), + }; + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretBearerVerify { + secret: secret.clone(), + resource: "resource:cas:local".to_owned(), + capabilities: vec!["cas.fetch".to_owned()], + nonce: nonce.clone(), + response: proof_response.clone(), + }, + ) + .expect("verify bearer proof"); + match response { + geth_control::ControlResponse::SecretBearerVerified { verified, .. } => { + assert!(verified); + } + other => panic!("unexpected response: {other:?}"), + } + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretBearerVerify { + secret: secret.clone(), + resource: "resource:cas:local".to_owned(), + capabilities: vec!["cas.pin".to_owned()], + nonce, + response: proof_response, + }, + ) + .expect("verify bearer proof for wrong capability"); + match response { + geth_control::ControlResponse::SecretBearerVerified { + verified, reason, .. + } => { + assert!(!verified); + assert!(reason.contains("did not match")); + } + other => panic!("unexpected response: {other:?}"), + } + geth_node::handle_request( &node, geth_control::ControlRequest::SecretBearerRevoke { diff --git a/docs/architecture.md b/docs/architecture.md index d2f4a2b..6b5b078 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -268,8 +268,11 @@ 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`. 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. +as `auth.delegate`, `auth.revoke`, and `node.enroll`. Bearer challenge/proof +commands derive deterministic BLAKE3 keyed responses from the bearer secret, +resource, nonce, and requested capabilities, then verify them against active +resource-scoped bearer grants. 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 7ca3d9c..5273896 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -220,8 +220,11 @@ resource-scoped capability decisions. - `[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. + - `[x]` `geth secret bearer challenge/prove/verify` exercises + resource-scoped bearer challenge-response proofs. + - `[x]` Tests verify valid bearer proofs and capability-scoped proof denial. + - `[ ]` Future completion wires bearer proof verification into remote module + authorization paths. - `[~]` SSH certificate and revocation lifecycle. Acceptance criteria: