geth/crates/geth-secrets/src/lib.rs

277 lines
7.7 KiB
Rust
Raw Normal View History

2026-05-15 15:08:20 +02:00
use geth_types::{Capability, PrincipalId, ResourceId, SecretId, UnixMillis};
use serde::{Deserialize, Serialize};
pub const RESOURCE_SECRET_SIGNATURE_NAMESPACE: &str = "geth.resource-secret.v1@geth.local";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceMasterSecret {
pub id: SecretId,
pub resource: ResourceId,
pub epoch: u64,
pub created_at: UnixMillis,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceKeyEnvelope {
pub secret: SecretId,
pub recipient: PrincipalId,
pub epoch: u64,
pub algorithm: String,
pub ciphertext: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerAccess {
pub secret: SecretId,
pub token: Option<SecretId>,
pub token_hash: Option<String>,
2026-05-15 15:08:20 +02:00
pub resource: ResourceId,
pub capabilities: Vec<Capability>,
pub expires_at: Option<UnixMillis>,
pub may_delegate: bool,
}
2026-05-19 19:08:08 +02:00
#[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,
}
2026-05-15 15:08:20 +02:00
impl BearerAccess {
#[must_use]
pub fn resource_scoped(
secret: SecretId,
resource: ResourceId,
capabilities: Vec<Capability>,
) -> Self {
Self {
secret,
token: None,
token_hash: None,
2026-05-15 15:08:20 +02:00
resource,
capabilities,
expires_at: None,
may_delegate: false,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SecretsError {
#[error("bearer access must grant at least one capability")]
EmptyBearerCapabilities,
#[error("capability is not allowed for bearer access: {0}")]
ForbiddenBearerCapability(String),
}
pub fn validate_bearer_capabilities(capabilities: &[Capability]) -> Result<(), SecretsError> {
if capabilities.is_empty() {
return Err(SecretsError::EmptyBearerCapabilities);
}
for capability in capabilities {
let capability = capability.as_str();
if matches!(
capability,
"auth.delegate"
| "auth.revoke"
| "trust.modify"
| "ssh_proxy.admin_shell"
| "node.enroll"
) {
return Err(SecretsError::ForbiddenBearerCapability(
capability.to_owned(),
));
}
}
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]))
}
2026-05-19 19:08:08 +02:00
#[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 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(),
)
}
2026-05-19 19:08:08 +02:00
#[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
}
#[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::*;
#[test]
fn bearer_capabilities_reject_trust_mutation() {
assert!(validate_bearer_capabilities(&["kv.read".into(), "kv.write".into()]).is_ok());
assert!(matches!(
validate_bearer_capabilities(&[]),
Err(SecretsError::EmptyBearerCapabilities)
));
assert!(matches!(
validate_bearer_capabilities(&["auth.delegate".into()]),
Err(SecretsError::ForbiddenBearerCapability(_))
));
assert!(matches!(
validate_bearer_capabilities(&["node.enroll".into()]),
Err(SecretsError::ForbiddenBearerCapability(_))
));
}
2026-05-19 19:08:08 +02:00
#[test]
fn bearer_response_is_resource_scoped_and_capability_scoped() {
let secret = SecretId::new("gbt_test");
2026-05-19 19:08:08 +02:00
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
));
}
#[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
));
}
}