1extern crate biscuit_auth as biscuit;
2
3use biscuit::Biscuit;
4use biscuit::macros::{authorizer, authorizer_merge};
5use chrono::Utc;
6use hessra_token_core::{PublicKey, TokenError};
7
8pub struct ContextVerifier {
48 token: String,
49 public_key: PublicKey,
50 excludes: Vec<String>,
51}
52
53impl ContextVerifier {
54 pub fn new(token: String, public_key: PublicKey) -> Self {
56 Self {
57 token,
58 public_key,
59 excludes: Vec::new(),
60 }
61 }
62
63 pub fn excludes(mut self, label: impl Into<String>) -> Self {
69 self.excludes.push(label.into());
70 self
71 }
72
73 pub fn verify(self) -> Result<(), TokenError> {
80 let biscuit = Biscuit::from_base64(&self.token, self.public_key)?;
81 let now = Utc::now().timestamp();
82
83 let mut authz = authorizer!(
87 r#"
88 time({now});
89 allow if true;
90 "#
91 );
92
93 for excluded in &self.excludes {
94 let label = excluded.clone();
95 authz = authorizer_merge!(authz, r#"exposure({label});"#);
96 }
97
98 authz
99 .build(&biscuit)
100 .map_err(|e| TokenError::internal(format!("failed to build authorizer: {e}")))?
101 .authorize()
102 .map_err(TokenError::from)?;
103
104 Ok(())
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use crate::exposure::add_exposure;
112 use crate::mint::HessraContext;
113 use hessra_token_core::{KeyPair, TokenTimeConfig};
114
115 #[test]
116 fn test_verify_valid_token() {
117 let keypair = KeyPair::new();
118 let public_key = keypair.public();
119
120 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
121 .issue(&keypair)
122 .expect("Failed to create context token");
123
124 ContextVerifier::new(token, public_key)
125 .verify()
126 .expect("Should verify valid token");
127 }
128
129 #[test]
130 fn test_verify_expired_token() {
131 let keypair = KeyPair::new();
132 let public_key = keypair.public();
133
134 let expired_config = TokenTimeConfig {
135 start_time: Some(0),
136 duration: 1,
137 };
138
139 let token = HessraContext::new("agent:test".to_string(), expired_config)
140 .issue(&keypair)
141 .expect("Failed to create expired context token");
142
143 let result = ContextVerifier::new(token, public_key).verify();
144 assert!(result.is_err(), "Expired token should fail verification");
145 }
146
147 #[test]
148 fn test_verify_wrong_key() {
149 let keypair = KeyPair::new();
150 let wrong_keypair = KeyPair::new();
151 let wrong_public_key = wrong_keypair.public();
152
153 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
154 .issue(&keypair)
155 .expect("Failed to create context token");
156
157 let result = ContextVerifier::new(token, wrong_public_key).verify();
158 assert!(result.is_err(), "Token verified with wrong key should fail");
159 }
160
161 #[test]
162 fn test_verify_exposed_token_no_excludes() {
163 let keypair = KeyPair::new();
164 let public_key = keypair.public();
165
166 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
167 .issue(&keypair)
168 .expect("Failed to create context token");
169
170 let exposed = add_exposure(
171 &token,
172 &keypair,
173 &["PII:SSN".to_string()],
174 "data:user-ssn".to_string(),
175 )
176 .expect("Failed to add exposure");
177
178 ContextVerifier::new(exposed, public_key)
179 .verify()
180 .expect("Exposed token should still verify when no excludes are set");
181 }
182
183 #[test]
184 fn test_excludes_matching_label_fails() {
185 let keypair = KeyPair::new();
186 let public_key = keypair.public();
187
188 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
189 .issue(&keypair)
190 .expect("Failed to create context token");
191
192 let exposed = add_exposure(
193 &token,
194 &keypair,
195 &["PII:SSN".to_string()],
196 "data:user-ssn".to_string(),
197 )
198 .expect("Failed to add exposure");
199
200 let result = ContextVerifier::new(exposed, public_key)
201 .excludes("PII:SSN")
202 .verify();
203
204 assert!(
205 result.is_err(),
206 "Should deny when an excluded label is attested"
207 );
208 }
209
210 #[test]
211 fn test_excludes_non_matching_label_passes() {
212 let keypair = KeyPair::new();
213 let public_key = keypair.public();
214
215 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
216 .issue(&keypair)
217 .expect("Failed to create context token");
218
219 let exposed = add_exposure(
220 &token,
221 &keypair,
222 &["PII:email".to_string()],
223 "data:user-profile".to_string(),
224 )
225 .expect("Failed to add exposure");
226
227 ContextVerifier::new(exposed, public_key)
228 .excludes("PII:SSN")
229 .verify()
230 .expect("Should allow when no excluded label is attested");
231 }
232
233 #[test]
234 fn test_excludes_chained_any_match_fails() {
235 let keypair = KeyPair::new();
236 let public_key = keypair.public();
237
238 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
239 .issue(&keypair)
240 .expect("Failed to create context token");
241
242 let exposed = add_exposure(
243 &token,
244 &keypair,
245 &["PII:email".to_string()],
246 "data:user-profile".to_string(),
247 )
248 .expect("Failed to add exposure");
249
250 let result = ContextVerifier::new(exposed, public_key)
251 .excludes("PII:SSN")
252 .excludes("PII:email")
253 .excludes("PII:dob")
254 .verify();
255
256 assert!(
257 result.is_err(),
258 "Should deny when any chained exclude matches an attested label"
259 );
260 }
261
262 #[test]
263 fn test_excludes_chained_none_match_passes() {
264 let keypair = KeyPair::new();
265 let public_key = keypair.public();
266
267 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
268 .issue(&keypair)
269 .expect("Failed to create context token");
270
271 let exposed = add_exposure(
272 &token,
273 &keypair,
274 &["PII:email".to_string()],
275 "data:user-profile".to_string(),
276 )
277 .expect("Failed to add exposure");
278
279 ContextVerifier::new(exposed, public_key)
280 .excludes("PII:SSN")
281 .excludes("PII:dob")
282 .verify()
283 .expect("Should pass when none of the chained excludes match");
284 }
285
286 #[test]
287 fn test_excludes_clean_token_passes() {
288 let keypair = KeyPair::new();
289 let public_key = keypair.public();
290
291 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
292 .issue(&keypair)
293 .expect("Failed to create context token");
294
295 ContextVerifier::new(token, public_key)
296 .excludes("PII:SSN")
297 .verify()
298 .expect("Clean token should pass any excludes check");
299 }
300
301 #[test]
302 fn test_excludes_expired_token_fails() {
303 let keypair = KeyPair::new();
304 let public_key = keypair.public();
305
306 let expired_config = TokenTimeConfig {
307 start_time: Some(0),
308 duration: 1,
309 };
310
311 let token = HessraContext::new("agent:test".to_string(), expired_config)
312 .issue(&keypair)
313 .expect("Failed to create expired context token");
314
315 let result = ContextVerifier::new(token, public_key)
316 .excludes("PII:SSN")
317 .verify();
318
319 assert!(
320 result.is_err(),
321 "Expired token should fail even with non-matching excludes"
322 );
323 }
324
325 #[test]
328 fn test_sketch_parity_three_exposures() {
329 let keypair = KeyPair::new();
330 let public_key = keypair.public();
331
332 let token = HessraContext::new("agent:sketch".to_string(), TokenTimeConfig::default())
333 .with_initial_exposures(&["exposure1".to_string()], "source1")
334 .issue(&keypair)
335 .expect("Failed to mint");
336
337 let token = add_exposure(
338 &token,
339 &keypair,
340 &["exposure2".to_string()],
341 "source2".to_string(),
342 )
343 .unwrap();
344
345 let token = add_exposure(
346 &token,
347 &keypair,
348 &["exposure3".to_string()],
349 "source3".to_string(),
350 )
351 .unwrap();
352
353 assert!(
354 ContextVerifier::new(token.clone(), public_key)
355 .excludes("exposure1")
356 .verify()
357 .is_err()
358 );
359 assert!(
360 ContextVerifier::new(token.clone(), public_key)
361 .excludes("exposure2")
362 .verify()
363 .is_err()
364 );
365 assert!(
366 ContextVerifier::new(token.clone(), public_key)
367 .excludes("exposure3")
368 .verify()
369 .is_err()
370 );
371 ContextVerifier::new(token, public_key)
372 .excludes("exposure4")
373 .verify()
374 .expect("exposure4 is absent");
375 }
376
377 #[test]
382 fn test_third_party_reject_from_ephemeral_key_applies() {
383 let issuer = KeyPair::new();
384 let issuer_pubkey = issuer.public();
385
386 let ephemeral = KeyPair::new();
387
388 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
389 .issue(&issuer)
390 .expect("Failed to mint");
391
392 let biscuit = Biscuit::from_base64(&token, issuer_pubkey).unwrap();
394 let third_party_request = biscuit.third_party_request().unwrap();
395 let reject_block = biscuit::macros::block!(r#"reject if exposure("blocked");"#);
396 let signed = third_party_request
397 .create_block(&ephemeral.private(), reject_block)
398 .unwrap();
399 let tightened = biscuit
400 .append_third_party(ephemeral.public(), signed)
401 .unwrap();
402 let tightened_token = tightened.to_base64().unwrap();
403
404 let result = ContextVerifier::new(tightened_token.clone(), issuer_pubkey)
406 .excludes("blocked")
407 .verify();
408 assert!(
409 result.is_err(),
410 "a reject appended by any signer must be honored"
411 );
412
413 ContextVerifier::new(tightened_token, issuer_pubkey)
415 .excludes("other")
416 .verify()
417 .expect("unrelated excludes must still pass");
418 }
419
420 #[test]
425 fn test_injected_exposure_fact_is_inert() {
426 use crate::exposure::extract_exposure_labels;
427
428 let issuer = KeyPair::new();
429 let issuer_pubkey = issuer.public();
430 let attacker = KeyPair::new();
431
432 let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
433 .issue(&issuer)
434 .expect("Failed to mint");
435
436 let exposed = add_exposure(
437 &token,
438 &issuer,
439 &["PII:email".to_string()],
440 "data:user-profile".to_string(),
441 )
442 .expect("Failed to add exposure");
443
444 let biscuit = Biscuit::from_base64(&exposed, issuer_pubkey).unwrap();
446 let third_party_request = biscuit.third_party_request().unwrap();
447 let attacker_block = biscuit::macros::block!(r#"exposure("PII:SSN");"#);
448 let signed = third_party_request
449 .create_block(&attacker.private(), attacker_block)
450 .unwrap();
451 let tampered = biscuit
452 .append_third_party(attacker.public(), signed)
453 .unwrap();
454 let tampered_token = tampered.to_base64().unwrap();
455
456 ContextVerifier::new(tampered_token.clone(), issuer_pubkey)
458 .excludes("PII:dob")
459 .verify()
460 .expect("a grafted exposure fact must not cause spurious failure");
461
462 let labels = extract_exposure_labels(&tampered_token, issuer_pubkey)
464 .expect("Failed to extract labels");
465 assert_eq!(labels, vec!["PII:email".to_string()]);
466 }
467}