Add bearer challenge-response proofs

This commit is contained in:
Eric Wendland 2026-05-19 19:08:08 +02:00
commit 72cd018a3e
11 changed files with 444 additions and 10 deletions

View file

@ -177,8 +177,10 @@ Roadmap items should be actionable and checkable:
SSH bytes or connect to sshd/admin shell yet. SSH bytes or connect to sshd/admin shell yet.
- Resource secret epoch metadata can be created, rotated, and listed locally. - Resource secret epoch metadata can be created, rotated, and listed locally.
Bearer access metadata can be created/listed/revoked as resource-scoped auth Bearer access metadata can be created/listed/revoked as resource-scoped auth
ops and must not allow trust graph mutation capabilities. Payload encryption, ops and must not allow trust graph mutation capabilities. Bearer
key envelopes, and bearer challenge-response are still roadmap work. 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 - SSH revocations can be exported as JSONL, OpenSSH KRL specification text, or
binary OpenSSH KRL files generated through `ssh-keygen`. JSONL and OpenSSH KRL binary OpenSSH KRL files generated through `ssh-keygen`. JSONL and OpenSSH KRL
specification imports are supported; binary KRL import is unsupported because specification imports are supported; binary KRL import is unsupported because

1
Cargo.lock generated
View file

@ -1294,6 +1294,7 @@ dependencies = [
name = "geth-secrets" name = "geth-secrets"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"blake3",
"geth-types", "geth-types",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",

View file

@ -97,6 +97,9 @@ The bootstrap implementation provides:
- `geth secret rotate <resource>` - `geth secret rotate <resource>`
- `geth secret bearer create <resource> --capability <capability>` - `geth secret bearer create <resource> --capability <capability>`
- `geth secret bearer list` - `geth secret bearer list`
- `geth secret bearer challenge <resource> --capability <capability>`
- `geth secret bearer prove <secret> <resource> --nonce <nonce> --capability <capability>`
- `geth secret bearer verify <secret> <resource> --nonce <nonce> --response <response> --capability <capability>`
- `geth secret bearer revoke <resource> <secret>` - `geth secret bearer revoke <resource> <secret>`
- `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>]`

View file

@ -209,6 +209,29 @@ pub enum SecretBearerCommand {
expires_at_ms: Option<i64>, expires_at_ms: Option<i64>,
}, },
List, List,
Challenge {
resource: String,
#[arg(long = "capability", required = true)]
capabilities: Vec<String>,
},
Prove {
secret: String,
resource: String,
#[arg(long)]
nonce: String,
#[arg(long = "capability", required = true)]
capabilities: Vec<String>,
},
Verify {
secret: String,
resource: String,
#[arg(long)]
nonce: String,
#[arg(long)]
response: String,
#[arg(long = "capability", required = true)]
capabilities: Vec<String>,
},
Revoke { Revoke {
resource: String, resource: String,
secret: String, secret: String,
@ -606,6 +629,37 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
expires_at_ms, expires_at_ms,
}, },
SecretBearerCommand::List => ControlRequest::SecretBearerList, 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 } => { SecretBearerCommand::Revoke { resource, secret } => {
ControlRequest::SecretBearerRevoke { 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::<Vec<_>>()
.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::<Vec<_>>()
.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 } => { ControlResponse::SecretBearerRevoked { resource, secret } => {
println!("revoked bearer secret: {secret}"); println!("revoked bearer secret: {secret}");
println!("resource: {resource}"); println!("resource: {resource}");

View file

@ -8,7 +8,7 @@ use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener}; use geth_pipe::{PipeConnection, PipeListener};
use geth_pubsub::PubsubMessage; use geth_pubsub::PubsubMessage;
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_secrets::{BearerAccess, ResourceMasterSecret}; use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret};
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry, SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
}; };
@ -115,6 +115,23 @@ pub enum ControlRequest {
expires_at_ms: Option<i64>, expires_at_ms: Option<i64>,
}, },
SecretBearerList, SecretBearerList,
SecretBearerChallenge {
resource: String,
capabilities: Vec<String>,
},
SecretBearerProve {
secret: String,
resource: String,
capabilities: Vec<String>,
nonce: String,
},
SecretBearerVerify {
secret: String,
resource: String,
capabilities: Vec<String>,
nonce: String,
response: String,
},
SecretBearerRevoke { SecretBearerRevoke {
resource: String, resource: String,
secret: String, secret: String,
@ -384,6 +401,19 @@ pub enum ControlResponse {
SecretBearerList { SecretBearerList {
access: Vec<BearerAccess>, access: Vec<BearerAccess>,
}, },
SecretBearerChallenge {
challenge: BearerChallenge,
},
SecretBearerProof {
proof: BearerProof,
},
SecretBearerVerified {
secret: String,
resource: String,
capabilities: Vec<String>,
verified: bool,
reason: String,
},
SecretBearerRevoked { SecretBearerRevoked {
resource: String, resource: String,
secret: String, secret: String,
@ -943,6 +973,30 @@ mod tests {
response 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 { let request = ControlRequest::CasFetch {
node: "node:peer".to_owned(), node: "node:peer".to_owned(),
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),

View file

@ -24,7 +24,7 @@ use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener}; use geth_pipe::{PipeConnection, PipeListener};
use geth_pubsub::PubsubMessage; use geth_pubsub::PubsubMessage;
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_secrets::{BearerAccess, ResourceMasterSecret}; use geth_secrets::{BearerAccess, BearerChallenge, BearerProof, ResourceMasterSecret};
use geth_ssh_identity::{ use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord, SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, build_ssh_cert_sign_command, SshRevocationEntry, SshRevocationExportFormat, SshRevocationKind, build_ssh_cert_sign_command,
@ -3011,6 +3011,114 @@ pub fn handle_request(
ControlRequest::SecretBearerList => Ok(ControlResponse::SecretBearerList { ControlRequest::SecretBearerList => Ok(ControlResponse::SecretBearerList {
access: load_bearer_access(&store)?, access: load_bearer_access(&store)?,
}), }),
ControlRequest::SecretBearerChallenge {
resource,
capabilities,
} => {
ensure_resource_exists(&store, &resource)?;
let capabilities = capabilities
.into_iter()
.map(Capability::new)
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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 } => { ControlRequest::SecretBearerRevoke { resource, secret } => {
ensure_resource_exists(&store, &resource)?; ensure_resource_exists(&store, &resource)?;
let created_at = UnixMillis(geth_store::now_ms()); let created_at = UnixMillis(geth_store::now_ms());

View file

@ -6,6 +6,7 @@ rust-version.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
blake3.workspace = true
serde.workspace = true serde.workspace = true
thiserror.workspace = true thiserror.workspace = true
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }

View file

@ -29,6 +29,23 @@ pub struct BearerAccess {
pub may_delegate: bool, pub may_delegate: bool,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerChallenge {
pub resource: ResourceId,
pub capabilities: Vec<Capability>,
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<Capability>,
pub nonce: String,
pub response: String,
}
impl BearerAccess { impl BearerAccess {
#[must_use] #[must_use]
pub fn resource_scoped( pub fn resource_scoped(
@ -76,6 +93,43 @@ pub fn validate_bearer_capabilities(capabilities: &[Capability]) -> Result<(), S
Ok(()) 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::<Vec<_>>();
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -96,4 +150,35 @@ mod tests {
Err(SecretsError::ForbiddenBearerCapability(_)) 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
));
}
} }

View file

@ -1224,7 +1224,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
geth_control::ControlRequest::SecretBearerCreate { geth_control::ControlRequest::SecretBearerCreate {
resource: "resource:cas:local".to_owned(), resource: "resource:cas:local".to_owned(),
capabilities: vec!["cas.fetch".to_owned(), "cas.pin".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"); .expect("create bearer");
@ -1232,7 +1232,10 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
geth_control::ControlResponse::SecretBearerCreated { access } => { geth_control::ControlResponse::SecretBearerCreated { access } => {
assert_eq!(access.resource.to_string(), "resource:cas:local"); assert_eq!(access.resource.to_string(), "resource:cas:local");
assert_eq!(access.capabilities.len(), 2); 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); assert!(!access.may_delegate);
access.secret.to_string() access.secret.to_string()
} }
@ -1250,6 +1253,79 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
other => panic!("unexpected response: {other:?}"), 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( geth_node::handle_request(
&node, &node,
geth_control::ControlRequest::SecretBearerRevoke { geth_control::ControlRequest::SecretBearerRevoke {

View file

@ -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 modeled initially as secret epoch rotation. The daemon persists resource secret
epoch metadata through `secret create/rotate/status`. Bearer access is recorded epoch metadata through `secret create/rotate/status`. Bearer access is recorded
as resource-scoped auth operations and rejects trust-mutation capabilities such as resource-scoped auth operations and rejects trust-mutation capabilities such
as `auth.delegate`, `auth.revoke`, and `node.enroll`. The daemon does not yet as `auth.delegate`, `auth.revoke`, and `node.enroll`. Bearer challenge/proof
store payload key material, encrypt resource data, or distribute key envelopes. 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 ## Multi-User Direction

View file

@ -220,8 +220,11 @@ resource-scoped capability decisions.
- `[x]` Bearer secrets grant only resource-scoped capabilities. - `[x]` Bearer secrets grant only resource-scoped capabilities.
- `[x]` Bearer principals cannot mutate trust graph state by default. - `[x]` Bearer principals cannot mutate trust graph state by default.
- `[x]` Tests verify bearer access does not imply node identity. - `[x]` Tests verify bearer access does not imply node identity.
- `[ ]` Future completion requires bearer challenge-response proof instead - `[x]` `geth secret bearer challenge/prove/verify` exercises
of metadata-only local records. 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. - `[~]` SSH certificate and revocation lifecycle.
Acceptance criteria: Acceptance criteria: