Skip to main content

hessra_cap_token/
verify.rs

1extern crate biscuit_auth as biscuit;
2
3use biscuit::Algorithm;
4use biscuit::macros::{authorizer, check, fact};
5use chrono::Utc;
6use hessra_token_core::{
7    Biscuit, PublicKey, TokenError, TokenTimeConfig, parse_capability_failure, parse_check_failure,
8};
9
10/// Builder for verifying Hessra capability tokens with flexible configuration.
11///
12/// By default, capability verification only checks resource + operation.
13/// Subject verification is optional via `.with_subject()`.
14///
15/// # Example
16/// ```no_run
17/// use hessra_cap_token::{CapabilityVerifier, HessraCapability};
18/// use hessra_token_core::KeyPair;
19///
20/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
21/// let keypair = KeyPair::new();
22/// let public_key = keypair.public();
23/// let token = HessraCapability::new()
24///     .subject("user123")
25///     .resource("resource456")
26///     .operation("read")
27///     .issue(&keypair)?;
28///
29/// // Basic capability verification (no subject check)
30/// CapabilityVerifier::new(
31///     token.clone(),
32///     public_key,
33///     "resource456".to_string(),
34///     "read".to_string(),
35/// )
36/// .verify()?;
37///
38/// // With optional subject verification
39/// CapabilityVerifier::new(
40///     token.clone(),
41///     public_key,
42///     "resource456".to_string(),
43///     "read".to_string(),
44/// )
45/// .with_subject("user123".to_string())
46/// .verify()?;
47/// # Ok(())
48/// # }
49/// ```
50pub struct CapabilityVerifier {
51    token: String,
52    public_key: PublicKey,
53    resource: String,
54    operation: String,
55    subject: Option<String>,
56    designations: Vec<(String, String)>,
57    time_config: TokenTimeConfig,
58}
59
60impl CapabilityVerifier {
61    /// Creates a new capability verifier for a base64-encoded token.
62    ///
63    /// # Arguments
64    /// * `token` - The base64-encoded capability token to verify
65    /// * `public_key` - The public key used to verify the token signature
66    /// * `resource` - The resource identifier to verify
67    /// * `operation` - The operation to verify
68    pub fn new(token: String, public_key: PublicKey, resource: String, operation: String) -> Self {
69        Self {
70            token,
71            public_key,
72            resource,
73            operation,
74            subject: None,
75            designations: Vec::new(),
76            time_config: TokenTimeConfig::default(),
77        }
78    }
79
80    /// Adds an optional subject verification check.
81    ///
82    /// When set, the authorizer adds a check that the minted subject matches.
83    /// This is optional -- pure capability verification does not require it.
84    ///
85    /// # Arguments
86    /// * `subject` - The subject to verify in the token's right fact
87    pub fn with_subject(mut self, subject: String) -> Self {
88        self.subject = Some(subject);
89        self
90    }
91
92    /// Adds a designation fact to the verification.
93    ///
94    /// Each designation provides a `designation(label, value)` fact that the
95    /// token's designation checks will verify against.
96    ///
97    /// # Arguments
98    /// * `label` - The designation dimension (e.g., "tenant_id")
99    /// * `value` - The specific value (e.g., "t-123")
100    pub fn with_designation(mut self, label: String, value: String) -> Self {
101        self.designations.push((label, value));
102        self
103    }
104
105    /// Asserts this verifier's anchor identity. Sugar for
106    /// `with_designation("anchor", anchor)`: proves "I am `<anchor>`" to satisfy
107    /// an anchor-bound capability (see [`crate::HessraCapability::anchor`]).
108    pub fn anchor(self, anchor: impl Into<String>) -> Self {
109        self.with_designation("anchor".to_string(), anchor.into())
110    }
111
112    /// Supply a [`TokenTimeConfig`] to control verification time. Only
113    /// `start_time` is used: when set it overrides the "now" checked against the
114    /// token's expiry (the seam for deterministic tests). Without this call the
115    /// real wall clock applies.
116    pub fn with_time(mut self, time_config: TokenTimeConfig) -> Self {
117        self.time_config = time_config;
118        self
119    }
120
121    /// Performs the token verification with the configured parameters.
122    ///
123    /// # Returns
124    /// * `Ok(())` - If the token is valid and meets all verification requirements
125    /// * `Err(TokenError)` - If verification fails for any reason
126    pub fn verify(self) -> Result<(), TokenError> {
127        let biscuit = Biscuit::from_base64(&self.token, self.public_key)?;
128        let now = self
129            .time_config
130            .start_time
131            .unwrap_or_else(|| Utc::now().timestamp());
132        let resource = self.resource.clone();
133        let operation = self.operation.clone();
134
135        // Build the capability authorizer -- no subject fact needed
136        let mut authz = authorizer!(
137            r#"
138                time({now});
139                resource({resource});
140                operation({operation});
141                allow if true;
142            "#
143        );
144
145        // Optional: add subject check when caller wants to verify who minted the token
146        if let Some(ref subject) = self.subject {
147            let subject = subject.clone();
148            let resource = self.resource.clone();
149            let operation = self.operation.clone();
150            authz = authz.check(check!(
151                r#"check if right({subject}, {resource}, {operation});"#
152            ))?;
153        }
154
155        // Add designation facts
156        for (label, value) in &self.designations {
157            let label = label.clone();
158            let value = value.clone();
159            authz = authz.fact(fact!(r#"designation({label}, {value});"#))?;
160        }
161
162        // Biscuit's default datalog budget (1ms) is calibrated for native
163        // speed; under WebAssembly the same evaluation regularly exceeds it
164        // and verification fails spuriously. The capability chains verified
165        // here are small and self-authored, so a generous fixed budget keeps
166        // the DoS bound while working on every target.
167        let limits = biscuit::datalog::RunLimits {
168            max_time: std::time::Duration::from_millis(50),
169            ..Default::default()
170        };
171        match authz.build(&biscuit)?.authorize_with_limits(limits) {
172            Ok(_) => Ok(()),
173            Err(e) => Err(convert_capability_error(
174                e,
175                self.subject.as_deref(),
176                Some(&self.resource),
177                Some(&self.operation),
178            )),
179        }
180    }
181}
182
183/// Takes a public key encoded as a string in the format "ed25519/..." or "secp256r1/..."
184/// and returns a PublicKey.
185pub fn biscuit_key_from_string(key: String) -> Result<PublicKey, TokenError> {
186    let parts = key.split('/').collect::<Vec<&str>>();
187    if parts.len() != 2 {
188        return Err(TokenError::invalid_key_format(
189            "Key must be in format 'algorithm/hexkey'",
190        ));
191    }
192
193    let alg = match parts[0] {
194        "ed25519" => Algorithm::Ed25519,
195        "secp256r1" => Algorithm::Secp256r1,
196        _ => {
197            return Err(TokenError::invalid_key_format(
198                "Unsupported algorithm, must be ed25519 or secp256r1",
199            ));
200        }
201    };
202
203    let key_bytes = hex::decode(parts[1])?;
204
205    let key = PublicKey::from_bytes(&key_bytes, alg)
206        .map_err(|e| TokenError::invalid_key_format(e.to_string()))?;
207
208    Ok(key)
209}
210
211/// Convert biscuit authorization errors to detailed capability errors
212fn convert_capability_error(
213    err: biscuit::error::Token,
214    subject: Option<&str>,
215    resource: Option<&str>,
216    operation: Option<&str>,
217) -> TokenError {
218    use biscuit::error::{Logic, Token};
219
220    match err {
221        Token::FailedLogic(logic_err) => match &logic_err {
222            Logic::Unauthorized { checks, .. } | Logic::NoMatchingPolicy { checks } => {
223                for failed_check in checks.iter() {
224                    let (block_id, check_id, rule) = match failed_check {
225                        biscuit::error::FailedCheck::Block(block_check) => (
226                            block_check.block_id,
227                            block_check.check_id,
228                            block_check.rule.clone(),
229                        ),
230                        biscuit::error::FailedCheck::Authorizer(auth_check) => {
231                            (0, auth_check.check_id, auth_check.rule.clone())
232                        }
233                    };
234
235                    let parsed_error = parse_check_failure(block_id, check_id, &rule);
236
237                    if matches!(parsed_error, TokenError::Expired { .. }) {
238                        return parsed_error;
239                    }
240                }
241
242                // Check if this looks like a rights denial (no matching policy)
243                if matches!(logic_err, Logic::NoMatchingPolicy { .. }) {
244                    return parse_capability_failure(
245                        subject,
246                        resource,
247                        operation,
248                        &format!("{checks:?}"),
249                    );
250                }
251
252                TokenError::from(Token::FailedLogic(logic_err))
253            }
254            other => TokenError::from(Token::FailedLogic(other.clone())),
255        },
256        other => TokenError::from(other),
257    }
258}