Skip to main content

hessra_cap_token/
attenuate.rs

1extern crate biscuit_auth as biscuit;
2
3use std::time::Duration;
4
5use biscuit::Biscuit;
6use biscuit::macros::{block, check, fact};
7use chrono::Utc;
8use hessra_token_core::{KeyPair, PublicKey, TokenError, TokenTimeConfig};
9
10use crate::mint::HessraCapability;
11
12impl HessraCapability {
13    /// Open an existing capability for amendment. From here you add constraints
14    /// (require-side) and/or attestations (provide-side), then finalize with
15    /// either [`CapabilityAmendment::attenuate`] (keyless narrowing) or
16    /// [`CapabilityAmendment::attest`] (a keyholder signs facts onto it).
17    ///
18    /// `root_pk` is the key the capability is rooted at (the authority's).
19    pub fn amend(token: &str, root_pk: PublicKey) -> Result<CapabilityAmendment, TokenError> {
20        let biscuit = Biscuit::from_base64(token, root_pk)?;
21        Ok(CapabilityAmendment {
22            biscuit,
23            require_designations: Vec::new(),
24            require_designations_trusting: Vec::new(),
25            valid_for: None,
26            attest_activation: false,
27            attest_designations: Vec::new(),
28            time_config: TokenTimeConfig::default(),
29        })
30    }
31}
32
33/// A pending amendment to an existing capability.
34///
35/// Two kinds of change accumulate here:
36/// - **constraints** (require-side): [`Self::designation`],
37///   [`Self::designation_trusting`], [`Self::valid_for`]. They tighten the
38///   capability and are valid under either terminal.
39/// - **attestations** (provide-side): [`Self::activate`],
40///   [`Self::with_designation`]. They supply facts that only carry weight when
41///   the amendment is signed, so they require the [`Self::attest`] terminal.
42pub struct CapabilityAmendment {
43    biscuit: Biscuit,
44    require_designations: Vec<(String, String)>,
45    require_designations_trusting: Vec<(PublicKey, String, String)>,
46    valid_for: Option<Duration>,
47    attest_activation: bool,
48    attest_designations: Vec<(String, String)>,
49    /// Clock control. `start_time`, when set, overrides "now" for the activation
50    /// fact and the tightened-window deadline (the seam for deterministic tests).
51    /// `duration` is not consulted here -- amend's time check is opt-in via
52    /// [`Self::valid_for`].
53    time_config: TokenTimeConfig,
54}
55
56impl CapabilityAmendment {
57    /// Require a designation: adds `check if designation(label, value)`.
58    pub fn designation(mut self, label: impl Into<String>, value: impl Into<String>) -> Self {
59        self.require_designations.push((label.into(), value.into()));
60        self
61    }
62
63    /// Require a designation that must be attested by `pk`: adds
64    /// `check if designation(label, value) trusting {pk}`.
65    pub fn designation_trusting(
66        mut self,
67        pk: PublicKey,
68        label: impl Into<String>,
69        value: impl Into<String>,
70    ) -> Self {
71        self.require_designations_trusting
72            .push((pk, label.into(), value.into()));
73        self
74    }
75
76    /// Tighten the capability's lifetime to `duration` from now. Checks only
77    /// accumulate, so this can shorten the window, never extend it.
78    pub fn valid_for(mut self, duration: Duration) -> Self {
79        self.valid_for = Some(duration);
80        self
81    }
82
83    /// Attest that this (latent) capability is active as of now: supplies an
84    /// `activation` fact, satisfying a `latent` capability's activation check.
85    /// Only meaningful under [`Self::attest`] (the signer vouches for it).
86    pub fn activate(mut self) -> Self {
87        self.attest_activation = true;
88        self
89    }
90
91    /// Attest a designation fact: supplies `designation(label, value)`,
92    /// satisfying a matching `designation_trusting` constraint. Mirrors
93    /// [`crate::CapabilityVerifier::with_designation`]. Only meaningful under
94    /// [`Self::attest`].
95    pub fn with_designation(mut self, label: impl Into<String>, value: impl Into<String>) -> Self {
96        self.attest_designations.push((label.into(), value.into()));
97        self
98    }
99
100    /// Supply a [`TokenTimeConfig`] to control time. Only `start_time` is used
101    /// here: when set it overrides "now" for the activation fact and the
102    /// tightened-window deadline (the seam for deterministic tests). `duration`
103    /// is ignored -- amend's time check is opt-in via [`Self::valid_for`].
104    pub fn with_time(mut self, time_config: TokenTimeConfig) -> Self {
105        self.time_config = time_config;
106        self
107    }
108
109    /// Resolve "now": the injected clock override, else the real wall clock.
110    fn now(&self) -> i64 {
111        self.time_config
112            .start_time
113            .unwrap_or_else(|| Utc::now().timestamp())
114    }
115
116    /// Finalize as a keyless attenuation: appends a first-party block carrying
117    /// only the require-side constraints. Errors if any attestation (a
118    /// provide-side fact) was added -- those require a signature via
119    /// [`Self::attest`].
120    pub fn attenuate(self) -> Result<String, TokenError> {
121        if self.attest_activation || !self.attest_designations.is_empty() {
122            return Err(TokenError::generic(
123                "attenuate() cannot carry attested facts; use attest(keypair)",
124            ));
125        }
126        let mut block = block!(r#""#);
127        block = self.append_constraints(block)?;
128        let appended = self
129            .biscuit
130            .append(block)
131            .map_err(|e| TokenError::AttenuationFailed {
132                reason: e.to_string(),
133            })?;
134        Ok(appended.to_base64()?)
135    }
136
137    /// Finalize as an attestation: a third-party block signed by `keypair`
138    /// carrying the attested facts plus any constraints. The signer vouches for
139    /// the facts; constraints (`trusting {keypair.public()}`) reference this
140    /// signer so the matching facts are honored only from blocks it signed.
141    pub fn attest(self, keypair: &KeyPair) -> Result<String, TokenError> {
142        let now = self.now();
143        let mut block = block!(r#""#);
144
145        if self.attest_activation {
146            block = block
147                .fact(fact!(r#"activation({now});"#))
148                .map_err(|e| TokenError::generic(format!("attest activation: {e}")))?;
149        }
150        for (label, value) in &self.attest_designations {
151            let (label, value) = (label.clone(), value.clone());
152            block = block
153                .fact(fact!(r#"designation({label}, {value});"#))
154                .map_err(|e| TokenError::generic(format!("attest designation: {e}")))?;
155        }
156        block = self.append_constraints(block)?;
157
158        let request = self
159            .biscuit
160            .third_party_request()
161            .map_err(|e| TokenError::generic(format!("attest request: {e}")))?;
162        let signed = request
163            .create_block(&keypair.private(), block)
164            .map_err(|e| TokenError::generic(format!("attest sign: {e}")))?;
165        let appended = self
166            .biscuit
167            .append_third_party(keypair.public(), signed)
168            .map_err(|e| TokenError::generic(format!("attest append: {e}")))?;
169        Ok(appended.to_base64()?)
170    }
171
172    /// Append the require-side constraints (shared by both terminals).
173    fn append_constraints(
174        &self,
175        mut block: biscuit::builder::BlockBuilder,
176    ) -> Result<biscuit::builder::BlockBuilder, TokenError> {
177        for (label, value) in &self.require_designations {
178            let (label, value) = (label.clone(), value.clone());
179            block = block
180                .check(check!(r#"check if designation({label}, {value});"#))
181                .map_err(|e| TokenError::AttenuationFailed {
182                    reason: e.to_string(),
183                })?;
184        }
185        for (pk, label, value) in &self.require_designations_trusting {
186            let (pk, label, value) = (*pk, label.clone(), value.clone());
187            block = block
188                .check(check!(
189                    r#"check if designation({label}, {value}) trusting {pk};"#
190                ))
191                .map_err(|e| TokenError::AttenuationFailed {
192                    reason: e.to_string(),
193                })?;
194        }
195        if let Some(duration) = self.valid_for {
196            let expiration = self.now() + duration.as_secs() as i64;
197            block = block
198                .check(check!(r#"check if time($t), $t < {expiration};"#))
199                .map_err(|e| TokenError::AttenuationFailed {
200                    reason: e.to_string(),
201                })?;
202        }
203        Ok(block)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::verify::CapabilityVerifier;
211
212    fn authority_cap(authority: &KeyPair) -> String {
213        HessraCapability::new()
214            .subject("alice")
215            .resource("res1")
216            .operation("read")
217            .issue(authority)
218            .unwrap()
219    }
220
221    #[test]
222    fn keyless_designation_narrowing() {
223        let root = KeyPair::new();
224        let token = authority_cap(&root);
225        let narrowed = HessraCapability::amend(&token, root.public())
226            .unwrap()
227            .designation("tenant", "t-1")
228            .attenuate()
229            .unwrap();
230
231        // Matching designation passes.
232        assert!(
233            CapabilityVerifier::new(
234                narrowed.clone(),
235                root.public(),
236                "res1".into(),
237                "read".into()
238            )
239            .with_designation("tenant".into(), "t-1".into())
240            .verify()
241            .is_ok()
242        );
243        // Missing designation fails closed.
244        assert!(
245            CapabilityVerifier::new(narrowed, root.public(), "res1".into(), "read".into())
246                .verify()
247                .is_err()
248        );
249    }
250
251    #[test]
252    fn attenuate_rejects_attested_facts() {
253        let root = KeyPair::new();
254        let token = authority_cap(&root);
255        let err = HessraCapability::amend(&token, root.public())
256            .unwrap()
257            .activate()
258            .attenuate();
259        assert!(err.is_err());
260    }
261
262    // --- latent capability lifecycle (ported from the old latent module) -------
263    //
264    // A latent cap is inert until the activator (ToolSystem) attests an
265    // `activation` fact, and single-use via a per-activation facet pin
266    // (`designation_trusting`) completed by a later facet attestation.
267
268    use std::time::Duration;
269
270    /// (authority keypair, activator/ts keypair)
271    fn roles() -> (KeyPair, KeyPair) {
272        (KeyPair::new(), KeyPair::new())
273    }
274
275    fn latent(authority: &KeyPair, ts: &KeyPair, anchor: &str) -> String {
276        HessraCapability::new()
277            .subject("agent")
278            .resource("res:x")
279            .operation("read")
280            .anchor(anchor)
281            .valid_for(Duration::from_secs(3600))
282            .latent(ts.public())
283            .issue(authority)
284            .unwrap()
285    }
286
287    /// Activate: attest activation now, tighten to 5 min, pin a single-use facet.
288    fn activate(latent: &str, authority: &KeyPair, ts: &KeyPair, facet: &str) -> String {
289        HessraCapability::amend(latent, authority.public())
290            .unwrap()
291            .activate()
292            .valid_for(Duration::from_secs(300))
293            .designation_trusting(ts.public(), "facet", facet)
294            .attest(ts)
295            .unwrap()
296    }
297
298    /// Complete: attest the facet fact (the forward hop).
299    fn complete(activated: &str, authority: &KeyPair, ts: &KeyPair, facet: &str) -> String {
300        HessraCapability::amend(activated, authority.public())
301            .unwrap()
302            .with_designation("facet", facet)
303            .attest(ts)
304            .unwrap()
305    }
306
307    fn ok(token: &str, authority: &KeyPair, anchor: &str) -> bool {
308        CapabilityVerifier::new(
309            token.to_string(),
310            authority.public(),
311            "res:x".to_string(),
312            "read".to_string(),
313        )
314        .anchor(anchor)
315        .verify()
316        .is_ok()
317    }
318
319    /// Like [`ok`], but verifies against a fixed clock `at`.
320    fn ok_at(token: &str, authority: &KeyPair, anchor: &str, at: i64) -> bool {
321        CapabilityVerifier::new(
322            token.to_string(),
323            authority.public(),
324            "res:x".to_string(),
325            "read".to_string(),
326        )
327        .anchor(anchor)
328        .with_time(TokenTimeConfig {
329            start_time: Some(at),
330            ..Default::default()
331        })
332        .verify()
333        .is_ok()
334    }
335
336    #[test]
337    fn requires_both_activation_and_facet() {
338        let (auth, ts) = roles();
339        let latent = latent(&auth, &ts, "tool:x");
340
341        // Inert at rest.
342        assert!(!ok(&latent, &auth, "tool:x"));
343        // Activated but not completed: facet pin unmet -> still inert.
344        let activated = activate(&latent, &auth, &ts, "facet-A");
345        assert!(!ok(&activated, &auth, "tool:x"));
346        // Completed -> verifies.
347        let completed = complete(&activated, &auth, &ts, "facet-A");
348        assert!(ok(&completed, &auth, "tool:x"));
349    }
350
351    #[test]
352    fn facet_bound_to_activation() {
353        let (auth, ts) = roles();
354        let latent = latent(&auth, &ts, "tool:x");
355        let act_a = activate(&latent, &auth, &ts, "facet-A");
356        let act_b = activate(&latent, &auth, &ts, "facet-B");
357
358        assert!(ok(
359            &complete(&act_a, &auth, &ts, "facet-A"),
360            &auth,
361            "tool:x"
362        ));
363        assert!(ok(
364            &complete(&act_b, &auth, &ts, "facet-B"),
365            &auth,
366            "tool:x"
367        ));
368        // A facet from the other activation cannot satisfy this one.
369        assert!(!ok(
370            &complete(&act_b, &auth, &ts, "facet-A"),
371            &auth,
372            "tool:x"
373        ));
374        assert!(!ok(
375            &complete(&act_a, &auth, &ts, "facet-B"),
376            &auth,
377            "tool:x"
378        ));
379    }
380
381    #[test]
382    fn activation_from_wrong_key_rejected() {
383        // Activation attested by a rogue key; facet completed correctly by ts.
384        // Fails only on the activation check.
385        let (auth, ts) = roles();
386        let rogue = KeyPair::new();
387        let latent = latent(&auth, &ts, "tool:x");
388        let activated = activate(&latent, &auth, &rogue, "facet-A");
389        let completed = complete(&activated, &auth, &ts, "facet-A");
390        assert!(!ok(&completed, &auth, "tool:x"));
391    }
392
393    #[test]
394    fn facet_from_wrong_key_rejected() {
395        // Activation correct (ts); facet completed by a rogue key. Fails only on
396        // the facet pin.
397        let (auth, ts) = roles();
398        let rogue = KeyPair::new();
399        let latent = latent(&auth, &ts, "tool:x");
400        let activated = activate(&latent, &auth, &ts, "facet-A");
401        let forged = complete(&activated, &auth, &rogue, "facet-A");
402        assert!(!ok(&forged, &auth, "tool:x"));
403    }
404
405    #[test]
406    fn activation_shortens_lifetime() {
407        // The headline temporal claim: activating a long-lived latent cap
408        // tightens its effective lifetime to the activation window (<=5 min),
409        // even though the original mint window is still wide open.
410        let (auth, ts) = roles();
411        let base = 1_000_000_000;
412        let clock = TokenTimeConfig {
413            start_time: Some(base),
414            ..Default::default()
415        };
416
417        // Latent cap minted with a 1-hour window on the fixed clock.
418        let latent = HessraCapability::new()
419            .subject("agent")
420            .resource("res:x")
421            .operation("read")
422            .anchor("tool:x")
423            .valid_for(Duration::from_secs(3600))
424            .latent(ts.public())
425            .with_time(clock)
426            .issue(&auth)
427            .unwrap();
428
429        // Activate at `base`, tightening to a 5-minute window + facet pin.
430        let activated = HessraCapability::amend(&latent, auth.public())
431            .unwrap()
432            .activate()
433            .valid_for(Duration::from_secs(300))
434            .designation_trusting(ts.public(), "facet", "f")
435            .with_time(clock)
436            .attest(&ts)
437            .unwrap();
438        let completed = HessraCapability::amend(&activated, auth.public())
439            .unwrap()
440            .with_designation("facet", "f")
441            .with_time(clock)
442            .attest(&ts)
443            .unwrap();
444
445        // Inside the activation window: ok.
446        assert!(ok_at(&completed, &auth, "tool:x", base + 299));
447        // Past the 5-min activation window but still within the original 1-hour
448        // mint window: rejected -- activation shortened the lifetime.
449        assert!(!ok_at(&completed, &auth, "tool:x", base + 301));
450    }
451
452    #[test]
453    fn anchor_binds_latent_to_one_tool() {
454        let (auth, ts) = roles();
455        let latent = latent(&auth, &ts, "tool:a");
456        let activated = activate(&latent, &auth, &ts, "facet-A");
457        let completed = complete(&activated, &auth, &ts, "facet-A");
458        assert!(!ok(&completed, &auth, "tool:b"));
459        assert!(ok(&completed, &auth, "tool:a"));
460    }
461}