mnemo_core/model/
capability.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct Capability {
30 pub id: Uuid,
31 pub principal: String,
33 pub scope: String,
36 pub issued_at: DateTime<Utc>,
37 pub expires_at: Option<DateTime<Utc>>,
39 pub signature: Vec<u8>,
42 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#[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 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 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 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 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 assert_eq!(b.verify(&cap), Err(CapabilityError::SignatureMismatch));
216 }
217}