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.
9///
10/// Checks that the context token is valid (not expired, properly signed).
11///
12/// # Example
13/// ```rust
14/// use hessra_context_token::{HessraContext, ContextVerifier};
15/// use hessra_token_core::{KeyPair, TokenTimeConfig};
16///
17/// let keypair = KeyPair::new();
18/// let public_key = keypair.public();
19///
20/// let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
21///     .issue(&keypair)
22///     .expect("Failed to create context token");
23///
24/// ContextVerifier::new(token, public_key)
25///     .verify()
26///     .expect("Should verify");
27/// ```
28pub struct ContextVerifier {
29    token: String,
30    public_key: PublicKey,
31}
32
33impl ContextVerifier {
34    /// Creates a new context verifier.
35    ///
36    /// # Arguments
37    /// * `token` - The base64-encoded context token to verify
38    /// * `public_key` - The public key used to verify the token signature
39    pub fn new(token: String, public_key: PublicKey) -> Self {
40        Self { token, public_key }
41    }
42
43    /// Verify the context token.
44    ///
45    /// Checks that:
46    /// - The token signature is valid
47    /// - The token has not expired
48    ///
49    /// # Returns
50    /// * `Ok(())` - If the token is valid
51    /// * `Err(TokenError)` - If verification fails
52    pub fn verify(self) -> Result<(), TokenError> {
53        let biscuit = Biscuit::from_base64(&self.token, self.public_key)?;
54        let now = Utc::now().timestamp();
55
56        let authz = authorizer!(
57            r#"
58                time({now});
59                allow if true;
60            "#
61        );
62
63        authz
64            .build(&biscuit)
65            .map_err(|e| TokenError::internal(format!("failed to build authorizer: {e}")))?
66            .authorize()
67            .map_err(TokenError::from)?;
68
69        Ok(())
70    }
71
72    /// Check that the context token does not contain any precluded exposure labels.
73    ///
74    /// Verifies the token (signature + expiration) and then checks that none of the
75    /// token's `exposure(...)` facts match the precluded labels. Any match causes
76    /// the method to return an error.
77    ///
78    /// This is the authorization-grade check. For diagnostics, use
79    /// `extract_exposure_labels` or `inspect_context_token` instead.
80    ///
81    /// # Implementation Note
82    ///
83    /// Exposure facts live in first-party appended blocks. In biscuit-auth v6,
84    /// authorizer deny-policies cannot see these facts due to block scoping
85    /// (`Scope::Previous` is a no-op for the authorizer). Instead, we verify
86    /// the token cryptographically first, then inspect the verified block data
87    /// for precluded labels. The authorization decision is fully encapsulated --
88    /// callers never handle raw labels.
89    ///
90    /// # Arguments
91    /// * `precluded` - Labels that must NOT be present in the token's exposure facts.
92    ///   Any match blocks the grant (OR semantics).
93    ///
94    /// # Returns
95    /// * `Ok(())` - If no precluded labels are found (or precluded list is empty)
96    /// * `Err(TokenError)` - If the token contains any precluded exposure label,
97    ///   or if the token is invalid/expired
98    pub fn check_precluded_exposures(self, precluded: &[String]) -> Result<(), TokenError> {
99        let biscuit = Biscuit::from_base64(&self.token, self.public_key)?;
100        let now = Utc::now().timestamp();
101
102        // Verify signature and expiration
103        let authz = authorizer!(
104            r#"
105                time({now});
106                allow if true;
107            "#
108        );
109
110        authz
111            .build(&biscuit)
112            .map_err(|e| TokenError::internal(format!("failed to build authorizer: {e}")))?
113            .authorize()
114            .map_err(TokenError::from)?;
115
116        if precluded.is_empty() {
117            return Ok(());
118        }
119
120        // Check verified block data for precluded exposure labels
121        let block_count = biscuit.block_count();
122        for i in 0..block_count {
123            let block_source = biscuit.print_block_source(i).unwrap_or_default();
124            for line in block_source.lines() {
125                let trimmed = line.trim();
126                if let Some(rest) = trimmed.strip_prefix("exposure(") {
127                    if let Some(label_str) = rest.strip_suffix(");") {
128                        let label = label_str.trim_matches('"');
129                        if precluded.iter().any(|p| p == label) {
130                            return Err(TokenError::internal(format!(
131                                "precluded exposure label present: {label}"
132                            )));
133                        }
134                    }
135                }
136            }
137        }
138
139        Ok(())
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::mint::HessraContext;
147    use hessra_token_core::{KeyPair, TokenTimeConfig};
148
149    #[test]
150    fn test_verify_valid_token() {
151        let keypair = KeyPair::new();
152        let public_key = keypair.public();
153
154        let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
155            .issue(&keypair)
156            .expect("Failed to create context token");
157
158        ContextVerifier::new(token, public_key)
159            .verify()
160            .expect("Should verify valid token");
161    }
162
163    #[test]
164    fn test_verify_expired_token() {
165        let keypair = KeyPair::new();
166        let public_key = keypair.public();
167
168        let expired_config = TokenTimeConfig {
169            start_time: Some(0),
170            duration: 1,
171        };
172
173        let token = HessraContext::new("agent:test".to_string(), expired_config)
174            .issue(&keypair)
175            .expect("Failed to create expired context token");
176
177        let result = ContextVerifier::new(token, public_key).verify();
178        assert!(result.is_err(), "Expired token should fail verification");
179    }
180
181    #[test]
182    fn test_verify_wrong_key() {
183        let keypair = KeyPair::new();
184        let wrong_keypair = KeyPair::new();
185        let wrong_public_key = wrong_keypair.public();
186
187        let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
188            .issue(&keypair)
189            .expect("Failed to create context token");
190
191        let result = ContextVerifier::new(token, wrong_public_key).verify();
192        assert!(result.is_err(), "Token verified with wrong key should fail");
193    }
194
195    #[test]
196    fn test_verify_exposed_token() {
197        let keypair = KeyPair::new();
198        let public_key = keypair.public();
199
200        let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
201            .issue(&keypair)
202            .expect("Failed to create context token");
203
204        // Add exposure -- token should still verify
205        let exposed = crate::exposure::add_exposure(
206            &token,
207            public_key,
208            &["PII:SSN".to_string()],
209            "data:user-ssn".to_string(),
210        )
211        .expect("Failed to add exposure");
212
213        ContextVerifier::new(exposed, public_key)
214            .verify()
215            .expect("Exposed token should still verify");
216    }
217
218    #[test]
219    fn test_check_precluded_exposures_blocks_matching() {
220        let keypair = KeyPair::new();
221        let public_key = keypair.public();
222
223        let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
224            .issue(&keypair)
225            .expect("Failed to create context token");
226
227        let exposed = crate::exposure::add_exposure(
228            &token,
229            public_key,
230            &["PII:SSN".to_string()],
231            "data:user-ssn".to_string(),
232        )
233        .expect("Failed to add exposure");
234
235        let result = ContextVerifier::new(exposed, public_key)
236            .check_precluded_exposures(&["PII:SSN".to_string()]);
237
238        assert!(
239            result.is_err(),
240            "Should deny when precluded label is present"
241        );
242    }
243
244    #[test]
245    fn test_check_precluded_exposures_allows_non_matching() {
246        let keypair = KeyPair::new();
247        let public_key = keypair.public();
248
249        let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
250            .issue(&keypair)
251            .expect("Failed to create context token");
252
253        let exposed = crate::exposure::add_exposure(
254            &token,
255            public_key,
256            &["PII:email".to_string()],
257            "data:user-profile".to_string(),
258        )
259        .expect("Failed to add exposure");
260
261        ContextVerifier::new(exposed, public_key)
262            .check_precluded_exposures(&["PII:SSN".to_string()])
263            .expect("Should allow when precluded label is not present");
264    }
265
266    #[test]
267    fn test_check_precluded_exposures_empty_list() {
268        let keypair = KeyPair::new();
269        let public_key = keypair.public();
270
271        let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
272            .issue(&keypair)
273            .expect("Failed to create context token");
274
275        let exposed = crate::exposure::add_exposure(
276            &token,
277            public_key,
278            &["PII:SSN".to_string()],
279            "data:user-ssn".to_string(),
280        )
281        .expect("Failed to add exposure");
282
283        ContextVerifier::new(exposed, public_key)
284            .check_precluded_exposures(&[])
285            .expect("Empty precluded list should pass");
286    }
287
288    #[test]
289    fn test_check_precluded_exposures_expired_token_fails() {
290        let keypair = KeyPair::new();
291        let public_key = keypair.public();
292
293        let expired_config = TokenTimeConfig {
294            start_time: Some(0),
295            duration: 1,
296        };
297
298        let token = HessraContext::new("agent:test".to_string(), expired_config)
299            .issue(&keypair)
300            .expect("Failed to create expired context token");
301
302        let result = ContextVerifier::new(token, public_key)
303            .check_precluded_exposures(&["PII:SSN".to_string()]);
304
305        assert!(
306            result.is_err(),
307            "Expired token should fail even with non-matching precluded labels"
308        );
309    }
310
311    #[test]
312    fn test_check_precluded_exposures_clean_token_passes() {
313        let keypair = KeyPair::new();
314        let public_key = keypair.public();
315
316        let token = HessraContext::new("agent:test".to_string(), TokenTimeConfig::default())
317            .issue(&keypair)
318            .expect("Failed to create context token");
319
320        ContextVerifier::new(token, public_key)
321            .check_precluded_exposures(&["PII:SSN".to_string()])
322            .expect("Clean token should pass any precluded check");
323    }
324}