Skip to main content

hessra_context_token/
verify.rs

1extern crate biscuit_auth as biscuit;
2
3use biscuit::Biscuit;
4use biscuit::macros::authorizer;
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/// accumulates a Datalog deny rule scoped with `trusting authority, {pubkey}`,
12/// so authorization fails if the token carries any of the excluded exposure
13/// labels attested by the issuer.
14///
15/// # Example
16/// ```rust
17/// use hessra_context_token::{HessraContext, ContextVerifier, add_exposure};
18/// use hessra_token_core::{KeyPair, TokenTimeConfig};
19///
20/// let keypair = KeyPair::new();
21/// let public_key = keypair.public();
22///
23/// let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
24///     .issue(&keypair)
25///     .expect("Failed to mint context token");
26///
27/// let exposed = add_exposure(
28///     &token,
29///     &keypair,
30///     &["PII:email".to_string()],
31///     "data:user-profile".to_string(),
32/// ).expect("Failed to add exposure");
33///
34/// // Passes -- "PII:SSN" is not attested.
35/// ContextVerifier::new(exposed.clone(), public_key)
36///     .excludes("PII:SSN")
37///     .verify()
38///     .expect("clean of PII:SSN");
39///
40/// // Fails -- "PII:email" is attested.
41/// assert!(ContextVerifier::new(exposed, public_key)
42///     .excludes("PII:email")
43///     .verify()
44///     .is_err());
45/// ```
46pub struct ContextVerifier {
47    token: String,
48    public_key: PublicKey,
49    excludes: Vec<String>,
50}
51
52impl ContextVerifier {
53    /// Creates a new context verifier.
54    pub fn new(token: String, public_key: PublicKey) -> Self {
55        Self {
56            token,
57            public_key,
58            excludes: Vec::new(),
59        }
60    }
61
62    /// Add an exposure label that must NOT be present in the token.
63    ///
64    /// Chainable. Each call accumulates one deny rule; any match blocks
65    /// the grant (OR semantics across all excluded labels).
66    pub fn excludes(mut self, label: impl Into<String>) -> Self {
67        self.excludes.push(label.into());
68        self
69    }
70
71    /// Run authorization.
72    ///
73    /// Always enforces signature + expiration. If any `.excludes(...)` labels
74    /// were registered, each is checked via a Datalog deny rule scoped to
75    /// `trusting authority, {pubkey}` so only issuer-attested facts can
76    /// trigger the deny.
77    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        // Check each excluded label with its own authorize() pass. Each pass
100        // uses the macro form for compile-time substitution of {now}, {pk},
101        // and {label}. Any deny match aborts with an error tagged by label.
102        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    /// Sketch parity: verify the three-exposure scenario from
343    /// `/Users/jakev/code/playground/context-token/src/main.rs`.
344    #[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    /// Security-critical: exposure facts attested by a *different* keypair
395    /// must NOT trigger the issuer-scoped deny rule. The `trusting authority,
396    /// {issuer_pubkey}` scope is what enforces this.
397    #[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        // Try to use the attacker's keypair to attest a third-party block.
409        // This will sign the block with the attacker's key but append it under
410        // the attacker's public key, NOT the issuer's. The `trusting authority,
411        // {issuer_pubkey}` scope on the deny rule should ignore it.
412        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        // The deny rule is scoped to the issuer's pubkey; the attacker-attested
424        // PII:SSN fact must NOT trigger it.
425        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}