diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4b9c5..bc6f68f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-09-02 + +Adds the two credentials that confer rather than assert: the **VAC** (verifiable authority +credential) and the **VDC** (verifiable delegation credential). Both track drafts — +`trustoverip/dtgwg-cred-spec` PR #29 and #19 respectively — and their shapes may move before +those are approved. They are marked as such in the API docs. + +### Added + +- `DTGCredentialType::{Authority, Delegation}`, `CredentialSubject::Authority`, and the + `AuthorityGrant` object it carries: `scope`, `actions`, and the optional `parent` and + `audience` that make attenuation work. +- `DTGCredential::{new_vac, new_vdc}`, following the existing `new_v*` constructors. +- `DTGCredential::attenuate` — derive a narrower VAC from one you hold, without the issuer. + This is what lets a member equip an agent with four hours of read-only access instead of + lending it their own standing authority. +- `authority::verify_chain` — **the part that matters.** Issuing a VAC is a struct and a + signature; what stops a holder acquiring authority they were never given is a verifier + refusing a chain that widens. Anyone can mint a well-formed VAC naming any scope and any + actions, and it will verify perfectly as a *credential*. What makes it worthless is that + its chain does not reach the party governing the scope. + + Seven rules, each closing one way of getting more than was granted: the chain must reach a + root issued by the governing party; no link may add an action, widen scope, or outlive its + parent; each link's issuer must be its parent's subject (so a grant cannot be grafted onto + someone else's chain); `audience`, where set, must be the presenter; and depth is bounded + at 8, because verification is linear and runs on every presentation. + +- `DTGCommon::{authority, authority_mut}` accessors. The mutable one exists so tests can + build chains `attenuate` would refuse — nothing stops another implementation emitting such + JSON, so the verifier must be tested against it directly. + +### Notes + +- **Resolution is bearer-side.** `verify_chain` takes the whole chain as a slice and never + dereferences `parent` to fetch a link it was not given. Deliberate: resolving over the + network would make verification depend on availability, turn every `id` into a request the + verifier can be induced to make against an address the *holder* chooses, and signal + credential use to whoever hosts the identifier. `id` values are identifiers, not locators. +- **An empty `actions` list confers nothing, not everything.** Refused by the constructor and + at the deserialization boundary, so it cannot be reached either way. +- `DTGCredentialType` is `#[non_exhaustive]`, so the two new variants are not a breaking + change for callers matching with a wildcard arm. Callers matching exhaustively need one arm + each. + + ## [0.5.0] - 2026-08-30 `new_member_vmc` in 0.4.0 took a parsed `DTGCredential` and digested it. That is wrong for diff --git a/Cargo.toml b/Cargo.toml index 73d2258..5c43c91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dtg-credentials" description = "Decentralized Trust Graph (DTG) Credentials Library" -version = "0.5.0" +version = "0.6.0" edition = "2024" publish = true authors = ["Glenn Gore "] diff --git a/src/authority.rs b/src/authority.rs new file mode 100644 index 0000000..a5c1a10 --- /dev/null +++ b/src/authority.rs @@ -0,0 +1,381 @@ +//! Verifying a chain of Verifiable Authority Credentials. +//! +//! # Why this module is the important one +//! +//! Issuing a VAC is a struct and a signature. The security of the whole credential is in +//! *refusing* a chain that widens — because attenuation is only a narrowing if somebody +//! walks it. A verifier that checks only the credential it was handed accepts a +//! **self-issued grant of arbitrary authority**: anyone can mint a VAC naming any scope and +//! any actions, and it will verify perfectly as a signed credential. What makes it +//! worthless is that its chain does not reach the party governing the scope. +//! +//! So the rules below are not stylistic. Each of them closes a way to get authority you +//! were not given: +//! +//! | Rule | What it stops | +//! |---|---| +//! | Chain must reach a root issued by the governing party | a self-issued grant | +//! | No link may add an action absent from its parent | privilege escalation by re-issue | +//! | No link may widen `scope` | authority earned in one room used in another | +//! | No link may outlive its parent | an expiry escaped by re-delegation | +//! | Each link's issuer must be its parent's subject | grafting someone else's grant onto your own | +//! | `audience`, where set, must be the presenter | a leaked credential used by whoever holds it | +//! | Depth is bounded | a denial-of-service against the verifier, which walks every link | +//! +//! # Bearer-side resolution +//! +//! The holder presents every link. This module **never dereferences** +//! [`AuthorityGrant::parent`] to fetch a credential it was not given, and +//! [`verify_chain`] takes the chain as a slice for exactly that reason. +//! +//! Deliberate, and worth stating because the alternative is attractive until it isn't: +//! resolving parents over the network would make verification depend on availability, turn +//! every `id` into a request the verifier can be induced to make against an address the +//! *holder* chooses, and signal credential use to whoever hosts the identifier. `id` values +//! in a chain are identifiers, not locators, and need not resolve to anything. +//! +//! Tracks a draft: `trustoverip/dtgwg-cred-spec` PR #29. + +use chrono::{DateTime, Utc}; + +use crate::{DTGCredential, DTGCredentialType}; + +/// Maximum number of VACs in a chain, including the root. +/// +/// Verification is linear in depth and runs on every presentation, so an unbounded chain is +/// a denial-of-service surface. The known uses need far less — a person attenuating to an +/// agent is depth 2, and an agent attenuating to a sub-agent is depth 3 — so a chain near +/// this ceiling is a signal that authority is being re-delegated further than intended. +pub const MAX_CHAIN_DEPTH: usize = 8; + +/// Why a chain was refused. +/// +/// Each variant names a specific way of acquiring authority that was not granted, rather +/// than collapsing into one "invalid" — a verifier's logs are where an escalation attempt +/// becomes visible. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum AuthorityError { + /// The chain was empty. Nothing to verify. + #[error("authority chain is empty")] + EmptyChain, + + /// The chain is longer than [MAX_CHAIN_DEPTH]. + #[error("authority chain is {found} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}")] + TooDeep { + /// How many links were presented. + found: usize, + }, + + /// A credential in the chain was not an `AuthorityCredential`. + #[error("chain link {index} is a {found}, not an AuthorityCredential")] + NotAuthority { + /// Position in the chain, leaf first. + index: usize, + /// What was found instead. + found: String, + }, + + /// The chain root was not issued by the party governing the scope. + /// + /// This is the finding that matters most: a chain that does not reach the governing + /// party is a self-issued grant, however well-formed each link is. + #[error( + "chain root was issued by `{root_issuer}`, not by `{expected}` which governs the scope" + )] + RootNotGoverning { + /// Who actually issued the root. + root_issuer: String, + /// Who governs the scope being accessed. + expected: String, + }, + + /// A link's `parent` did not name the credential presented as its parent. + #[error("chain link {index} names parent `{named}`, but was presented after `{presented}`")] + BrokenLink { + /// Position in the chain, leaf first. + index: usize, + /// The `id` the link points at. + named: String, + /// The `id` of the credential actually presented as its parent. + presented: String, + }, + + /// A link was issued by someone other than its parent's subject. + /// + /// Only the party a grant was made to may attenuate it. Without this check a holder + /// could graft an unrelated grant onto their own chain. + #[error("chain link {index} was issued by `{issuer}`, but its parent granted to `{subject}`")] + IssuerNotParentSubject { + /// Position in the chain, leaf first. + index: usize, + /// Who issued the link. + issuer: String, + /// Who the parent granted to. + subject: String, + }, + + /// A link conferred an action its parent did not. + #[error("chain link {index} adds action `{action}`, which its parent does not confer")] + WidensActions { + /// Position in the chain, leaf first. + index: usize, + /// The action that was added. + action: String, + }, + + /// A link named a different scope from its parent. + #[error("chain link {index} has scope `{scope}`, its parent `{parent_scope}`")] + WidensScope { + /// Position in the chain, leaf first. + index: usize, + /// The link's scope. + scope: String, + /// The parent's scope. + parent_scope: String, + }, + + /// A link outlived its parent. + #[error("chain link {index} is valid until {until}, beyond its parent's {parent_until}")] + OutlivesParent { + /// Position in the chain, leaf first. + index: usize, + /// The link's expiry. + until: DateTime, + /// The parent's expiry. + parent_until: DateTime, + }, + + /// The requested scope is not the one the chain confers on. + #[error("chain confers on scope `{granted}`, but `{requested}` was requested")] + ScopeMismatch { + /// What the chain grants on. + granted: String, + /// What was asked for. + requested: String, + }, + + /// The chain does not confer the requested action. + #[error("chain does not confer action `{action}`")] + ActionNotGranted { + /// The action that was requested. + action: String, + }, + + /// A link was presented by a party other than its bound audience. + #[error("chain link {index} is bound to audience `{audience}`, presented by `{presenter}`")] + WrongAudience { + /// Position in the chain, leaf first. + index: usize, + /// Who the link is bound to. + audience: String, + /// Who presented it. + presenter: String, + }, + + /// A link was outside its validity window at the time of the check. + #[error("chain link {index} is not valid at {at}")] + NotValidNow { + /// Position in the chain, leaf first. + index: usize, + /// The instant checked against. + at: DateTime, + }, + + /// A link carried an empty `actions` list. + #[error("chain link {index} confers no actions")] + NoActions { + /// Position in the chain, leaf first. + index: usize, + }, +} + +/// What a verified chain permits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedAuthority { + /// The party the leaf grants to — who may act. + pub subject: String, + /// The scope the chain confers on. + pub scope: String, + /// The actions the leaf confers, already narrowed by every link above it. + pub actions: Vec, + /// The party governing the scope, which issued the chain root. + pub governing_party: String, +} + +/// Verify a chain of VACs and return what it permits. +/// +/// `chain` is **leaf first**: `chain[0]` is the credential being presented, and the last +/// element must be the root issued by `governing_party`. Every link the holder relies on +/// must be present — this function never fetches one (see the module docs). +/// +/// The signature on each credential is *not* checked here. Verify those first, with +/// [crate::DTGCredential] and the data-integrity suite; this function answers the separate +/// question of whether a set of cryptographically valid credentials adds up to the +/// authority claimed. Both checks are required and neither substitutes for the other. +/// +/// Returns [VerifiedAuthority] describing what the chain actually permits, which is never +/// more than the root conferred. +pub fn verify_chain( + chain: &[DTGCredential], + governing_party: &str, + requested_scope: &str, + requested_action: &str, + presenter: &str, + at: DateTime, +) -> Result { + if chain.is_empty() { + return Err(AuthorityError::EmptyChain); + } + if chain.len() > MAX_CHAIN_DEPTH { + return Err(AuthorityError::TooDeep { found: chain.len() }); + } + + // Every link must be a VAC carrying a grant. + for (index, link) in chain.iter().enumerate() { + if !matches!(link.type_(), DTGCredentialType::Authority) { + return Err(AuthorityError::NotAuthority { + index, + found: link.type_().to_string(), + }); + } + let grant = link + .credential() + .authority() + .ok_or_else(|| AuthorityError::NotAuthority { + index, + found: "AuthorityCredential without an authority grant".to_string(), + })?; + if grant.actions.is_empty() { + return Err(AuthorityError::NoActions { index }); + } + // Validity window, checked per link: a chain is only as live as its shortest-lived + // member, and an expired parent does not become live again because its child says so. + let c = link.credential(); + if c.valid_from() > at { + return Err(AuthorityError::NotValidNow { index, at }); + } + if let Some(until) = c.valid_until() + && until < at + { + return Err(AuthorityError::NotValidNow { index, at }); + } + } + + // The leaf must be presentable by whoever is presenting it. + let leaf = &chain[0]; + let leaf_grant = leaf.credential().authority().expect("checked above"); + if let Some(audience) = &leaf_grant.audience + && audience != presenter + { + return Err(AuthorityError::WrongAudience { + index: 0, + audience: audience.clone(), + presenter: presenter.to_string(), + }); + } + + // Walk leaf -> root. Each step checks the link against the credential above it. + for index in 0..chain.len() - 1 { + let link = &chain[index]; + let parent = &chain[index + 1]; + let grant = link.credential().authority().expect("checked above"); + let parent_grant = parent.credential().authority().expect("checked above"); + + // The link must point at the credential presented as its parent. Without this a + // holder could interleave links from unrelated chains. + match (&grant.parent, parent.id()) { + (Some(named), Some(presented)) if named == presented => {} + (Some(named), presented) => { + return Err(AuthorityError::BrokenLink { + index, + named: named.clone(), + presented: presented.unwrap_or("").to_string(), + }); + } + (None, presented) => { + // A link with no `parent` claims to be a root, but something was presented + // above it. + return Err(AuthorityError::BrokenLink { + index, + named: "".to_string(), + presented: presented.unwrap_or("").to_string(), + }); + } + } + + // Only the party a grant was made to may attenuate it. + if link.credential().issuer() != parent.credential().subject() { + return Err(AuthorityError::IssuerNotParentSubject { + index, + issuer: link.credential().issuer().to_string(), + subject: parent.credential().subject().to_string(), + }); + } + + // Narrowing, on all three axes. + if grant.scope != parent_grant.scope { + return Err(AuthorityError::WidensScope { + index, + scope: grant.scope.clone(), + parent_scope: parent_grant.scope.clone(), + }); + } + for action in &grant.actions { + if !parent_grant.actions.contains(action) { + return Err(AuthorityError::WidensActions { + index, + action: action.clone(), + }); + } + } + if let (Some(until), Some(parent_until)) = ( + link.credential().valid_until(), + parent.credential().valid_until(), + ) && until > parent_until + { + return Err(AuthorityError::OutlivesParent { + index, + until, + parent_until, + }); + } + } + + // The root must be the governing party's, and must claim to be a root. + let root = chain.last().expect("non-empty"); + let root_grant = root.credential().authority().expect("checked above"); + if root.credential().issuer() != governing_party { + return Err(AuthorityError::RootNotGoverning { + root_issuer: root.credential().issuer().to_string(), + expected: governing_party.to_string(), + }); + } + if root_grant.parent.is_some() { + // The chain was truncated: its "root" points at something not presented. + return Err(AuthorityError::BrokenLink { + index: chain.len() - 1, + named: root_grant.parent.clone().unwrap_or_default(), + presented: "".to_string(), + }); + } + + // Finally, what was asked for. + if leaf_grant.scope != requested_scope { + return Err(AuthorityError::ScopeMismatch { + granted: leaf_grant.scope.clone(), + requested: requested_scope.to_string(), + }); + } + if !leaf_grant.actions.iter().any(|a| a == requested_action) { + return Err(AuthorityError::ActionNotGranted { + action: requested_action.to_string(), + }); + } + + Ok(VerifiedAuthority { + subject: leaf.credential().subject().to_string(), + scope: leaf_grant.scope.clone(), + actions: leaf_grant.actions.clone(), + governing_party: governing_party.to_string(), + }) +} diff --git a/src/create.rs b/src/create.rs index 95208d7..d490ba8 100644 --- a/src/create.rs +++ b/src/create.rs @@ -4,9 +4,10 @@ #[allow(deprecated)] use crate::{ - CredentialSubject, CredentialSubjectBasic, CredentialSubjectEndorsement, - CredentialSubjectMembership, CredentialSubjectRCard, CredentialSubjectWitness, DTGCommon, - DTGCredential, DTGCredentialError, DTGCredentialType, WitnessContext, + AuthorityGrant, CredentialSubject, CredentialSubjectAuthority, CredentialSubjectBasic, + CredentialSubjectEndorsement, CredentialSubjectMembership, CredentialSubjectRCard, + CredentialSubjectWitness, DTGCommon, DTGCredential, DTGCredentialError, DTGCredentialType, + WitnessContext, }; use chrono::{DateTime, Utc}; use serde_json::Value; @@ -240,6 +241,164 @@ impl DTGCredential { } } + /// Creates a new Verifiable Authority Credential (VAC) — a chain root. + /// + /// The issuer is the party governing `scope`. To derive a narrower VAC from one you + /// already hold, use [DTGCredential::attenuate] instead: a chain root is a grant made + /// by the governing party, and minting one directly is how a self-issued grant of + /// arbitrary authority gets in. + /// + /// `actions` MUST NOT be empty — an empty list confers nothing rather than everything. + /// + /// Tracks a draft (`trustoverip/dtgwg-cred-spec` PR #29); the shape may move. + pub fn new_vac( + issuer: String, + subject: String, + scope: String, + actions: Vec, + valid_from: DateTime, + valid_until: Option>, + ) -> Result { + if actions.is_empty() { + return Err(DTGCredentialError::EmptyAuthorityActions); + } + let mut vac = DTGCommon { + issuer, + valid_from, + valid_until, + credential_subject: CredentialSubject::Authority(CredentialSubjectAuthority { + id: subject, + authority: AuthorityGrant { + scope, + actions, + parent: None, + audience: None, + }, + }), + ..Default::default() + }; + + vac.type_.push(DTGCredentialType::Authority.to_string()); + + Ok(DTGCredential { + credential: vac, + type_: DTGCredentialType::Authority, + version: crate::W3CVCVersion::V2_0, + }) + } + + /// Derive a narrower VAC from one this holder already holds. + /// + /// This is what lets a member equip an agent, a device, or a short-lived session with + /// only the authority that task needs, rather than lending it their own. The derived + /// credential is issued by the *holder*, not by the party governing the scope, and + /// carries `parent` so a verifier can walk back to a root. + /// + /// Refuses anything that would widen. The checks here mirror + /// [crate::authority::verify_chain] on purpose: a holder should be unable to *build* a + /// chain a verifier would reject, so the failure surfaces at issue time rather than at + /// use — but the verifier's checks remain authoritative, because nothing stops a + /// different implementation constructing the JSON by hand. + /// + /// - `self` must be a VAC, and must carry an `id` (a parent with no identifier cannot + /// be pointed at). + /// - `actions` must be a subset of what `self` confers. + /// - `valid_until` must not exceed `self`'s. + /// - `audience` binds the derived credential to one presenter; strongly recommended + /// when equipping an agent, since it makes a leaked credential useless to anyone else. + pub fn attenuate( + &self, + subject: String, + actions: Vec, + valid_from: DateTime, + valid_until: Option>, + audience: Option, + ) -> Result { + let parent_grant = self + .credential() + .authority() + .ok_or(DTGCredentialError::NotAnAuthorityCredential)?; + + let parent_id = self + .id() + .ok_or(DTGCredentialError::AttenuationParentHasNoId)? + .to_string(); + + if actions.is_empty() { + return Err(DTGCredentialError::EmptyAuthorityActions); + } + for action in &actions { + if !parent_grant.actions.contains(action) { + return Err(DTGCredentialError::AttenuationWidens(format!( + "action `{action}` is not conferred by the parent" + ))); + } + } + if let (Some(until), Some(parent_until)) = (valid_until, self.credential().valid_until()) + && until > parent_until + { + return Err(DTGCredentialError::AttenuationWidens(format!( + "validUntil {until} is beyond the parent's {parent_until}" + ))); + } + + let mut vac = DTGCommon { + // The holder issues: they are the subject of the parent grant. + issuer: self.credential().subject().to_string(), + valid_from, + valid_until, + credential_subject: CredentialSubject::Authority(CredentialSubjectAuthority { + id: subject, + authority: AuthorityGrant { + // Scope never changes down a chain. + scope: parent_grant.scope.clone(), + actions, + parent: Some(parent_id), + audience, + }, + }), + ..Default::default() + }; + + vac.type_.push(DTGCredentialType::Authority.to_string()); + + Ok(DTGCredential { + credential: vac, + type_: DTGCredentialType::Authority, + version: crate::W3CVCVersion::V2_0, + }) + } + + /// Creates a new Verifiable Delegation Credential (VDC). + /// + /// Establishes that `subject` may act **in the issuer's name**. This is not authority: + /// a VDC never supplies permission the delegator did not itself hold, and a verifier + /// must settle the two questions separately. See [DTGCredential::new_vac]. + /// + /// Tracks a draft (`trustoverip/dtgwg-cred-spec` PR #19); the shape may move. + pub fn new_vdc( + issuer: String, + subject: String, + valid_from: DateTime, + valid_until: Option>, + ) -> Self { + let mut vdc = DTGCommon { + issuer, + valid_from, + valid_until, + credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }), + ..Default::default() + }; + + vdc.type_.push(DTGCredentialType::Delegation.to_string()); + + DTGCredential { + credential: vdc, + type_: DTGCredentialType::Delegation, + version: crate::W3CVCVersion::V2_0, + } + } + /// Creates a new Verified Persona Credential (VPC) /// issuer: The issuer DID of the credential /// subject: The DID of the subject of this credential diff --git a/src/lib.rs b/src/lib.rs index 0fd612d..b6460fc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ use sha2::{Digest, Sha256}; use std::fmt::Display; use thiserror::Error; +pub mod authority; pub mod create; /// What W3C VC Format is the credential using? @@ -57,6 +58,29 @@ pub enum DTGCredentialError { #[error("Unknown W3C VC Version")] UnknownVCVersion, + /// An AuthorityCredential (VAC) carried an empty `actions` list. + /// + /// Emptiness is never a wildcard: a VAC conferring no actions confers nothing, and is + /// rejected rather than treated as unrestricted. + #[error("AuthorityCredential carries an empty actions list, which confers nothing")] + EmptyAuthorityActions, + + /// [DTGCredential::attenuate] was called on a credential that is not a VAC. + #[error("not an AuthorityCredential, so there is no authority to attenuate")] + NotAnAuthorityCredential, + + /// [DTGCredential::attenuate] was called on a VAC with no `id`. + /// + /// A derived credential points at its parent by `id`; a parent without one cannot be + /// pointed at, so the chain could never be verified. Set one with + /// [DTGCredential::with_id] before attenuating. + #[error("cannot attenuate a credential with no id — the derived VAC could not name it")] + AttenuationParentHasNoId, + + /// An attenuation attempted to confer more than its parent held. + #[error("attenuation would widen the parent grant: {0}")] + AttenuationWidens(String), + /// A WitnessCredential (VWC) was missing the REQUIRED `taskContext` property #[error("WitnessCredential is missing the required taskContext property")] MissingTaskContext, @@ -414,6 +438,20 @@ pub enum DTGCredentialType { Endorsement, Witness, + /// Verifiable Authority Credential (VAC) — confers authority on a party to perform + /// specified actions within a named scope governed by the issuer. + /// + /// Tracks a draft: `trustoverip/dtgwg-cred-spec` PR #29. The shape may move before the + /// specification is approved. + Authority, + + /// Verifiable Delegation Credential (VDC) — establishes that one entity may act in + /// another's name. + /// + /// Tracks a draft: `trustoverip/dtgwg-cred-spec` PR #19. The shape may move before the + /// specification is approved. + Delegation, + /// R-Card is no longer a DTG credential type. #[deprecated( since = "0.2.0", @@ -435,19 +473,23 @@ impl Display for DTGCredentialType { DTGCredentialType::Persona => write!(f, "PersonaCredential"), DTGCredentialType::Endorsement => write!(f, "EndorsementCredential"), DTGCredentialType::Witness => write!(f, "WitnessCredential"), + DTGCredentialType::Authority => write!(f, "AuthorityCredential"), + DTGCredentialType::Delegation => write!(f, "DelegationCredential"), DTGCredentialType::RCard => write!(f, "RCardCredential"), } } } /// This helps with matching the right credential type to the [DTGCredentialType] -const DTG_TYPES: [&str; 7] = [ +const DTG_TYPES: [&str; 9] = [ "MembershipCredential", "RelationshipCredential", "InvitationCredential", "PersonaCredential", "EndorsementCredential", "WitnessCredential", + "AuthorityCredential", + "DelegationCredential", "RCardCredential", ]; @@ -464,6 +506,8 @@ impl TryFrom<&[String]> for DTGCredentialType { "PersonaCredential" => Ok(DTGCredentialType::Persona), "EndorsementCredential" => Ok(DTGCredentialType::Endorsement), "WitnessCredential" => Ok(DTGCredentialType::Witness), + "AuthorityCredential" => Ok(DTGCredentialType::Authority), + "DelegationCredential" => Ok(DTGCredentialType::Delegation), "RCardCredential" => Ok(DTGCredentialType::RCard), _ => Err(DTGCredentialError::UnknownCredential), } @@ -572,10 +616,35 @@ impl DTGCommon { CredentialSubject::Endorsement(subject) => &subject.id, CredentialSubject::Witness(subject) => &subject.id, CredentialSubject::Membership(subject) => &subject.id, + CredentialSubject::Authority(subject) => &subject.id, CredentialSubject::RCard(subject) => &subject.id, } } + /// The `authority` grant, when this credential is a VAC. + /// + /// `None` for every other credential type — the accessor is deliberately fallible + /// rather than panicking, so a caller handed a credential of unknown type can ask + /// without first matching on `type_`. + pub fn authority(&self) -> Option<&AuthorityGrant> { + match &self.credential_subject { + CredentialSubject::Authority(subject) => Some(&subject.authority), + _ => None, + } + } + + /// Mutable access to the `authority` grant, when this credential is a VAC. + /// + /// Present so that a caller can construct chains this library's own + /// [DTGCredential::attenuate] would refuse — which is exactly what a verifier must be + /// tested against, since nothing stops another implementation emitting such JSON. + pub fn authority_mut(&mut self) -> Option<&mut AuthorityGrant> { + match &mut self.credential_subject { + CredentialSubject::Authority(subject) => Some(&mut subject.authority), + _ => None, + } + } + /// The credential is valid from this timestamp pub fn valid_from(&self) -> DateTime { self.valid_from @@ -724,6 +793,33 @@ impl TryFrom for DTGCredential { _ => Err(DTGCredentialError::UnknownCredential), } } + DTGCredentialType::Authority => { + // A VAC's subject must actually carry the grant. `Basic` — a bare `{ id }` — + // is the shape a caller lands on when the `authority` member is missing + // entirely, and a credential that confers nothing is malformed rather than + // merely empty. There is no normalization to do here (unlike VMC/VWC, whose + // shapes collide): `authority` is unique to this subject. + match &value.credential_subject { + CredentialSubject::Authority(subject) => { + if subject.authority.actions.is_empty() { + // Emptiness is never a wildcard. Refusing here means a caller + // cannot construct one by deserialization either. + return Err(DTGCredentialError::EmptyAuthorityActions); + } + Ok(DTGCredential { + type_: DTGCredentialType::Authority, + version: value.context.as_slice().try_into()?, + credential: value, + }) + } + _ => Err(DTGCredentialError::UnknownCredential), + } + } + DTGCredentialType::Delegation => Ok(DTGCredential { + type_: DTGCredentialType::Delegation, + version: value.context.as_slice().try_into()?, + credential: value, + }), DTGCredentialType::RCard => match &value.credential_subject { CredentialSubject::RCard { .. } => Ok(DTGCredential { type_: DTGCredentialType::RCard, @@ -794,6 +890,13 @@ pub enum CredentialSubject { /// Verifiable Witness Credential subject Witness(CredentialSubjectWitness), + /// Verifiable Authority Credential subject. + /// + /// Unambiguous under the untagged match: no other DTG subject carries an `authority` + /// member, and `deny_unknown_fields` keeps a subject that does not have one from + /// landing here. + Authority(CredentialSubjectAuthority), + /// Membership Credential subject, carrying the OPTIONAL `digest` that a member-issued /// VMC MUST set. /// @@ -821,6 +924,60 @@ pub struct CredentialSubjectBasic { pub id: String, } +/// The `authority` object a [CredentialSubject::Authority] carries. +/// +/// # Attenuation +/// +/// A holder may derive a narrower VAC from one they hold without involving the issuer. An +/// attenuated VAC sets [AuthorityGrant::parent] to the `id` of the credential it derives +/// from, and MUST NOT widen `actions`, `scope`, or the validity window. Verification walks +/// the chain to a VAC issued by the party governing the scope — see +/// [crate::authority::verify_chain], which is where the security of this credential +/// actually lives. Issuing one is a struct and a signature; refusing a widening link is the +/// part that matters. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorityGrant { + /// The DID or URI the authority applies to. + /// + /// Matched exactly. A verifier rejects a VAC whose `scope` is not the resource being + /// accessed; nothing here implies containment between scopes. + pub scope: String, + + /// The permitted actions, from a vocabulary the governing party defines. + /// + /// MUST NOT be empty. An empty list confers nothing — emptiness is never a wildcard, + /// which is the failure mode this rule exists to prevent. Action strings are compared + /// exactly and case-sensitively, and no action implies another: `admin` does not grant + /// `write` unless both are listed. + pub actions: Vec, + + /// The `id` of the VAC this one was attenuated from. + /// + /// Absent means this VAC was issued directly by the party governing the scope, and is + /// therefore a chain root. + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, + + /// A DID that MUST be the presenter for this VAC to be accepted. + /// + /// Absent means any holder may present it. Setting it is what makes a leaked agent + /// credential useless to anyone but that agent. + #[serde(skip_serializing_if = "Option::is_none")] + pub audience: Option, +} + +/// Verifiable Authority Credential (VAC) subject. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CredentialSubjectAuthority { + /// DID of the party receiving the authority. + pub id: String, + + /// What the subject may do, and where. + pub authority: AuthorityGrant, +} + /// Membership Credential subject /// /// The two directions of a membership edge share this shape and are told apart by diff --git a/tests/authority_chain.rs b/tests/authority_chain.rs new file mode 100644 index 0000000..f06c339 --- /dev/null +++ b/tests/authority_chain.rs @@ -0,0 +1,308 @@ +//! Chain verification for Verifiable Authority Credentials. +//! +//! These tests are the reason the credential is safe to use. Issuing a VAC is a struct and +//! a signature; what stops a holder acquiring authority they were not given is the verifier +//! refusing a chain that widens. So the cases below are mostly *attacks* — each one is a +//! way of getting more than was granted, and each must be refused with a specific error +//! rather than a generic failure, because a verifier's logs are where an escalation attempt +//! becomes visible. + +use chrono::{Duration, TimeZone, Utc}; +use dtg_credentials::authority::{AuthorityError, MAX_CHAIN_DEPTH, verify_chain}; +use dtg_credentials::{DTGCredential, DTGCredentialType}; + +const ROOM: &str = "did:webvh:zroom:example.com:rooms:7f3a"; +const BOB: &str = "did:key:zBob"; +const AGENT: &str = "did:key:zBobAgent"; +const MALLORY: &str = "did:key:zMallory"; + +fn t(h: i64) -> chrono::DateTime { + Utc.with_ymd_and_hms(2026, 1, 6, 10, 0, 0).unwrap() + Duration::hours(h) +} + +/// The room grants Bob read+write+curate for a month. +fn root_grant() -> DTGCredential { + DTGCredential::new_vac( + ROOM.into(), + BOB.into(), + ROOM.into(), + vec!["read".into(), "write".into(), "curate".into()], + t(0), + Some(t(24 * 30)), + ) + .expect("root grant") + .with_id("urn:uuid:root-0001") +} + +/// Bob equips his agent with read-only for four hours, bound to the agent. +fn agent_grant(parent: &DTGCredential) -> DTGCredential { + parent + .attenuate( + AGENT.into(), + vec!["read".into()], + t(0), + Some(t(4)), + Some(AGENT.into()), + ) + .expect("attenuation") + .with_id("urn:uuid:agent-0001") +} + +#[test] +fn a_root_grant_verifies_for_what_it_confers() { + let root = root_grant(); + let v = verify_chain(&[root], ROOM, ROOM, "write", BOB, t(1)).expect("root should verify"); + assert_eq!(v.subject, BOB); + assert_eq!(v.governing_party, ROOM); + assert!(v.actions.contains(&"curate".to_string())); +} + +/// The case the whole credential exists for: an agent acting on strictly less authority +/// than the human it works for. +#[test] +fn an_attenuated_agent_credential_verifies_for_its_narrower_grant() { + let root = root_grant(); + let agent = agent_grant(&root); + + let v = verify_chain( + &[agent.clone(), root.clone()], + ROOM, + ROOM, + "read", + AGENT, + t(1), + ) + .expect("agent chain should verify for read"); + assert_eq!(v.subject, AGENT); + assert_eq!(v.actions, vec!["read".to_string()]); + + // ...and not for what it was not given, even though its parent holds it. + let err = verify_chain(&[agent, root], ROOM, ROOM, "write", AGENT, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::ActionNotGranted { ref action } if action == "write"), + "got {err:?}" + ); +} + +/// The headline attack. A valid signature on a self-minted credential proves nothing about +/// authority: what makes it worthless is that its chain never reaches the governing party. +#[test] +fn a_self_issued_grant_is_refused_however_well_formed() { + let forged = DTGCredential::new_vac( + MALLORY.into(), + MALLORY.into(), + ROOM.into(), + vec!["read".into(), "write".into(), "curate".into()], + t(0), + Some(t(24)), + ) + .expect("mallory can build one") + .with_id("urn:uuid:forged"); + + let err = verify_chain(&[forged], ROOM, ROOM, "write", MALLORY, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::RootNotGoverning { .. }), + "a chain not reaching the governing party must be refused: got {err:?}" + ); +} + +#[test] +fn attenuation_cannot_add_an_action_the_parent_lacks() { + let root = DTGCredential::new_vac( + ROOM.into(), + BOB.into(), + ROOM.into(), + vec!["read".into()], + t(0), + Some(t(24)), + ) + .unwrap() + .with_id("urn:uuid:read-only-root"); + + // Refused at issue time... + let err = root + .attenuate(AGENT.into(), vec!["write".into()], t(0), Some(t(4)), None) + .unwrap_err(); + assert!( + format!("{err}").contains("not conferred by the parent"), + "{err}" + ); + + // ...and refused at verification time too, for an implementation that built the JSON by + // hand. The verifier's check is the authoritative one. + let widened = DTGCredential::new_vac( + BOB.into(), + AGENT.into(), + ROOM.into(), + vec!["write".into()], + t(0), + Some(t(4)), + ) + .unwrap() + .with_id("urn:uuid:widened"); + let mut widened = widened; + if let Some(g) = widened.credential_mut().authority_mut() { + g.parent = Some("urn:uuid:read-only-root".into()); + } + + let err = verify_chain(&[widened, root], ROOM, ROOM, "write", AGENT, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::WidensActions { ref action, .. } if action == "write"), + "got {err:?}" + ); +} + +#[test] +fn attenuation_cannot_outlive_its_parent() { + let root = root_grant(); + let err = root + .attenuate( + AGENT.into(), + vec!["read".into()], + t(0), + Some(t(24 * 365)), + None, + ) + .unwrap_err(); + assert!(format!("{err}").contains("beyond the parent's"), "{err}"); +} + +/// Only the party a grant was made to may attenuate it — otherwise a holder could graft +/// someone else's grant onto their own chain. +#[test] +fn a_link_issued_by_someone_other_than_the_parents_subject_is_refused() { + let root = root_grant(); // granted to BOB + let grafted = DTGCredential::new_vac( + MALLORY.into(), // not BOB + MALLORY.into(), + ROOM.into(), + vec!["read".into()], + t(0), + Some(t(4)), + ) + .unwrap() + .with_id("urn:uuid:grafted"); + let mut grafted = grafted; + if let Some(g) = grafted.credential_mut().authority_mut() { + g.parent = Some("urn:uuid:root-0001".into()); + } + + let err = verify_chain(&[grafted, root], ROOM, ROOM, "read", MALLORY, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::IssuerNotParentSubject { .. }), + "got {err:?}" + ); +} + +/// Audience binding is what makes a leaked agent credential useless to whoever picks it up. +#[test] +fn an_audience_bound_credential_refuses_another_presenter() { + let root = root_grant(); + let agent = agent_grant(&root); + + let err = verify_chain(&[agent, root], ROOM, ROOM, "read", MALLORY, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::WrongAudience { ref presenter, .. } if presenter == MALLORY), + "got {err:?}" + ); +} + +#[test] +fn an_expired_link_is_refused_even_when_its_parent_is_live() { + let root = root_grant(); // valid 30 days + let agent = agent_grant(&root); // valid 4 hours + + // Five hours in: the agent's credential has expired, the root has not. + let err = verify_chain(&[agent, root.clone()], ROOM, ROOM, "read", AGENT, t(5)).unwrap_err(); + assert!( + matches!(err, AuthorityError::NotValidNow { index: 0, .. }), + "got {err:?}" + ); + + // The root alone is still good, presented by Bob. + verify_chain(&[root], ROOM, ROOM, "read", BOB, t(5)).expect("root still live"); +} + +#[test] +fn authority_in_one_scope_does_not_reach_another() { + let root = root_grant(); + let other_room = "did:webvh:zroom:example.com:rooms:beef"; + let err = verify_chain(&[root], ROOM, other_room, "read", BOB, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::ScopeMismatch { .. }), + "got {err:?}" + ); +} + +#[test] +fn a_chain_deeper_than_the_ceiling_is_refused() { + let root = root_grant(); + let chain: Vec = std::iter::repeat_n(root, MAX_CHAIN_DEPTH + 1).collect(); + let err = verify_chain(&chain, ROOM, ROOM, "read", BOB, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::TooDeep { found } if found == MAX_CHAIN_DEPTH + 1), + "got {err:?}" + ); +} + +#[test] +fn an_empty_chain_confers_nothing() { + let err = verify_chain(&[], ROOM, ROOM, "read", BOB, t(1)).unwrap_err(); + assert!(matches!(err, AuthorityError::EmptyChain)); +} + +/// Emptiness is never a wildcard — the failure mode this rule exists to prevent. +#[test] +fn a_vac_conferring_no_actions_is_refused_at_construction() { + let err = DTGCredential::new_vac(ROOM.into(), BOB.into(), ROOM.into(), vec![], t(0), None) + .unwrap_err(); + assert!(format!("{err}").contains("confers nothing"), "{err}"); +} + +#[test] +fn a_vac_round_trips_through_json_with_its_grant_intact() { + let root = root_grant(); + let json = serde_json::to_string(&root).expect("serialize"); + let back: DTGCredential = serde_json::from_str(&json).expect("deserialize"); + + assert!(matches!(back.type_(), DTGCredentialType::Authority)); + let grant = back + .credential() + .authority() + .expect("grant survives the round trip"); + assert_eq!(grant.scope, ROOM); + assert_eq!(grant.actions.len(), 3); + assert!(grant.parent.is_none(), "a root carries no parent"); + + // And the attenuated form keeps its chain link and its audience. + let agent = agent_grant(&root); + let json = serde_json::to_string(&agent).unwrap(); + let back: DTGCredential = serde_json::from_str(&json).unwrap(); + let grant = back.credential().authority().unwrap(); + assert_eq!(grant.parent.as_deref(), Some("urn:uuid:root-0001")); + assert_eq!(grant.audience.as_deref(), Some(AGENT)); + assert_eq!(grant.actions, vec!["read".to_string()]); +} + +/// A VAC with an empty `actions` array must not be constructable by deserialization either +/// — otherwise the constructor's guard is trivially bypassed. +#[test] +fn an_empty_actions_list_is_refused_on_deserialization() { + let json = serde_json::json!({ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://firstperson.network/credentials/dtg/v1" + ], + "type": ["VerifiableCredential", "DTGCredential", "AuthorityCredential"], + "issuer": ROOM, + "validFrom": "2026-01-06T10:00:00Z", + "credentialSubject": { "id": BOB, "authority": { "scope": ROOM, "actions": [] } } + }) + .to_string(); + + let err = serde_json::from_str::(&json).unwrap_err(); + assert!( + err.to_string().contains("confers nothing"), + "empty actions must be refused at the deserialization boundary too: {err}" + ); +}