Skip to main content

hessra_context_token/
inspect.rs

1extern crate biscuit_auth as biscuit;
2
3use biscuit::macros::authorizer;
4use chrono::Utc;
5use hessra_token_core::{Biscuit, PublicKey, TokenError};
6
7/// Result of inspecting a context token.
8#[derive(Debug, Clone)]
9pub struct ContextInspectResult {
10    /// The subject (session owner) in the context token.
11    pub subject: String,
12    /// The taint labels accumulated in the context token.
13    pub taint_labels: Vec<String>,
14    /// The taint sources that contributed taint labels.
15    pub taint_sources: Vec<String>,
16    /// Unix timestamp when the token expires (if extractable).
17    pub expiry: Option<i64>,
18    /// Whether the token is currently expired.
19    pub is_expired: bool,
20    /// Number of taint blocks appended to the token.
21    pub taint_block_count: usize,
22}
23
24/// Inspects a context token to extract session and taint information.
25///
26/// This performs signature verification but does not enforce time checks,
27/// so expired tokens can still be inspected.
28///
29/// # Arguments
30/// * `token` - The base64-encoded context token
31/// * `public_key` - The public key used to verify the token signature
32///
33/// # Returns
34/// Inspection result with subject, taint labels, sources, and expiry info
35pub fn inspect_context_token(
36    token: String,
37    public_key: PublicKey,
38) -> Result<ContextInspectResult, TokenError> {
39    let biscuit = Biscuit::from_base64(&token, public_key)?;
40    let now = Utc::now().timestamp();
41
42    // Extract the subject from the authority block via an authorizer query
43    let authorizer = authorizer!(
44        r#"
45            time({now});
46            allow if true;
47        "#
48    );
49
50    let mut authorizer = authorizer
51        .build(&biscuit)
52        .map_err(|e| TokenError::internal(format!("failed to build authorizer: {e}")))?;
53
54    let subjects: Vec<(String,)> = authorizer
55        .query("data($name) <- context($name)")
56        .map_err(|e| TokenError::internal(format!("failed to query context subject: {e}")))?;
57
58    let subject = subjects.first().map(|(s,)| s.clone()).unwrap_or_default();
59
60    // Extract taint labels and sources from block source strings
61    let mut taint_labels = Vec::new();
62    let mut taint_sources = Vec::new();
63    let mut taint_block_count = 0;
64
65    let block_count = biscuit.block_count();
66    for i in 0..block_count {
67        let block_source = biscuit.print_block_source(i).unwrap_or_default();
68        let mut block_has_taint = false;
69
70        for line in block_source.lines() {
71            let trimmed = line.trim();
72            if let Some(rest) = trimmed.strip_prefix("taint(") {
73                if let Some(label_str) = rest.strip_suffix(");") {
74                    let label = label_str.trim_matches('"').to_string();
75                    if !taint_labels.contains(&label) {
76                        taint_labels.push(label);
77                    }
78                    block_has_taint = true;
79                }
80            }
81            if let Some(rest) = trimmed.strip_prefix("taint_source(") {
82                if let Some(source_str) = rest.strip_suffix(");") {
83                    let source = source_str.trim_matches('"').to_string();
84                    if !taint_sources.contains(&source) {
85                        taint_sources.push(source);
86                    }
87                }
88            }
89        }
90
91        if block_has_taint {
92            taint_block_count += 1;
93        }
94    }
95
96    // Extract expiry from token content
97    let token_content = biscuit.print();
98    let expiry = extract_expiry_from_content(&token_content);
99    let is_expired = expiry.is_some_and(|exp| exp < now);
100
101    Ok(ContextInspectResult {
102        subject,
103        taint_labels,
104        taint_sources,
105        expiry,
106        is_expired,
107        taint_block_count,
108    })
109}
110
111/// Extracts expiry timestamp from token content.
112fn extract_expiry_from_content(content: &str) -> Option<i64> {
113    let mut earliest_expiry: Option<i64> = None;
114
115    for line in content.lines() {
116        if line.contains("check if") && line.contains("time") && line.contains("<") {
117            if let Some(pos) = line.find("$time <") {
118                let after_lt = &line[pos + 8..].trim();
119                let number_str = after_lt
120                    .chars()
121                    .take_while(|c| c.is_ascii_digit() || *c == '-')
122                    .collect::<String>();
123
124                if let Ok(timestamp) = number_str.parse::<i64>() {
125                    earliest_expiry = Some(earliest_expiry.map_or(timestamp, |e| e.min(timestamp)));
126                }
127            }
128        }
129    }
130
131    earliest_expiry
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::mint::HessraContext;
138    use crate::taint::add_taint;
139    use hessra_token_core::{KeyPair, TokenTimeConfig};
140
141    #[test]
142    fn test_inspect_fresh_context() {
143        let keypair = KeyPair::new();
144        let public_key = keypair.public();
145
146        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
147            .issue(&keypair)
148            .expect("Failed to create context token");
149
150        let result =
151            inspect_context_token(token, public_key).expect("Failed to inspect context token");
152
153        assert_eq!(result.subject, "agent:openclaw");
154        assert!(result.taint_labels.is_empty());
155        assert!(result.taint_sources.is_empty());
156        assert!(!result.is_expired);
157        assert!(result.expiry.is_some());
158        assert_eq!(result.taint_block_count, 0);
159    }
160
161    #[test]
162    fn test_inspect_tainted_context() {
163        let keypair = KeyPair::new();
164        let public_key = keypair.public();
165
166        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
167            .issue(&keypair)
168            .expect("Failed to create context token");
169
170        let tainted = add_taint(
171            &token,
172            public_key,
173            &["PII:SSN".to_string()],
174            "data:user-ssn".to_string(),
175        )
176        .expect("Failed to add taint");
177
178        let result =
179            inspect_context_token(tainted, public_key).expect("Failed to inspect tainted context");
180
181        assert_eq!(result.subject, "agent:openclaw");
182        assert_eq!(result.taint_labels, vec!["PII:SSN".to_string()]);
183        assert_eq!(result.taint_sources, vec!["data:user-ssn".to_string()]);
184        assert_eq!(result.taint_block_count, 1);
185    }
186
187    #[test]
188    fn test_inspect_multi_tainted_context() {
189        let keypair = KeyPair::new();
190        let public_key = keypair.public();
191
192        let token = HessraContext::new("agent:openclaw".to_string(), TokenTimeConfig::default())
193            .issue(&keypair)
194            .expect("Failed to create context token");
195
196        let tainted = add_taint(
197            &token,
198            public_key,
199            &["PII:email".to_string(), "PII:address".to_string()],
200            "data:user-profile".to_string(),
201        )
202        .expect("Failed to add profile taint");
203
204        let more_tainted = add_taint(
205            &tainted,
206            public_key,
207            &["PII:SSN".to_string()],
208            "data:user-ssn".to_string(),
209        )
210        .expect("Failed to add SSN taint");
211
212        let result = inspect_context_token(more_tainted, public_key)
213            .expect("Failed to inspect multi-tainted context");
214
215        assert_eq!(result.subject, "agent:openclaw");
216        assert_eq!(result.taint_labels.len(), 3);
217        assert!(result.taint_labels.contains(&"PII:email".to_string()));
218        assert!(result.taint_labels.contains(&"PII:address".to_string()));
219        assert!(result.taint_labels.contains(&"PII:SSN".to_string()));
220        assert_eq!(result.taint_sources.len(), 2);
221        assert_eq!(result.taint_block_count, 2);
222    }
223
224    #[test]
225    fn test_inspect_expired_context() {
226        let keypair = KeyPair::new();
227        let public_key = keypair.public();
228
229        let expired_config = TokenTimeConfig {
230            start_time: Some(0),
231            duration: 1,
232        };
233
234        let token = HessraContext::new("agent:test".to_string(), expired_config)
235            .issue(&keypair)
236            .expect("Failed to create expired context token");
237
238        let result = inspect_context_token(token, public_key)
239            .expect("Should be able to inspect expired token");
240
241        assert_eq!(result.subject, "agent:test");
242        assert!(result.is_expired);
243        assert_eq!(result.expiry, Some(1));
244    }
245}