1mod capability;
25mod chain;
26mod did_key;
27mod first_wins;
28mod token;
29
30use std::collections::HashMap;
31use std::fmt;
32
33use base64::Engine;
34use serde_json::{Map, Value};
35use sha2::{Digest, Sha384};
36
37use crate::node_key::{KeyError, NodeKey, Purpose};
38use crate::profile::Profile;
39
40pub use capability::covers;
41pub use did_key::{carried_key, did_key};
42
43pub const MAX_LIFETIME: i64 = 315_576_000;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum Refusal {
55 Malformed,
56 WrongAlgorithm,
57 SignatureInvalid,
58 NotTheIssuer,
59 NotTheAudience,
60 Expired,
61 ExpBeyondMaxLifetime,
62 NotYetValid,
63 MissingCapability,
64 MissingProof,
65 UnreferencedProof,
66 NotTheDelegate,
67 ChainNotLinear,
68 GrantsMoreThanProof,
69 CanChanged,
70 WrongRealm,
71 RealmNameNotCanonical,
72 ProcedureWithoutOrg,
73}
74
75impl Refusal {
76 pub fn name(self) -> &'static str {
78 match self {
79 Refusal::Malformed => "malformed",
80 Refusal::WrongAlgorithm => "wrong_algorithm",
81 Refusal::SignatureInvalid => "signature_invalid",
82 Refusal::NotTheIssuer => "not_the_issuer",
83 Refusal::NotTheAudience => "not_the_audience",
84 Refusal::Expired => "expired",
85 Refusal::ExpBeyondMaxLifetime => "exp_beyond_max_lifetime",
86 Refusal::NotYetValid => "not_yet_valid",
87 Refusal::MissingCapability => "missing_capability",
88 Refusal::MissingProof => "missing_proof",
89 Refusal::UnreferencedProof => "unreferenced_proof",
90 Refusal::NotTheDelegate => "not_the_delegate",
91 Refusal::ChainNotLinear => "chain_not_linear",
92 Refusal::GrantsMoreThanProof => "grants_more_than_proof",
93 Refusal::CanChanged => "can_changed",
94 Refusal::WrongRealm => "wrong_realm",
95 Refusal::RealmNameNotCanonical => "realm_name_not_canonical",
96 Refusal::ProcedureWithoutOrg => "procedure_without_org",
97 }
98 }
99}
100
101impl fmt::Display for Refusal {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 write!(f, "ucan: {}", self.name())
104 }
105}
106
107impl std::error::Error for Refusal {}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum Policy {
112 UcanRequired { issuer: [u8; 32] },
114 RealmMemberRequired { key_id: [u8; 32], can: String },
117}
118
119impl Policy {
120 pub fn valid(&self) -> bool {
123 match self {
124 Policy::UcanRequired { .. } => true,
125 Policy::RealmMemberRequired { can, .. } => !can.is_empty(),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Request {
133 pub realm: [u8; 32],
134 pub procedure: String,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Context {
143 pub caller: [u8; 32],
144 pub profile: Profile,
145 pub now: i64,
146 pub request: Option<Request>,
147 pub proofs: HashMap<String, Vec<u8>>,
148}
149
150impl Context {
151 pub fn keyed_proofs(proofs: &[Vec<u8>]) -> HashMap<String, Vec<u8>> {
153 proofs.iter().map(|p| (proof_id(p), p.clone())).collect()
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Capability {
160 pub with: String,
161 pub can: String,
162}
163
164#[derive(Debug, Clone, Default, PartialEq)]
168pub struct Options {
169 pub exp: i64,
170 pub nbf: Option<i64>,
171 pub nnc: Option<String>,
172 pub fct: Option<Map<String, Value>>,
173 pub prf: Option<Vec<String>>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum CreateError {
179 NotAnIdentityKey(Purpose),
181 ExpBeyondMaxLifetime { exp: i64, now: i64, max: i64 },
183 WindowNeverOpens { nbf: i64, exp: i64 },
185 Sign(KeyError),
187}
188
189impl fmt::Display for CreateError {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 match self {
192 CreateError::NotAnIdentityKey(purpose) => {
193 write!(
194 f,
195 "ucan: a token is signed by an identity key, not a {purpose} key"
196 )
197 }
198 CreateError::ExpBeyondMaxLifetime { exp, now, max } => write!(
199 f,
200 "ucan: exp_beyond_max_lifetime: exp {exp}, now {now}, at most {max} \
201 (milliseconds, where seconds are meant?)"
202 ),
203 CreateError::WindowNeverOpens { nbf, exp } => write!(
204 f,
205 "ucan: nbf is not before exp, so the token is never valid: nbf {nbf}, exp {exp}"
206 ),
207 CreateError::Sign(e) => write!(f, "ucan: {e}"),
208 }
209 }
210}
211
212impl std::error::Error for CreateError {}
213
214const TYP: &str = "JWT";
215const UCV: &str = "0.10.0";
216
217const BASE64URL: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
219
220fn algorithm(profile: Profile) -> &'static str {
222 match profile {
223 Profile::PqPure => "ML-DSA-87",
224 Profile::PqHybrid => "ML-DSA-87-PS384",
225 }
226}
227
228pub fn create(
232 issuer: &NodeKey,
233 audience: &[u8; 32],
234 caps: &[Capability],
235 o: &Options,
236) -> Result<Vec<u8>, CreateError> {
237 if issuer.purpose() != Purpose::Identity {
238 return Err(CreateError::NotAnIdentityKey(issuer.purpose()));
239 }
240 window_opens(o, now_seconds())?;
241 let caps: Vec<Value> = caps
242 .iter()
243 .map(|c| serde_json::json!({"with": c.with, "can": c.can}))
244 .collect();
245 let mut claims = Map::new();
246 claims.insert(
247 "iss".into(),
248 Value::String(did_key(&issuer.public_key(), issuer.profile())),
249 );
250 claims.insert("aud".into(), Value::String(hex(audience)));
251 claims.insert("cap".into(), Value::Array(caps));
252 claims.insert("exp".into(), Value::from(o.exp));
253 if let Some(nbf) = o.nbf {
254 claims.insert("nbf".into(), Value::from(nbf));
255 }
256 if let Some(nnc) = o.nnc.as_ref().filter(|n| !n.is_empty()) {
257 claims.insert("nnc".into(), Value::String(nnc.clone()));
258 }
259 if let Some(fct) = &o.fct {
260 claims.insert("fct".into(), Value::Object(fct.clone()));
261 }
262 if let Some(prf) = &o.prf {
263 claims.insert(
264 "prf".into(),
265 Value::Array(prf.iter().cloned().map(Value::String).collect()),
266 );
267 }
268 let header = serde_json::json!({"alg": algorithm(issuer.profile()), "typ": TYP, "ucv": UCV});
269 let input = format!(
270 "{}.{}",
271 BASE64URL.encode(header.to_string()),
272 BASE64URL.encode(Value::Object(claims).to_string())
273 );
274 let signature = issuer.sign(input.as_bytes()).map_err(CreateError::Sign)?;
275 Ok(format!("{input}.{}", BASE64URL.encode(signature)).into_bytes())
276}
277
278fn window_opens(o: &Options, now: i64) -> Result<(), CreateError> {
281 let max = now.saturating_add(MAX_LIFETIME);
282 if o.exp > max {
283 return Err(CreateError::ExpBeyondMaxLifetime {
284 exp: o.exp,
285 now,
286 max,
287 });
288 }
289 match o.nbf {
290 Some(nbf) if nbf >= o.exp => Err(CreateError::WindowNeverOpens { nbf, exp: o.exp }),
291 _ => Ok(()),
292 }
293}
294
295pub fn proof_id(token: &[u8]) -> String {
298 hex(&Sha384::digest(token))
299}
300
301pub fn authorize(token: &[u8], p: &Policy, ctx: &Context) -> Result<Map<String, Value>, Refusal> {
304 let mut leaf = token::Checked::parsed(token)?;
305 leaf.algorithm_is(ctx.profile)?;
306 leaf.signed_by_iss(ctx.profile)?;
307 leaf.audience_is(&ctx.caller)?;
308 leaf.valid_at(ctx.now)?;
309 let granted = capability::grants(p, ctx.request.as_ref(), &leaf.claims)?;
310 chain::chained(p, ctx, &leaf, granted)?;
311 Ok(leaf.claims)
312}
313
314pub(crate) fn now_seconds() -> i64 {
316 std::time::SystemTime::now()
317 .duration_since(std::time::UNIX_EPOCH)
318 .map(|d| d.as_secs() as i64)
319 .unwrap_or(0)
320}
321
322fn hex(bytes: &[u8]) -> String {
323 bytes.iter().map(|b| format!("{b:02x}")).collect()
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn a_window_beyond_the_max_lifetime_or_never_open_is_not_minted() {
332 let now = 1_790_000_000;
333 let at = |exp, nbf| Options {
334 exp,
335 nbf,
336 ..Options::default()
337 };
338 assert_eq!(window_opens(&at(now + MAX_LIFETIME, None), now), Ok(()));
339 assert_eq!(
340 window_opens(&at(now + MAX_LIFETIME + 1, None), now),
341 Err(CreateError::ExpBeyondMaxLifetime {
342 exp: now + MAX_LIFETIME + 1,
343 now,
344 max: now + MAX_LIFETIME
345 })
346 );
347 assert_eq!(window_opens(&at(now + 10, Some(now + 9)), now), Ok(()));
348 assert_eq!(
349 window_opens(&at(now + 10, Some(now + 10)), now),
350 Err(CreateError::WindowNeverOpens {
351 nbf: now + 10,
352 exp: now + 10
353 })
354 );
355 }
356
357 #[test]
358 fn the_max_lifetime_is_ten_years_of_365_25_days() {
359 assert_eq!(MAX_LIFETIME, 10 * 36525 * 24 * 3600 / 100);
360 }
361}