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