diff --git a/AGENTS.md b/AGENTS.md index 3ab0fa3..9f81f4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,9 +182,9 @@ Roadmap items should be actionable and checkable: ops and must not allow trust graph mutation capabilities. Bearer challenge/proof/verify commands exist for resource-scoped possession checks. Remote module authorization paths can accept optional bearer proofs without - enrolling the caller as a trusted node. Payload encryption, key envelopes, and - separating public bearer ids from private bearer tokens are still roadmap - work. + enrolling the caller as a trusted node. Bearer creation separates the public + bearer id stored in auth logs from the private bearer token returned to the + caller once. Payload encryption and key envelopes 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/README.md b/README.md index f549333..0e84a44 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,9 @@ The bootstrap implementation provides: - `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 secret bearer prove --nonce --capability ` +- `geth secret bearer verify --nonce --response --capability ` +- `geth secret bearer revoke ` - `geth auth explain ` - `geth auth grant [--grant-id ]` - `geth auth revoke ` @@ -158,8 +158,10 @@ fetches record the serving peer as a local provider, visible with `geth cas providers `. This is the bootstrap transfer path; future work will move provider/fetch behavior to `iroh-blobs`. Remote resource commands that accept `--bearer-secret` can also authorize with a -resource-scoped bearer proof. This does not enroll the caller as a trusted node; -it only unlocks the requested capability on that one resource. +resource-scoped bearer proof generated from the private bearer token returned at +creation time. The persisted auth log stores a public bearer id and token +verifier, not the private token. This does not enroll the caller as a trusted +node; it only unlocks the requested capability on that one resource. `geth ssh cert sync ` requires `ssh_cert.sync` on `resource:ssh:certs` at the peer. `geth ssh revocation sync ` requires `ssh_revocation.sync` on `resource:ssh:revocations`. Both commands merge diff --git a/crates/geth-auth/src/lib.rs b/crates/geth-auth/src/lib.rs index 5d07e2b..1ce8a43 100644 --- a/crates/geth-auth/src/lib.rs +++ b/crates/geth-auth/src/lib.rs @@ -48,6 +48,7 @@ pub enum AuthOpKind { }, BearerAccessCreate { secret: SecretId, + token_hash: Option, capabilities: Vec, expires_at: Option, }, @@ -99,6 +100,7 @@ pub struct GroupRecord { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BearerAccessRecord { pub secret: SecretId, + pub token_hash: Option, pub resource: ResourceId, pub capabilities: Vec, pub expires_at: Option, @@ -150,6 +152,7 @@ pub fn reduce_auth_ops(ops: &[AuthOp]) -> AuthView { } AuthOpKind::BearerAccessCreate { secret, + token_hash, capabilities, expires_at, } => { @@ -160,6 +163,7 @@ pub fn reduce_auth_ops(ops: &[AuthOp]) -> AuthView { secret.clone(), BearerAccessRecord { secret: secret.clone(), + token_hash: token_hash.clone(), resource: op.resource.clone(), capabilities, expires_at: *expires_at, @@ -542,6 +546,7 @@ mod tests { 4, AuthOpKind::BearerAccessCreate { secret: "secret:invite".into(), + token_hash: Some("token-hash".to_owned()), capabilities: vec!["pipe.connect".into()], expires_at: Some(UnixMillis(100)), }, diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index f0226dd..94ea864 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -215,7 +215,7 @@ pub enum SecretBearerCommand { capabilities: Vec, }, Prove { - secret: String, + token: String, resource: String, #[arg(long)] nonce: String, @@ -223,7 +223,7 @@ pub enum SecretBearerCommand { capabilities: Vec, }, Verify { - secret: String, + token: String, resource: String, #[arg(long)] nonce: String, @@ -234,7 +234,7 @@ pub enum SecretBearerCommand { }, Revoke { resource: String, - secret: String, + bearer_id: String, }, } @@ -673,32 +673,36 @@ fn request_for_command(command: Command) -> Result { capabilities, }, SecretBearerCommand::Prove { - secret, + token, resource, capabilities, nonce, } => ControlRequest::SecretBearerProve { - secret, + secret: token, resource, capabilities, nonce, }, SecretBearerCommand::Verify { - secret, + token, resource, capabilities, nonce, response, } => ControlRequest::SecretBearerVerify { - secret, + secret: token, resource, capabilities, nonce, response, }, - SecretBearerCommand::Revoke { resource, secret } => { - ControlRequest::SecretBearerRevoke { resource, secret } - } + SecretBearerCommand::Revoke { + resource, + bearer_id, + } => ControlRequest::SecretBearerRevoke { + resource, + secret: bearer_id, + }, }, }, Command::Cas { command } => match command { @@ -1301,7 +1305,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("epoch: {}", secret.epoch); } ControlResponse::SecretBearerCreated { access } => { - println!("bearer secret: {}", access.secret); + println!("bearer id: {}", access.secret); + if let Some(token) = access.token { + println!("bearer token: {token}"); + } println!("resource: {}", access.resource); println!( "capabilities: {}", diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 3de9b71..855768d 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -1724,11 +1724,12 @@ fn bearer_proof( capability: &str, nonce: &str, ) -> Option { - bearer_secret.map(|secret| { - let secret = geth_types::SecretId::new(secret); + bearer_secret.map(|token| { + let token = geth_types::SecretId::new(token); + let secret = geth_secrets::bearer_id_for_token(&token); let resource = ResourceId::new(resource.to_owned()); let capabilities = vec![Capability::new(capability.to_owned())]; - let response = geth_secrets::bearer_response(&secret, &resource, &capabilities, nonce); + let response = geth_secrets::bearer_response(&token, &resource, &capabilities, nonce); BearerProof { secret, resource, @@ -1793,16 +1794,6 @@ fn explain_peer_or_bearer( "bearer proof does not include the requested capability".to_owned(), )); } - if !geth_secrets::verify_bearer_response( - &proof.secret, - &proof.resource, - &proof.capabilities, - nonce, - &proof.response, - ) { - return Ok(denied("bearer proof response is invalid".to_owned())); - } - let now = UnixMillis(geth_store::now_ms()); let Some(access) = load_bearer_access(store)?.into_iter().find(|access| { access.secret == proof.secret @@ -1813,6 +1804,20 @@ fn explain_peer_or_bearer( "bearer access is not active for requested resource".to_owned(), )); }; + let Some(token_hash) = access.token_hash.as_deref() else { + return Ok(denied( + "bearer access does not have a token verifier".to_owned(), + )); + }; + if !geth_secrets::verify_bearer_response_with_token_hash( + token_hash, + &proof.resource, + &proof.capabilities, + nonce, + &proof.response, + ) { + return Ok(denied("bearer proof response is invalid".to_owned())); + } if !access .capabilities .iter() @@ -3307,11 +3312,12 @@ pub fn handle_request( .collect::>(); geth_secrets::validate_bearer_capabilities(&capabilities)?; let created_at = UnixMillis(geth_store::now_ms()); - let secret = geth_types::SecretId::new(format!( - "bearer:{}", + let token = geth_types::SecretId::new(format!( + "gbt_{}", geth_crypto::blake3_hex( format!( - "{resource}\0{}\0{}", + "{}\0{resource}\0{}\0{}", + node.agent_id, capabilities .iter() .map(ToString::to_string) @@ -3322,6 +3328,8 @@ pub fn handle_request( .as_bytes() ) )); + let secret = geth_secrets::bearer_id_for_token(&token); + let token_hash = geth_secrets::bearer_token_hash(&token); let access = BearerAccess::resource_scoped( secret.clone(), ResourceId::new(resource.clone()), @@ -3333,6 +3341,7 @@ pub fn handle_request( created_at, kind: AuthOpKind::BearerAccessCreate { secret, + token_hash: Some(token_hash), capabilities, expires_at: expires_at_ms.map(UnixMillis), }, @@ -3340,6 +3349,7 @@ pub fn handle_request( store_auth_op(&store, &op)?; Ok(ControlResponse::SecretBearerCreated { access: BearerAccess { + token: Some(token), expires_at: expires_at_ms.map(UnixMillis), ..access }, @@ -3383,9 +3393,10 @@ pub fn handle_request( .map(Capability::new) .collect::>(); geth_secrets::validate_bearer_capabilities(&capabilities)?; - let secret = geth_types::SecretId::new(secret); + let token = geth_types::SecretId::new(secret); + let secret = geth_secrets::bearer_id_for_token(&token); let resource = ResourceId::new(resource); - let response = geth_secrets::bearer_response(&secret, &resource, &capabilities, &nonce); + let response = geth_secrets::bearer_response(&token, &resource, &capabilities, &nonce); Ok(ControlResponse::SecretBearerProof { proof: BearerProof { secret, @@ -3410,7 +3421,8 @@ pub fn handle_request( .map(Capability::new) .collect::>(); geth_secrets::validate_bearer_capabilities(&requested_capabilities)?; - let secret_id = geth_types::SecretId::new(secret.clone()); + let token = geth_types::SecretId::new(secret.clone()); + let secret_id = geth_secrets::bearer_id_for_token(&token); let resource_id = ResourceId::new(resource.clone()); let access = load_bearer_access(&store)?.into_iter().find(|access| { access.secret == secret_id @@ -3428,13 +3440,15 @@ pub fn handle_request( false, "bearer secret lacks requested capabilities".to_owned(), ) - } else if geth_secrets::verify_bearer_response( - &secret_id, - &resource_id, - &requested_capabilities, - &nonce, - &response, - ) { + } else if access.token_hash.as_deref().is_some_and(|token_hash| { + geth_secrets::verify_bearer_response_with_token_hash( + token_hash, + &resource_id, + &requested_capabilities, + &nonce, + &response, + ) + }) { ( true, "bearer proof verified for resource-scoped capabilities".to_owned(), @@ -4458,6 +4472,8 @@ fn load_bearer_access(store: &Store) -> Result, NodeError> { .into_values() .map(|record| BearerAccess { secret: record.secret, + token: None, + token_hash: record.token_hash, resource: record.resource, capabilities: record.capabilities, expires_at: record.expires_at, @@ -5482,7 +5498,10 @@ mod tests { ) .expect("create remote CAS bearer access") { - ControlResponse::SecretBearerCreated { access } => access.secret.to_string(), + ControlResponse::SecretBearerCreated { access } => access + .token + .expect("private bearer token on create") + .to_string(), other => panic!("unexpected bearer create response: {other:?}"), }; let bearer_fetch = handle_request_async( diff --git a/crates/geth-secrets/src/lib.rs b/crates/geth-secrets/src/lib.rs index 80e00b1..e5b7c4c 100644 --- a/crates/geth-secrets/src/lib.rs +++ b/crates/geth-secrets/src/lib.rs @@ -23,6 +23,8 @@ pub struct ResourceKeyEnvelope { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BearerAccess { pub secret: SecretId, + pub token: Option, + pub token_hash: Option, pub resource: ResourceId, pub capabilities: Vec, pub expires_at: Option, @@ -55,6 +57,8 @@ impl BearerAccess { ) -> Self { Self { secret, + token: None, + token_hash: None, resource, capabilities, expires_at: None, @@ -93,6 +97,17 @@ pub fn validate_bearer_capabilities(capabilities: &[Capability]) -> Result<(), S Ok(()) } +#[must_use] +pub fn bearer_token_hash(token: &SecretId) -> String { + blake3::hash(token.as_str().as_bytes()).to_hex().to_string() +} + +#[must_use] +pub fn bearer_id_for_token(token: &SecretId) -> SecretId { + let hash = bearer_token_hash(token); + SecretId::new(format!("bearer:{}", &hash[..32])) +} + #[must_use] pub fn bearer_response( secret: &SecretId, @@ -119,6 +134,34 @@ pub fn bearer_response( .to_string() } +#[must_use] +pub fn bearer_response_with_token_hash( + token_hash: &str, + resource: &ResourceId, + capabilities: &[Capability], + nonce: &str, +) -> Option { + let hash_bytes = hex_to_32_bytes(token_hash)?; + 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); + } + Some( + blake3::keyed_hash(&hash_bytes, message.as_bytes()) + .to_hex() + .to_string(), + ) +} + #[must_use] pub fn verify_bearer_response( secret: &SecretId, @@ -130,6 +173,29 @@ pub fn verify_bearer_response( bearer_response(secret, resource, capabilities, nonce) == response } +#[must_use] +pub fn verify_bearer_response_with_token_hash( + token_hash: &str, + resource: &ResourceId, + capabilities: &[Capability], + nonce: &str, + response: &str, +) -> bool { + bearer_response_with_token_hash(token_hash, resource, capabilities, nonce) + .is_some_and(|expected| expected == response) +} + +fn hex_to_32_bytes(hex: &str) -> Option<[u8; 32]> { + if hex.len() != 64 { + return None; + } + let mut bytes = [0_u8; 32]; + for index in 0..32 { + bytes[index] = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16).ok()?; + } + Some(bytes) +} + #[cfg(test)] mod tests { use super::*; @@ -153,7 +219,7 @@ mod tests { #[test] fn bearer_response_is_resource_scoped_and_capability_scoped() { - let secret = SecretId::new("bearer:test"); + let secret = SecretId::new("gbt_test"); let resource = ResourceId::new("resource:kv:prefs"); let capabilities = vec![Capability::new("kv.read"), Capability::new("kv.write")]; @@ -181,4 +247,31 @@ mod tests { &response )); } + + #[test] + fn bearer_id_is_separate_from_private_token() { + let token = SecretId::new("gbt_private_token"); + let bearer_id = bearer_id_for_token(&token); + let token_hash = bearer_token_hash(&token); + let resource = ResourceId::new("resource:cas:local"); + let capabilities = vec![Capability::new("cas.fetch")]; + let response = bearer_response(&token, &resource, &capabilities, "nonce"); + + assert_ne!(bearer_id, token); + assert!(bearer_id.as_str().starts_with("bearer:")); + assert!(verify_bearer_response_with_token_hash( + &token_hash, + &resource, + &capabilities, + "nonce", + &response + )); + assert!(!verify_bearer_response_with_token_hash( + &token_hash, + &resource, + &[Capability::new("cas.pin")], + "nonce", + &response + )); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 0ed0e94..4a5b6e1 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -1228,7 +1228,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { }, ) .expect("create bearer"); - let secret = match response { + let (secret, token) = match response { geth_control::ControlResponse::SecretBearerCreated { access } => { assert_eq!(access.resource.to_string(), "resource:cas:local"); assert_eq!(access.capabilities.len(), 2); @@ -1237,7 +1237,10 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { Some(4_102_444_800_000) ); assert!(!access.may_delegate); - access.secret.to_string() + let token = access.token.expect("private bearer token on create"); + assert_ne!(access.secret, token); + assert!(access.token_hash.is_none()); + (access.secret.to_string(), token.to_string()) } other => panic!("unexpected response: {other:?}"), }; @@ -1248,6 +1251,8 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { geth_control::ControlResponse::SecretBearerList { access } => { assert_eq!(access.len(), 1); assert_eq!(access[0].secret.to_string(), secret); + assert!(access[0].token.is_none()); + assert!(access[0].token_hash.is_some()); assert!(!access[0].may_delegate); } other => panic!("unexpected response: {other:?}"), @@ -1272,7 +1277,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { let response = geth_node::handle_request( &node, geth_control::ControlRequest::SecretBearerProve { - secret: secret.clone(), + secret: token.clone(), resource: "resource:cas:local".to_owned(), capabilities: vec!["cas.fetch".to_owned()], nonce: nonce.clone(), @@ -1291,7 +1296,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { let response = geth_node::handle_request( &node, geth_control::ControlRequest::SecretBearerVerify { - secret: secret.clone(), + secret: token.clone(), resource: "resource:cas:local".to_owned(), capabilities: vec!["cas.fetch".to_owned()], nonce: nonce.clone(), @@ -1308,7 +1313,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() { let response = geth_node::handle_request( &node, geth_control::ControlRequest::SecretBearerVerify { - secret: secret.clone(), + secret: token, resource: "resource:cas:local".to_owned(), capabilities: vec!["cas.pin".to_owned()], nonce, diff --git a/docs/architecture.md b/docs/architecture.md index 0f6d3e2..3b0735a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -270,14 +270,15 @@ 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`. 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. Remote resource operations can carry optional -bearer proofs over the protected Iroh control path; a valid proof authorizes -only the requested resource capability and does not create node trust. The daemon -does not yet store payload key material, encrypt resource data, distribute key -envelopes, or separate public bearer ids from private bearer tokens. +as `auth.delegate`, `auth.revoke`, and `node.enroll`. Bearer creation returns a +private bearer token once and stores a separate public bearer id plus token +verifier in the auth log. Bearer challenge/proof commands derive deterministic +BLAKE3 keyed responses from the private token, resource, nonce, and requested +capabilities, then verify them against active resource-scoped bearer grants. +Remote resource operations can carry optional bearer proofs over the protected +Iroh control path; a valid proof authorizes only the requested resource +capability and does not create node trust. 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 a51ba5b..d9b9fcf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -227,8 +227,12 @@ resource-scoped capability decisions. the requested resource capability without granting node identity. - `[x]` Tests verify remote CAS fetch succeeds through a bearer proof before the caller has a node grant. - - `[ ]` Future completion avoids sending bearer secret identifiers as proof - material by separating public bearer ids from private bearer tokens. + - `[x]` Bearer creation separates the persisted public bearer id from the + private bearer token returned to the caller. + - `[x]` Bearer list/revoke operate on public bearer ids while proof and remote + authorization use the private token. + - `[x]` Tests verify the public bearer id differs from the private token and + remote bearer auth uses the token. - `[~]` SSH certificate and revocation lifecycle. Acceptance criteria: