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

@ -209,6 +209,29 @@ pub enum SecretBearerCommand {
expires_at_ms: Option<i64>,
},
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 {
resource: String,
secret: String,
@ -606,6 +629,37 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
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::<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 } => {
println!("revoked bearer secret: {secret}");
println!("resource: {resource}");

View file

@ -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<i64>,
},
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 {
resource: String,
secret: String,
@ -384,6 +401,19 @@ pub enum ControlResponse {
SecretBearerList {
access: Vec<BearerAccess>,
},
SecretBearerChallenge {
challenge: BearerChallenge,
},
SecretBearerProof {
proof: BearerProof,
},
SecretBearerVerified {
secret: String,
resource: String,
capabilities: Vec<String>,
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(),

View file

@ -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::<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 } => {
ensure_resource_exists(&store, &resource)?;
let created_at = UnixMillis(geth_store::now_ms());

View file

@ -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" }

View file

@ -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<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 {
#[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::<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)]
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
));
}
}

View file

@ -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 {