mod capability;
mod chain;
mod did_key;
mod first_wins;
mod token;
use std::collections::HashMap;
use std::fmt;
use base64::Engine;
use serde_json::{Map, Value};
use sha2::{Digest, Sha384};
use crate::node_key::{KeyError, NodeKey, Purpose};
use crate::profile::Profile;
pub use capability::covers;
pub use did_key::{carried_key, did_key};
pub const MAX_LIFETIME: i64 = 315_576_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Refusal {
Malformed,
WrongAlgorithm,
SignatureInvalid,
NotTheIssuer,
NotTheAudience,
Expired,
ExpBeyondMaxLifetime,
NotYetValid,
MissingCapability,
MissingProof,
UnreferencedProof,
NotTheDelegate,
ChainNotLinear,
GrantsMoreThanProof,
CanChanged,
WrongRealm,
RealmNameNotCanonical,
ProcedureWithoutOrg,
}
impl Refusal {
pub fn name(self) -> &'static str {
match self {
Refusal::Malformed => "malformed",
Refusal::WrongAlgorithm => "wrong_algorithm",
Refusal::SignatureInvalid => "signature_invalid",
Refusal::NotTheIssuer => "not_the_issuer",
Refusal::NotTheAudience => "not_the_audience",
Refusal::Expired => "expired",
Refusal::ExpBeyondMaxLifetime => "exp_beyond_max_lifetime",
Refusal::NotYetValid => "not_yet_valid",
Refusal::MissingCapability => "missing_capability",
Refusal::MissingProof => "missing_proof",
Refusal::UnreferencedProof => "unreferenced_proof",
Refusal::NotTheDelegate => "not_the_delegate",
Refusal::ChainNotLinear => "chain_not_linear",
Refusal::GrantsMoreThanProof => "grants_more_than_proof",
Refusal::CanChanged => "can_changed",
Refusal::WrongRealm => "wrong_realm",
Refusal::RealmNameNotCanonical => "realm_name_not_canonical",
Refusal::ProcedureWithoutOrg => "procedure_without_org",
}
}
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ucan: {}", self.name())
}
}
impl std::error::Error for Refusal {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Policy {
UcanRequired { issuer: [u8; 32] },
RealmMemberRequired { key_id: [u8; 32], can: String },
}
impl Policy {
pub fn valid(&self) -> bool {
match self {
Policy::UcanRequired { .. } => true,
Policy::RealmMemberRequired { can, .. } => !can.is_empty(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Request {
pub realm: [u8; 32],
pub procedure: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Context {
pub caller: [u8; 32],
pub profile: Profile,
pub now: i64,
pub request: Option<Request>,
pub proofs: HashMap<String, Vec<u8>>,
}
impl Context {
pub fn keyed_proofs(proofs: &[Vec<u8>]) -> HashMap<String, Vec<u8>> {
proofs.iter().map(|p| (proof_id(p), p.clone())).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capability {
pub with: String,
pub can: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Options {
pub exp: i64,
pub nbf: Option<i64>,
pub nnc: Option<String>,
pub fct: Option<Map<String, Value>>,
pub prf: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreateError {
NotAnIdentityKey(Purpose),
ExpBeyondMaxLifetime { exp: i64, now: i64, max: i64 },
WindowNeverOpens { nbf: i64, exp: i64 },
Sign(KeyError),
}
impl fmt::Display for CreateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CreateError::NotAnIdentityKey(purpose) => {
write!(
f,
"ucan: a token is signed by an identity key, not a {purpose} key"
)
}
CreateError::ExpBeyondMaxLifetime { exp, now, max } => write!(
f,
"ucan: exp_beyond_max_lifetime: exp {exp}, now {now}, at most {max} \
(milliseconds, where seconds are meant?)"
),
CreateError::WindowNeverOpens { nbf, exp } => write!(
f,
"ucan: nbf is not before exp, so the token is never valid: nbf {nbf}, exp {exp}"
),
CreateError::Sign(e) => write!(f, "ucan: {e}"),
}
}
}
impl std::error::Error for CreateError {}
const TYP: &str = "JWT";
const UCV: &str = "0.10.0";
const BASE64URL: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
fn algorithm(profile: Profile) -> &'static str {
match profile {
Profile::PqPure => "ML-DSA-87",
Profile::PqHybrid => "ML-DSA-87-PS384",
}
}
pub fn create(
issuer: &NodeKey,
audience: &[u8; 32],
caps: &[Capability],
o: &Options,
) -> Result<Vec<u8>, CreateError> {
if issuer.purpose() != Purpose::Identity {
return Err(CreateError::NotAnIdentityKey(issuer.purpose()));
}
window_opens(o, now_seconds())?;
let caps: Vec<Value> = caps
.iter()
.map(|c| serde_json::json!({"with": c.with, "can": c.can}))
.collect();
let mut claims = Map::new();
claims.insert(
"iss".into(),
Value::String(did_key(&issuer.public_key(), issuer.profile())),
);
claims.insert("aud".into(), Value::String(hex(audience)));
claims.insert("cap".into(), Value::Array(caps));
claims.insert("exp".into(), Value::from(o.exp));
if let Some(nbf) = o.nbf {
claims.insert("nbf".into(), Value::from(nbf));
}
if let Some(nnc) = o.nnc.as_ref().filter(|n| !n.is_empty()) {
claims.insert("nnc".into(), Value::String(nnc.clone()));
}
if let Some(fct) = &o.fct {
claims.insert("fct".into(), Value::Object(fct.clone()));
}
if let Some(prf) = &o.prf {
claims.insert(
"prf".into(),
Value::Array(prf.iter().cloned().map(Value::String).collect()),
);
}
let header = serde_json::json!({"alg": algorithm(issuer.profile()), "typ": TYP, "ucv": UCV});
let input = format!(
"{}.{}",
BASE64URL.encode(header.to_string()),
BASE64URL.encode(Value::Object(claims).to_string())
);
let signature = issuer.sign(input.as_bytes()).map_err(CreateError::Sign)?;
Ok(format!("{input}.{}", BASE64URL.encode(signature)).into_bytes())
}
fn window_opens(o: &Options, now: i64) -> Result<(), CreateError> {
let max = now.saturating_add(MAX_LIFETIME);
if o.exp > max {
return Err(CreateError::ExpBeyondMaxLifetime {
exp: o.exp,
now,
max,
});
}
match o.nbf {
Some(nbf) if nbf >= o.exp => Err(CreateError::WindowNeverOpens { nbf, exp: o.exp }),
_ => Ok(()),
}
}
pub fn proof_id(token: &[u8]) -> String {
hex(&Sha384::digest(token))
}
pub fn authorize(token: &[u8], p: &Policy, ctx: &Context) -> Result<Map<String, Value>, Refusal> {
let mut leaf = token::Checked::parsed(token)?;
leaf.algorithm_is(ctx.profile)?;
leaf.signed_by_iss(ctx.profile)?;
leaf.audience_is(&ctx.caller)?;
leaf.valid_at(ctx.now)?;
let granted = capability::grants(p, ctx.request.as_ref(), &leaf.claims)?;
chain::chained(p, ctx, &leaf, granted)?;
Ok(leaf.claims)
}
pub(crate) fn now_seconds() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_window_beyond_the_max_lifetime_or_never_open_is_not_minted() {
let now = 1_790_000_000;
let at = |exp, nbf| Options {
exp,
nbf,
..Options::default()
};
assert_eq!(window_opens(&at(now + MAX_LIFETIME, None), now), Ok(()));
assert_eq!(
window_opens(&at(now + MAX_LIFETIME + 1, None), now),
Err(CreateError::ExpBeyondMaxLifetime {
exp: now + MAX_LIFETIME + 1,
now,
max: now + MAX_LIFETIME
})
);
assert_eq!(window_opens(&at(now + 10, Some(now + 9)), now), Ok(()));
assert_eq!(
window_opens(&at(now + 10, Some(now + 10)), now),
Err(CreateError::WindowNeverOpens {
nbf: now + 10,
exp: now + 10
})
);
}
#[test]
fn the_max_lifetime_is_ten_years_of_365_25_days() {
assert_eq!(MAX_LIFETIME, 10 * 36525 * 24 * 3600 / 100);
}
}