Separate bearer ids from private tokens

This commit is contained in:
Eric Wendland 2026-05-20 13:10:34 +02:00
commit 460acab67b
9 changed files with 198 additions and 62 deletions

View file

@ -48,6 +48,7 @@ pub enum AuthOpKind {
},
BearerAccessCreate {
secret: SecretId,
token_hash: Option<String>,
capabilities: Vec<Capability>,
expires_at: Option<UnixMillis>,
},
@ -99,6 +100,7 @@ pub struct GroupRecord {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerAccessRecord {
pub secret: SecretId,
pub token_hash: Option<String>,
pub resource: ResourceId,
pub capabilities: Vec<Capability>,
pub expires_at: Option<UnixMillis>,
@ -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)),
},

View file

@ -215,7 +215,7 @@ pub enum SecretBearerCommand {
capabilities: Vec<String>,
},
Prove {
secret: String,
token: String,
resource: String,
#[arg(long)]
nonce: String,
@ -223,7 +223,7 @@ pub enum SecretBearerCommand {
capabilities: Vec<String>,
},
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<ControlRequest> {
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: {}",

View file

@ -1724,11 +1724,12 @@ fn bearer_proof(
capability: &str,
nonce: &str,
) -> Option<BearerProof> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<Vec<BearerAccess>, 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(

View file

@ -23,6 +23,8 @@ pub struct ResourceKeyEnvelope {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerAccess {
pub secret: SecretId,
pub token: Option<SecretId>,
pub token_hash: Option<String>,
pub resource: ResourceId,
pub capabilities: Vec<Capability>,
pub expires_at: Option<UnixMillis>,
@ -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<String> {
let hash_bytes = hex_to_32_bytes(token_hash)?;
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);
}
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
));
}
}

View file

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