hessra_cap_token/verify.rs
1extern crate biscuit_auth as biscuit;
2
3use biscuit::Algorithm;
4use biscuit::macros::{authorizer, check, fact};
5use chrono::Utc;
6use hessra_token_core::{
7 Biscuit, PublicKey, TokenError, TokenTimeConfig, parse_capability_failure, parse_check_failure,
8};
9
10/// Builder for verifying Hessra capability tokens with flexible configuration.
11///
12/// By default, capability verification only checks resource + operation.
13/// Subject verification is optional via `.with_subject()`.
14///
15/// # Example
16/// ```no_run
17/// use hessra_cap_token::{CapabilityVerifier, HessraCapability};
18/// use hessra_token_core::KeyPair;
19///
20/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
21/// let keypair = KeyPair::new();
22/// let public_key = keypair.public();
23/// let token = HessraCapability::new()
24/// .subject("user123")
25/// .resource("resource456")
26/// .operation("read")
27/// .issue(&keypair)?;
28///
29/// // Basic capability verification (no subject check)
30/// CapabilityVerifier::new(
31/// token.clone(),
32/// public_key,
33/// "resource456".to_string(),
34/// "read".to_string(),
35/// )
36/// .verify()?;
37///
38/// // With optional subject verification
39/// CapabilityVerifier::new(
40/// token.clone(),
41/// public_key,
42/// "resource456".to_string(),
43/// "read".to_string(),
44/// )
45/// .with_subject("user123".to_string())
46/// .verify()?;
47/// # Ok(())
48/// # }
49/// ```
50pub struct CapabilityVerifier {
51 token: String,
52 public_key: PublicKey,
53 resource: String,
54 operation: String,
55 subject: Option<String>,
56 designations: Vec<(String, String)>,
57 time_config: TokenTimeConfig,
58}
59
60impl CapabilityVerifier {
61 /// Creates a new capability verifier for a base64-encoded token.
62 ///
63 /// # Arguments
64 /// * `token` - The base64-encoded capability token to verify
65 /// * `public_key` - The public key used to verify the token signature
66 /// * `resource` - The resource identifier to verify
67 /// * `operation` - The operation to verify
68 pub fn new(token: String, public_key: PublicKey, resource: String, operation: String) -> Self {
69 Self {
70 token,
71 public_key,
72 resource,
73 operation,
74 subject: None,
75 designations: Vec::new(),
76 time_config: TokenTimeConfig::default(),
77 }
78 }
79
80 /// Adds an optional subject verification check.
81 ///
82 /// When set, the authorizer adds a check that the minted subject matches.
83 /// This is optional -- pure capability verification does not require it.
84 ///
85 /// # Arguments
86 /// * `subject` - The subject to verify in the token's right fact
87 pub fn with_subject(mut self, subject: String) -> Self {
88 self.subject = Some(subject);
89 self
90 }
91
92 /// Adds a designation fact to the verification.
93 ///
94 /// Each designation provides a `designation(label, value)` fact that the
95 /// token's designation checks will verify against.
96 ///
97 /// # Arguments
98 /// * `label` - The designation dimension (e.g., "tenant_id")
99 /// * `value` - The specific value (e.g., "t-123")
100 pub fn with_designation(mut self, label: String, value: String) -> Self {
101 self.designations.push((label, value));
102 self
103 }
104
105 /// Asserts this verifier's anchor identity. Sugar for
106 /// `with_designation("anchor", anchor)`: proves "I am `<anchor>`" to satisfy
107 /// an anchor-bound capability (see [`crate::HessraCapability::anchor`]).
108 pub fn anchor(self, anchor: impl Into<String>) -> Self {
109 self.with_designation("anchor".to_string(), anchor.into())
110 }
111
112 /// Supply a [`TokenTimeConfig`] to control verification time. Only
113 /// `start_time` is used: when set it overrides the "now" checked against the
114 /// token's expiry (the seam for deterministic tests). Without this call the
115 /// real wall clock applies.
116 pub fn with_time(mut self, time_config: TokenTimeConfig) -> Self {
117 self.time_config = time_config;
118 self
119 }
120
121 /// Performs the token verification with the configured parameters.
122 ///
123 /// # Returns
124 /// * `Ok(())` - If the token is valid and meets all verification requirements
125 /// * `Err(TokenError)` - If verification fails for any reason
126 pub fn verify(self) -> Result<(), TokenError> {
127 let biscuit = Biscuit::from_base64(&self.token, self.public_key)?;
128 let now = self
129 .time_config
130 .start_time
131 .unwrap_or_else(|| Utc::now().timestamp());
132 let resource = self.resource.clone();
133 let operation = self.operation.clone();
134
135 // Build the capability authorizer -- no subject fact needed
136 let mut authz = authorizer!(
137 r#"
138 time({now});
139 resource({resource});
140 operation({operation});
141 allow if true;
142 "#
143 );
144
145 // Optional: add subject check when caller wants to verify who minted the token
146 if let Some(ref subject) = self.subject {
147 let subject = subject.clone();
148 let resource = self.resource.clone();
149 let operation = self.operation.clone();
150 authz = authz.check(check!(
151 r#"check if right({subject}, {resource}, {operation});"#
152 ))?;
153 }
154
155 // Add designation facts
156 for (label, value) in &self.designations {
157 let label = label.clone();
158 let value = value.clone();
159 authz = authz.fact(fact!(r#"designation({label}, {value});"#))?;
160 }
161
162 // Biscuit's default datalog budget (1ms) is calibrated for native
163 // speed; under WebAssembly the same evaluation regularly exceeds it
164 // and verification fails spuriously. The capability chains verified
165 // here are small and self-authored, so a generous fixed budget keeps
166 // the DoS bound while working on every target.
167 let limits = biscuit::datalog::RunLimits {
168 max_time: std::time::Duration::from_millis(50),
169 ..Default::default()
170 };
171 match authz.build(&biscuit)?.authorize_with_limits(limits) {
172 Ok(_) => Ok(()),
173 Err(e) => Err(convert_capability_error(
174 e,
175 self.subject.as_deref(),
176 Some(&self.resource),
177 Some(&self.operation),
178 )),
179 }
180 }
181}
182
183/// Takes a public key encoded as a string in the format "ed25519/..." or "secp256r1/..."
184/// and returns a PublicKey.
185pub fn biscuit_key_from_string(key: String) -> Result<PublicKey, TokenError> {
186 let parts = key.split('/').collect::<Vec<&str>>();
187 if parts.len() != 2 {
188 return Err(TokenError::invalid_key_format(
189 "Key must be in format 'algorithm/hexkey'",
190 ));
191 }
192
193 let alg = match parts[0] {
194 "ed25519" => Algorithm::Ed25519,
195 "secp256r1" => Algorithm::Secp256r1,
196 _ => {
197 return Err(TokenError::invalid_key_format(
198 "Unsupported algorithm, must be ed25519 or secp256r1",
199 ));
200 }
201 };
202
203 let key_bytes = hex::decode(parts[1])?;
204
205 let key = PublicKey::from_bytes(&key_bytes, alg)
206 .map_err(|e| TokenError::invalid_key_format(e.to_string()))?;
207
208 Ok(key)
209}
210
211/// Convert biscuit authorization errors to detailed capability errors
212fn convert_capability_error(
213 err: biscuit::error::Token,
214 subject: Option<&str>,
215 resource: Option<&str>,
216 operation: Option<&str>,
217) -> TokenError {
218 use biscuit::error::{Logic, Token};
219
220 match err {
221 Token::FailedLogic(logic_err) => match &logic_err {
222 Logic::Unauthorized { checks, .. } | Logic::NoMatchingPolicy { checks } => {
223 for failed_check in checks.iter() {
224 let (block_id, check_id, rule) = match failed_check {
225 biscuit::error::FailedCheck::Block(block_check) => (
226 block_check.block_id,
227 block_check.check_id,
228 block_check.rule.clone(),
229 ),
230 biscuit::error::FailedCheck::Authorizer(auth_check) => {
231 (0, auth_check.check_id, auth_check.rule.clone())
232 }
233 };
234
235 let parsed_error = parse_check_failure(block_id, check_id, &rule);
236
237 if matches!(parsed_error, TokenError::Expired { .. }) {
238 return parsed_error;
239 }
240 }
241
242 // Check if this looks like a rights denial (no matching policy)
243 if matches!(logic_err, Logic::NoMatchingPolicy { .. }) {
244 return parse_capability_failure(
245 subject,
246 resource,
247 operation,
248 &format!("{checks:?}"),
249 );
250 }
251
252 TokenError::from(Token::FailedLogic(logic_err))
253 }
254 other => TokenError::from(Token::FailedLogic(other.clone())),
255 },
256 other => TokenError::from(other),
257 }
258}