1use std::collections::HashMap;
34use std::time::{SystemTime, UNIX_EPOCH};
35
36use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
37use serde::{Deserialize, Serialize};
38
39use crate::identity::{verify as identity_verify, KeyPair};
40
41const ALG: &str = "EdDSA";
42const TYP: &str = "JWT";
43const UCV: &str = "0.10.0";
44
45#[derive(Debug)]
47pub enum UcanError {
48 InvalidToken,
52 InvalidSignature,
55 InvalidPublicKey,
58 Expired,
60 NotYetValid,
63 NoToken,
68}
69
70impl std::fmt::Display for UcanError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 UcanError::InvalidToken => write!(f, "ucan: invalid token"),
74 UcanError::InvalidSignature => write!(f, "ucan: invalid signature"),
75 UcanError::InvalidPublicKey => write!(f, "ucan: invalid public key"),
76 UcanError::Expired => write!(f, "ucan: token expired"),
77 UcanError::NotYetValid => write!(f, "ucan: token not yet valid"),
78 UcanError::NoToken => write!(f, "ucan: no token presented for a gated procedure"),
79 }
80 }
81}
82
83impl std::error::Error for UcanError {}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Capability {
89 pub with: String,
90 pub can: String,
91}
92
93#[derive(Serialize, Deserialize)]
94struct Header {
95 alg: String,
96 typ: String,
97 ucv: String,
98}
99
100#[derive(Serialize, Deserialize)]
103struct WirePayload {
104 iss: String,
105 aud: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 exp: Option<i64>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 nbf: Option<i64>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 nnc: Option<String>,
112 cap: Vec<Capability>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 fct: Option<HashMap<String, serde_json::Value>>,
115 prf: Vec<String>,
116}
117
118#[derive(Debug, Clone, PartialEq)]
121pub struct Payload {
122 pub issuer: String,
123 pub audience: String,
124 pub capabilities: Vec<Capability>,
125 pub expires_at: Option<i64>,
126 pub not_before: Option<i64>,
127 pub nonce: String,
128 pub facts: Option<HashMap<String, serde_json::Value>>,
129 pub proofs: Vec<String>,
130}
131
132#[derive(Debug, Clone, Default)]
135pub struct CreateOpts {
136 pub expires_at: Option<i64>,
137 pub not_before: Option<i64>,
138 pub nonce: Option<String>,
139 pub facts: Option<HashMap<String, serde_json::Value>>,
140 pub proofs: Option<Vec<String>>,
141}
142
143pub fn create(
152 issuer: &str,
153 audience: &str,
154 capabilities: Vec<Capability>,
155 id: &KeyPair,
156 opts: CreateOpts,
157) -> Result<Vec<u8>, UcanError> {
158 let payload = WirePayload {
159 iss: issuer.to_string(),
160 aud: audience.to_string(),
161 exp: opts.expires_at,
162 nbf: opts.not_before,
163 nnc: opts.nonce,
164 cap: capabilities,
165 fct: opts.facts,
166 prf: opts.proofs.unwrap_or_default(),
167 };
168
169 let header_json = serde_json::to_vec(&Header {
170 alg: ALG.into(),
171 typ: TYP.into(),
172 ucv: UCV.into(),
173 })
174 .map_err(|_| UcanError::InvalidToken)?;
175 let payload_json = serde_json::to_vec(&payload).map_err(|_| UcanError::InvalidToken)?;
176 let header_b64 = URL_SAFE_NO_PAD.encode(header_json);
177 let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json);
178 let signing_input = format!("{header_b64}.{payload_b64}");
179 let sig = id.sign(signing_input.as_bytes());
180 let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
181 Ok(format!("{signing_input}.{sig_b64}").into_bytes())
182}
183
184fn split_token(token: &[u8]) -> Result<(&str, &str, &str), UcanError> {
185 let text = std::str::from_utf8(token).map_err(|_| UcanError::InvalidToken)?;
186 let mut parts = text.split('.');
187 let (Some(h), Some(p), Some(s), None) =
188 (parts.next(), parts.next(), parts.next(), parts.next())
189 else {
190 return Err(UcanError::InvalidToken);
191 };
192 Ok((h, p, s))
193}
194
195fn decode_payload(payload_b64: &str) -> Result<Payload, UcanError> {
196 let raw = URL_SAFE_NO_PAD
197 .decode(payload_b64)
198 .map_err(|_| UcanError::InvalidToken)?;
199 let wp: WirePayload = serde_json::from_slice(&raw).map_err(|_| UcanError::InvalidToken)?;
200 Ok(Payload {
201 issuer: wp.iss,
202 audience: wp.aud,
203 capabilities: wp.cap,
204 expires_at: wp.exp,
205 not_before: wp.nbf,
206 nonce: wp.nnc.unwrap_or_default(),
207 facts: wp.fct,
208 proofs: wp.prf,
209 })
210}
211
212pub fn decode(token: &[u8]) -> Result<Payload, UcanError> {
217 let (_, payload_b64, _) = split_token(token)?;
218 decode_payload(payload_b64)
219}
220
221fn now_unix() -> i64 {
222 SystemTime::now()
223 .duration_since(UNIX_EPOCH)
224 .map(|d| d.as_secs() as i64)
225 .unwrap_or(0)
226}
227
228pub fn verify(token: &[u8], public_key: &[u8; 32]) -> Result<Payload, UcanError> {
238 let (header_b64, payload_b64, sig_b64) = split_token(token)?;
239 let payload = decode_payload(payload_b64)?;
240 let now = now_unix();
241 if let Some(exp) = payload.expires_at {
242 if now > exp {
243 return Err(UcanError::Expired);
244 }
245 }
246 if let Some(nbf) = payload.not_before {
247 if now < nbf {
248 return Err(UcanError::NotYetValid);
249 }
250 }
251 let sig_bytes = URL_SAFE_NO_PAD
252 .decode(sig_b64)
253 .map_err(|_| UcanError::InvalidToken)?;
254 let sig: [u8; 64] = sig_bytes.try_into().map_err(|_| UcanError::InvalidToken)?;
255 let signing_input = format!("{header_b64}.{payload_b64}");
256 if !identity_verify(signing_input.as_bytes(), &sig, public_key) {
257 return Err(UcanError::InvalidSignature);
258 }
259 Ok(payload)
260}
261
262pub fn compute_cid(token: &[u8]) -> String {
268 use sha2::{Digest, Sha256};
269 let mut hasher = Sha256::new();
270 hasher.update(token);
271 URL_SAFE_NO_PAD.encode(hasher.finalize())
272}
273
274pub fn get_issuer(token: &[u8]) -> Result<String, UcanError> {
277 decode(token).map(|p| p.issuer)
278}
279
280pub fn get_audience(token: &[u8]) -> Result<String, UcanError> {
283 decode(token).map(|p| p.audience)
284}
285
286pub fn get_capabilities(token: &[u8]) -> Result<Vec<Capability>, UcanError> {
289 decode(token).map(|p| p.capabilities)
290}
291
292pub fn get_expiration(token: &[u8]) -> Result<Option<i64>, UcanError> {
295 decode(token).map(|p| p.expires_at)
296}
297
298pub fn get_proofs(token: &[u8]) -> Result<Vec<String>, UcanError> {
301 decode(token).map(|p| p.proofs)
302}
303
304pub fn is_expired(token: &[u8]) -> Result<bool, UcanError> {
308 let payload = decode(token)?;
309 Ok(match payload.expires_at {
310 Some(exp) => now_unix() > exp,
311 None => false,
312 })
313}
314
315#[derive(Debug, Clone, Default)]
330pub struct Policy {
331 pub gated: bool,
332 pub required_issuer: [u8; 32],
333}
334
335impl Policy {
336 pub fn open() -> Self {
339 Self::default()
340 }
341
342 pub fn required(issuer_public_key: [u8; 32]) -> Self {
346 Self {
347 gated: true,
348 required_issuer: issuer_public_key,
349 }
350 }
351
352 pub fn check(&self, ucan_token: &[u8]) -> Result<(), UcanError> {
357 if !self.gated {
358 return Ok(());
359 }
360 if ucan_token.is_empty() {
361 return Err(UcanError::NoToken);
362 }
363 verify(ucan_token, &self.required_issuer).map(|_| ())
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 fn keypair() -> KeyPair {
372 KeyPair::generate()
373 }
374
375 #[test]
376 fn create_and_verify_round_trip() {
377 let id = keypair();
378 let token = create(
379 "did:macula:issuer",
380 "did:macula:audience",
381 vec![Capability {
382 with: "mri:x".into(),
383 can: "read".into(),
384 }],
385 &id,
386 CreateOpts::default(),
387 )
388 .unwrap();
389 let payload = verify(&token, &id.node_id()).unwrap();
390 assert_eq!(payload.issuer, "did:macula:issuer");
391 assert_eq!(payload.audience, "did:macula:audience");
392 assert_eq!(
393 payload.capabilities,
394 vec![Capability {
395 with: "mri:x".into(),
396 can: "read".into()
397 }]
398 );
399 }
400
401 #[test]
402 fn verify_rejects_tampered_payload() {
403 let id = keypair();
404 let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
405 let mut text = String::from_utf8(token).unwrap();
406 let parts: Vec<&str> = text.split('.').collect();
410 let mut payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
411 payload_bytes[0] ^= 0xFF;
412 let tampered_payload = URL_SAFE_NO_PAD.encode(payload_bytes);
413 text = format!("{}.{}.{}", parts[0], tampered_payload, parts[2]);
414 let err = verify(text.as_bytes(), &id.node_id()).unwrap_err();
415 assert!(matches!(
416 err,
417 UcanError::InvalidToken | UcanError::InvalidSignature
418 ));
419 }
420
421 #[test]
422 fn verify_rejects_wrong_signer() {
423 let id = keypair();
424 let other = keypair();
425 let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
426 let err = verify(&token, &other.node_id()).unwrap_err();
427 assert!(matches!(err, UcanError::InvalidSignature));
428 }
429
430 #[test]
431 fn verify_rejects_expired() {
432 let id = keypair();
433 let opts = CreateOpts {
434 expires_at: Some(now_unix() - 60),
435 ..Default::default()
436 };
437 let token = create("iss", "aud", vec![], &id, opts).unwrap();
438 let err = verify(&token, &id.node_id()).unwrap_err();
439 assert!(matches!(err, UcanError::Expired));
440 }
441
442 #[test]
443 fn verify_rejects_not_yet_valid() {
444 let id = keypair();
445 let opts = CreateOpts {
446 not_before: Some(now_unix() + 3600),
447 ..Default::default()
448 };
449 let token = create("iss", "aud", vec![], &id, opts).unwrap();
450 let err = verify(&token, &id.node_id()).unwrap_err();
451 assert!(matches!(err, UcanError::NotYetValid));
452 }
453
454 #[test]
455 fn decode_does_not_check_signature() {
456 let id = keypair();
457 let other = keypair();
458 let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
459 let payload = decode(&token).unwrap();
463 assert_eq!(payload.issuer, "iss");
464 let _ = other; }
466
467 #[test]
468 fn getters_match_created_claims() {
469 let id = keypair();
470 let caps = vec![Capability {
471 with: "mri:x".into(),
472 can: "write".into(),
473 }];
474 let opts = CreateOpts {
475 expires_at: Some(now_unix() + 3600),
476 proofs: Some(vec!["parent-cid".into()]),
477 ..Default::default()
478 };
479 let token = create("did:iss", "did:aud", caps.clone(), &id, opts).unwrap();
480 assert_eq!(get_issuer(&token).unwrap(), "did:iss");
481 assert_eq!(get_audience(&token).unwrap(), "did:aud");
482 assert_eq!(get_capabilities(&token).unwrap(), caps);
483 assert!(get_expiration(&token).unwrap().is_some());
484 assert_eq!(get_proofs(&token).unwrap(), vec!["parent-cid".to_string()]);
485 assert!(!is_expired(&token).unwrap());
486 }
487
488 #[test]
489 fn is_expired_true_for_past_exp() {
490 let id = keypair();
491 let opts = CreateOpts {
492 expires_at: Some(now_unix() - 1),
493 ..Default::default()
494 };
495 let token = create("iss", "aud", vec![], &id, opts).unwrap();
496 assert!(is_expired(&token).unwrap());
499 }
500
501 #[test]
502 fn is_expired_false_with_no_exp_claim() {
503 let id = keypair();
504 let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
505 assert!(!is_expired(&token).unwrap());
506 }
507
508 #[test]
509 fn cid_is_deterministic_and_content_addressed() {
510 let id = keypair();
511 let token_a = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap();
512 assert_eq!(compute_cid(&token_a), compute_cid(&token_a));
513 let token_b = create("iss2", "aud", vec![], &id, CreateOpts::default()).unwrap();
514 assert_ne!(compute_cid(&token_a), compute_cid(&token_b));
515 }
516
517 #[test]
518 fn policy_open_never_requires_a_token() {
519 let policy = Policy::open();
520 assert!(policy.check(&[]).is_ok());
521 }
522
523 #[test]
524 fn policy_required_rejects_empty_token() {
525 let id = keypair();
526 let policy = Policy::required(id.node_id());
527 assert!(matches!(policy.check(&[]).unwrap_err(), UcanError::NoToken));
528 }
529
530 #[test]
531 fn policy_required_accepts_valid_token_from_the_right_issuer() {
532 let id = keypair();
533 let token = create("did:iss", "did:aud", vec![], &id, CreateOpts::default()).unwrap();
534 let policy = Policy::required(id.node_id());
535 assert!(policy.check(&token).is_ok());
536 }
537
538 #[test]
539 fn policy_required_rejects_token_from_the_wrong_issuer() {
540 let id = keypair();
541 let impostor = keypair();
542 let token = create(
543 "did:iss",
544 "did:aud",
545 vec![],
546 &impostor,
547 CreateOpts::default(),
548 )
549 .unwrap();
550 let policy = Policy::required(id.node_id());
551 assert!(matches!(
552 policy.check(&token).unwrap_err(),
553 UcanError::InvalidSignature
554 ));
555 }
556}