Skip to main content

mnemo_core/model/
capability.rs

1//! Minimal verifiable capability (lease) — the seed of #126 (capability-leased
2//! access).
3//!
4//! A [`Capability`] binds a `principal` to a `scope` with an optional expiry and
5//! is **HMAC-signed** by an issuer key, so a capability id recorded in write
6//! provenance can be *verified* against the key rather than trusted as a
7//! free-form string. The write path (REMEMBER / SHARE) verifies a presented
8//! capability before recording it in [`crate::model::write_provenance`].
9//!
10//! This is deliberately small: it is the authorisation *token*, not a policy
11//! engine. Enforcing what a `scope` permits (namespace / op gating) is the
12//! follow-up tracked in #126. Today the token proves exactly one thing:
13//! "principal `P` held a valid, unexpired, issuer-signed capability with scope
14//! `S`." That is enough to make a write's authority a real, checkable fact
15//! instead of a recorded label.
16
17use chrono::{DateTime, Duration, Utc};
18use hmac::{Hmac, KeyInit, Mac};
19use serde::{Deserialize, Serialize};
20use subtle::ConstantTimeEq;
21use thiserror::Error;
22use uuid::Uuid;
23
24type HmacSha256 = Hmac<Sha256>;
25use sha2::Sha256;
26
27/// A signed, time-bounded authorisation token.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct Capability {
30    pub id: Uuid,
31    /// Who holds this capability (the writing principal).
32    pub principal: String,
33    /// What it authorises. Free-form for now (e.g. `"remember"`,
34    /// `"share:agent-x"`, `"namespace:acme"`); scope *enforcement* is #126.
35    pub scope: String,
36    pub issued_at: DateTime<Utc>,
37    /// `None` = no expiry.
38    pub expires_at: Option<DateTime<Utc>>,
39    /// HMAC-SHA256 over `id || principal || scope || issued_at || expires_at`,
40    /// binding the token to the issuer key.
41    pub signature: Vec<u8>,
42    /// Issuer key identifier, so a rotated key can still verify old tokens.
43    pub key_id: String,
44}
45
46#[derive(Debug, Error, PartialEq, Eq)]
47pub enum CapabilityError {
48    #[error("capability signature mismatch — forged token or wrong key")]
49    SignatureMismatch,
50    #[error("capability expired at {0}")]
51    Expired(DateTime<Utc>),
52    #[error("capability was issued by key `{0}`, which this issuer does not hold")]
53    UnknownKey(String),
54}
55
56/// Issues and verifies [`Capability`] tokens against a single HMAC key.
57///
58/// To verify tokens issued under a rotated key, keep the old issuer around and
59/// dispatch by `capability.key_id` (same pattern as
60/// [`crate::provenance::ProvenanceKeystore`]).
61#[derive(Debug, Clone)]
62pub struct CapabilityIssuer {
63    key_id: String,
64    key: Vec<u8>,
65}
66
67impl CapabilityIssuer {
68    pub fn new(key_id: impl Into<String>, key: &[u8]) -> Self {
69        Self {
70            key_id: key_id.into(),
71            key: key.to_vec(),
72        }
73    }
74
75    pub fn key_id(&self) -> &str {
76        &self.key_id
77    }
78
79    /// Issue a signed capability valid for `ttl` (`None` = no expiry).
80    pub fn issue(
81        &self,
82        principal: impl Into<String>,
83        scope: impl Into<String>,
84        ttl: Option<Duration>,
85    ) -> Capability {
86        let principal = principal.into();
87        let scope = scope.into();
88        let issued_at = Utc::now();
89        let expires_at = ttl.map(|d| issued_at + d);
90        let id = Uuid::now_v7();
91        let signature = self.sign(&id, &principal, &scope, &issued_at, &expires_at);
92        Capability {
93            id,
94            principal,
95            scope,
96            issued_at,
97            expires_at,
98            signature,
99            key_id: self.key_id.clone(),
100        }
101    }
102
103    fn sign(
104        &self,
105        id: &Uuid,
106        principal: &str,
107        scope: &str,
108        issued_at: &DateTime<Utc>,
109        expires_at: &Option<DateTime<Utc>>,
110    ) -> Vec<u8> {
111        // HMAC key length is unconstrained for HMAC-SHA256, so this never fails.
112        let mut mac = <HmacSha256 as KeyInit>::new_from_slice(&self.key)
113            .expect("HMAC-SHA256 accepts any key length");
114        mac.update(id.as_bytes());
115        mac.update(principal.as_bytes());
116        mac.update(scope.as_bytes());
117        mac.update(issued_at.to_rfc3339().as_bytes());
118        if let Some(exp) = expires_at {
119            mac.update(exp.to_rfc3339().as_bytes());
120        }
121        mac.finalize().into_bytes().to_vec()
122    }
123
124    /// Verify a capability's signature and expiry against this issuer's key.
125    pub fn verify(&self, cap: &Capability) -> Result<(), CapabilityError> {
126        if cap.key_id != self.key_id {
127            return Err(CapabilityError::UnknownKey(cap.key_id.clone()));
128        }
129        let expected = self.sign(
130            &cap.id,
131            &cap.principal,
132            &cap.scope,
133            &cap.issued_at,
134            &cap.expires_at,
135        );
136        if !bool::from(expected.ct_eq(&cap.signature)) {
137            return Err(CapabilityError::SignatureMismatch);
138        }
139        if let Some(exp) = cap.expires_at
140            && Utc::now() > exp
141        {
142            return Err(CapabilityError::Expired(exp));
143        }
144        Ok(())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn issuer() -> CapabilityIssuer {
153        CapabilityIssuer::new("mnemo-cap-test", &[9u8; 32])
154    }
155
156    #[test]
157    fn issue_then_verify_round_trips() {
158        let iss = issuer();
159        let cap = iss.issue("alice", "remember", Some(Duration::hours(1)));
160        assert_eq!(cap.principal, "alice");
161        assert_eq!(cap.scope, "remember");
162        iss.verify(&cap)
163            .expect("freshly issued capability must verify");
164    }
165
166    #[test]
167    fn no_expiry_verifies() {
168        let iss = issuer();
169        let cap = iss.issue("svc", "share:agent-x", None);
170        assert!(cap.expires_at.is_none());
171        iss.verify(&cap).unwrap();
172    }
173
174    #[test]
175    fn tampered_principal_fails() {
176        let iss = issuer();
177        let mut cap = iss.issue("alice", "remember", None);
178        cap.principal = "mallory".to_string();
179        assert_eq!(iss.verify(&cap), Err(CapabilityError::SignatureMismatch));
180    }
181
182    #[test]
183    fn tampered_scope_fails() {
184        let iss = issuer();
185        let mut cap = iss.issue("alice", "remember", None);
186        cap.scope = "admin".to_string();
187        assert_eq!(iss.verify(&cap), Err(CapabilityError::SignatureMismatch));
188    }
189
190    #[test]
191    fn expired_capability_fails() {
192        let iss = issuer();
193        // Negative ttl => already expired.
194        let cap = iss.issue("alice", "remember", Some(Duration::seconds(-1)));
195        assert!(matches!(iss.verify(&cap), Err(CapabilityError::Expired(_))));
196    }
197
198    #[test]
199    fn wrong_key_id_is_rejected() {
200        let iss = issuer();
201        let mut cap = iss.issue("alice", "remember", None);
202        cap.key_id = "rotated-out".to_string();
203        assert_eq!(
204            iss.verify(&cap),
205            Err(CapabilityError::UnknownKey("rotated-out".to_string()))
206        );
207    }
208
209    #[test]
210    fn different_key_same_id_fails_signature() {
211        let a = CapabilityIssuer::new("k", &[1u8; 32]);
212        let b = CapabilityIssuer::new("k", &[2u8; 32]);
213        let cap = a.issue("alice", "remember", None);
214        // Same key_id, different key material => signature mismatch, not UnknownKey.
215        assert_eq!(b.verify(&cap), Err(CapabilityError::SignatureMismatch));
216    }
217}