Skip to main content

hessra_context_token/
verify.rs

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
8/// Verifier for context tokens with a fluent builder for exclusion checks.
9///
10/// `.verify()` always enforces the signature + expiration. Each `.excludes(...)`
11/// asserts a candidate `exposure({label})` fact into the authorizer; if the
12/// token carries a matching `reject if exposure({label})` rule (added by any
13/// signer, in any block), that reject fires and authorization fails. No
14/// `trusting` scope is involved -- reject rules apply to the whole token.
15///
16/// # Example
17/// ```rust
18/// use hessra_context_token::{HessraContext, ContextVerifier, add_exposure};
19/// use hessra_token_core::{KeyPair, TokenTimeConfig};
20///
21/// let keypair = KeyPair::new();
22/// let public_key = keypair.public();
23///
24/// let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
25///     .issue(&keypair)
26///     .expect("Failed to mint context token");
27///
28/// let exposed = add_exposure(
29///     &token,
30///     &keypair,
31///     &["PII:email".to_string()],
32///     "data:user-profile".to_string(),
33/// ).expect("Failed to add exposure");
34///
35/// // Passes -- "PII:SSN" is not attested.
36/// ContextVerifier::new(exposed.clone(), public_key)
37///     .excludes("PII:SSN")
38///     .verify()
39///     .expect("clean of PII:SSN");
40///
41/// // Fails -- "PII:email" is attested.
42/// assert!(ContextVerifier::new(exposed, public_key)
43///     .excludes("PII:email")
44///     .verify()
45///     .is_err());
46/// ```
47pub struct ContextVerifier {
48    token: String,
49    public_key: PublicKey,
50    excludes: Vec<String>,
51}
52
53impl ContextVerifier {
54    /// Creates a new context verifier.
55    pub fn new(token: String, public_key: PublicKey) -> Self {
56        Self {
57            token,
58            public_key,
59            excludes: Vec::new(),
60        }
61    }
62
63    /// Add an exposure label that must NOT be present in the token.
64    ///
65    /// Chainable. Each call asserts one candidate `exposure({label})` fact; if
66    /// the token rejects any of them, the grant is blocked (OR semantics across
67    /// all excluded labels).
68    pub fn excludes(mut self, label: impl Into<String>) -> Self {
69        self.excludes.push(label.into());
70        self
71    }
72
73    /// Run authorization.
74    ///
75    /// Always enforces signature + expiration. Each `.excludes(...)` label is
76    /// asserted as an `exposure({label})` fact in a single authorizer pass; a
77    /// matching `reject if exposure({label})` rule anywhere in the token fires
78    /// the reject and fails verification.
79    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        // Assert the candidate exposure facts for every excluded label in one
84        // pass. A token reject rule for any of them aborts authorization; the
85        // empty case collapses to a plain signature + expiration check.
86        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    /// Sketch parity: verify the three-exposure scenario from
326    /// `/Users/jakev/code/playground/context-token/src/main.rs`.
327    #[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    /// By design under reject-if: a `reject if exposure(X)` appended by *any*
378    /// signer (here a throwaway/ephemeral keypair) is honored. Rejects are
379    /// monotonic -- they can only make a token more restrictive -- so no
380    /// `trusting` scope is needed and any party may tighten the token.
381    #[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        // Append a reject rule signed by an unrelated ephemeral keypair.
393        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        // The reject applies even though it was not signed by the issuer.
405        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        // ...but only for the label it names.
414        ContextVerifier::new(tightened_token, issuer_pubkey)
415            .excludes("other")
416            .verify()
417            .expect("unrelated excludes must still pass");
418    }
419
420    /// An attacker-appended `exposure(X)` *fact* (not a reject) is inert: reject
421    /// rules in other blocks do not see sibling-block facts, and enumeration
422    /// reads `exposed_label`, not `exposure`. So it cannot cause a spurious
423    /// failure nor leak into the reported labels.
424    #[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        // Attacker grafts a bare exposure(...) fact in a third-party block.
445        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        // The injected fact does not trip an unrelated excludes check.
457        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        // And it does not appear in the enumerated labels.
463        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}