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, } #[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, pub may_delegate: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BearerChallenge { pub resource: ResourceId, pub capabilities: Vec, 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, pub nonce: String, pub response: String, } impl BearerAccess { #[must_use] pub fn resource_scoped( secret: SecretId, resource: ResourceId, capabilities: Vec, ) -> Self { Self { secret, token: None, token_hash: None, 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])) } #[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::>(); 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 { 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, 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(_)) )); } #[test] fn bearer_response_is_resource_scoped_and_capability_scoped() { let secret = SecretId::new("gbt_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 )); } #[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 )); } }