Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **A VAC states purposes of use** (DTG Core Credentials, *Purposes of Use*;
trustoverip/dtgwg-cred-spec#84). `credentialSubject.purposes` is optional, `{"scope":"any"}`
or `{"scope":"listed","purposes":[...]}` with a non-empty list of registered names or
absolute IRIs, each once; absent means `any`, and `any` never grants `modelTraining`.
`dtg_credentials::purposes::PurposeScope` models it, read strictly (an `any` carrying
`purposes`, an empty list or a duplicate is refused at parse). A VAC stating it lists
`DTG_CONTEXT_V2` (`https://registry.trustoverip.org/dtg/context/v2`) second, which is now
recognized beside `v1`; a VAC stating it under `v1` is refused.
- **`verify_chain` enforces the chain rule and reports the effective purposes.** A link
stating none takes its parent's; a link stating purposes its parent does not grant is
`AuthorityError::WidensPurposes`. The root states the grant, so only a root naming
`modelTraining` can pass it on. `VerifiedAuthority::purposes` carries the result.
- `DTGCredential::with_purposes` states them (switching the context to `v2`), and
`with_purposes_under` refuses at issue purposes wider than those the parent states.

### Changed (breaking)

- `CredentialSubjectAuthority` gains `purposes`, `VerifiedAuthority` gains `purposes`, and
`AuthorityError` gains `WidensPurposes`; `DTGCredentialError` gains `InvalidPurposes`
(non-exhaustive).

- **did:scid parties are matched by their DID.** Chain verification (`verify_chain`,
delegation chains) and the acknowledgement/acceptance pairing compare an issuer,
subject, presenter, governing party or principal by DID, ignoring a did:scid `?src=`
Expand Down
38 changes: 38 additions & 0 deletions src/authority.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ pub enum AuthorityError {
#[error("authority chain is empty")]
EmptyChain,

/// A link states purposes its parent's effective purposes do not grant — `any`
/// beneath a list, a purpose the list does not name, or `modelTraining` its parent
/// does not name (DTG Core Credentials, *Purposes of Use*).
#[error("chain link {index} states purposes {purposes:?} its parent does not grant")]
WidensPurposes {
/// Position in the chain, leaf first.
index: usize,
/// The purposes beyond the parent's, or `["any"]`.
purposes: Vec<String>,
},

/// A link's digest could not be computed, or one it carries could not be read.
///
/// Distinct from [AuthorityError::BrokenLink]: a digest that cannot be *read* is not a
Expand Down Expand Up @@ -291,6 +302,10 @@ pub struct VerifiedAuthority {
pub actions: Vec<String>,
/// The party governing the scope, which issued the chain root.
pub governing_party: String,
/// The chain's **effective purposes**: what the presented VAC states, or, where it
/// states none, what the nearest link above it that does states — `any` where none
/// does. A verifier holds a content-disclosing request to these (*Purposes of Use*).
pub purposes: crate::purposes::PurposeScope,
}

/// Verify a chain of VACs and return what it permits.
Expand Down Expand Up @@ -509,6 +524,28 @@ pub fn verify_chain(
});
}

// Purposes, root to leaf: a link stating none takes its parent's, and a link stating
// purposes its parent does not grant invalidates the chain, as a widened action does.
let mut effective = crate::purposes::PurposeScope::Any;
for index in (0..chain.len()).rev() {
let stated = match &chain[index].credential().credential_subject {
crate::CredentialSubject::Authority(subject) => subject.purposes.as_ref(),
_ => None,
};
if let Some(stated) = stated {
// The root has no parent: what the governing party states is the grant, and
// only it can name `modelTraining` for every link beneath.
let is_root = index == chain.len() - 1;
if !is_root && !stated.is_within(&effective) {
return Err(AuthorityError::WidensPurposes {
index,
purposes: stated.beyond(&effective),
});
}
effective = stated.clone();
}
}

// Finally, what was asked for.
if leaf_grant.scope != requested_scope {
return Err(AuthorityError::ScopeMismatch {
Expand All @@ -527,5 +564,6 @@ pub fn verify_chain(
scope: leaf_grant.scope.clone(),
actions: leaf_grant.actions.clone(),
governing_party: governing_party.to_string(),
purposes: effective,
})
}
63 changes: 63 additions & 0 deletions src/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ impl DTGCredential {
parent: None,
max_attenuation: None,
},
purposes: None,
}),
))
}
Expand Down Expand Up @@ -640,6 +641,65 @@ impl DTGCredential {
Ok(self)
}

/// States `purposes` on a VAC: what its subject may use content it obtains under the
/// authority for (DTG Core Credentials, *Purposes of Use*). Lists [crate::DTG_CONTEXT_V2]
/// second, which defines the member.
///
/// On a link this library attenuated, the purposes must be within the parent's
/// effective purposes — a link MUST NOT widen them — and this refuses ones wider than
/// purposes the parent states itself. A parent that states none takes its own parent's,
/// which this credential cannot see, so [crate::authority::verify_chain] remains the
/// authority on the chain rule.
///
/// # Set it before signing
///
/// Same caveat as [DTGCredential::with_id].
///
/// # Errors
///
/// [DTGCredentialError::NotAnAuthorityCredential] on anything but a VAC, and
/// [DTGCredentialError::InvalidPurposes] for a malformed scope.
pub fn with_purposes(
mut self,
purposes: crate::purposes::PurposeScope,
) -> Result<Self, DTGCredentialError> {
purposes
.check()
.map_err(DTGCredentialError::InvalidPurposes)?;
match &mut self.credential.credential_subject {
CredentialSubject::Authority(subject) => subject.purposes = Some(purposes),
_ => return Err(DTGCredentialError::NotAnAuthorityCredential),
}
if let Some(second) = self.credential.context.get_mut(1) {
*second = crate::DTG_CONTEXT_V2.to_string();
}
Ok(self)
}

/// [DTGCredential::with_purposes] on a link attenuated from `parent`, refusing purposes
/// wider than those `parent` states.
///
/// # Errors
///
/// As [DTGCredential::with_purposes], and [DTGCredentialError::AttenuationWidens] where
/// `parent` states purposes these are not within.
pub fn with_purposes_under(
self,
parent: &DTGCredential,
purposes: crate::purposes::PurposeScope,
) -> Result<Self, DTGCredentialError> {
if let CredentialSubject::Authority(p) = &parent.credential.credential_subject
&& let Some(stated) = &p.purposes
&& !purposes.is_within(stated)
{
return Err(DTGCredentialError::AttenuationWidens(format!(
"purposes {:?} are wider than the parent's",
purposes.beyond(stated)
)));
}
self.with_purposes(purposes)
}

/// 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
Expand Down Expand Up @@ -865,6 +925,9 @@ impl DTGCredential {
parent: Some(parent_digest),
max_attenuation,
},
// A link stating none takes its parent's; state narrower ones with
// [DTGCredential::with_purposes].
purposes: None,
}),
))
}
Expand Down
37 changes: 34 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod accept;
pub mod authority;
pub mod create;
pub mod delegation;
pub mod purposes;
pub mod statement;

pub use accept::{
Expand Down Expand Up @@ -52,6 +53,14 @@ pub const W3C_VC_V1_CONTEXT: &str = "https://www.w3.org/2018/credentials/v1";
/// recognized. A credential listing one is refused at parse rather than aliased.
pub const DTG_CONTEXT_V1: &str = "https://registry.trustoverip.org/dtg/context/v1";

/// The DTG credential context, version 2: [DTG_CONTEXT_V1] and the VAC's `purposes`
/// (DTG Core Credentials, *Purposes of Use*).
///
/// A VAC stating `purposes` lists this one second, since `v1` is frozen and does not define
/// the member; a credential carrying no member `v2` added MAY continue to list `v1`. Both
/// are recognized, and nothing else is.
pub const DTG_CONTEXT_V2: &str = "https://registry.trustoverip.org/dtg/context/v2";

/// The correlation scope an issuer declares for its own identifier, carried as the
/// REQUIRED top-level `issuerScope` of every DTG credential (DTG Core Credentials
/// §Correlation Scope).
Expand Down Expand Up @@ -229,10 +238,16 @@ pub enum DTGCredentialError {
#[error("taskContext is required here, and so is taskDigestMultibase, which is absent")]
MissingTaskDigest,

/// `@context` does not list the W3C VC context first and [DTG_CONTEXT_V1] second.
/// `@context` does not list the W3C VC context first and [DTG_CONTEXT_V1] or
/// [DTG_CONTEXT_V2] second, or a VAC states `purposes` under `v1`.
#[error("malformed @context: {0}")]
InvalidContext(String),

/// A VAC's `purposes` is not a well-formed purpose scope: an empty list, a purpose
/// named twice, or one that is neither a registered name nor an absolute IRI.
#[error("malformed purposes: {0}")]
InvalidPurposes(String),

/// `type` is not `VerifiableCredential`, `DTGCredential` and exactly one concrete
/// subtype (with `PersonhoodCredential` permitted as a hint on a VMC only), or names a
/// subtype this specification has retired.
Expand Down Expand Up @@ -826,6 +841,16 @@ impl DTGCredential {
// construct one by deserialization either.
return Err(DTGCredentialError::EmptyAuthorityActions);
}
if let Some(scope) = &subject.purposes {
scope.check().map_err(DTGCredentialError::InvalidPurposes)?;
// `v1` is frozen and defines no `purposes`: a VAC stating them lists the
// context that does.
if c.context.get(1).map(String::as_str) != Some(DTG_CONTEXT_V2) {
return Err(DTGCredentialError::InvalidContext(format!(
"a VAC stating `purposes` lists `{DTG_CONTEXT_V2}` second"
)));
}
}
}
(DTGCredentialType::Delegation, CredentialSubject::Delegation(subject)) => {
check_delegation_shape(&subject.delegation)?;
Expand Down Expand Up @@ -1457,9 +1482,9 @@ fn check_context(context: &[String]) -> Result<W3CVCVersion, DTGCredentialError>
_ => return Err(DTGCredentialError::UnknownVCVersion),
};
match context.get(1).map(String::as_str) {
Some(DTG_CONTEXT_V1) => Ok(version),
Some(DTG_CONTEXT_V1 | DTG_CONTEXT_V2) => Ok(version),
Some(other) => Err(DTGCredentialError::InvalidContext(format!(
"second entry is `{other}`, not `{DTG_CONTEXT_V1}`"
"second entry is `{other}`, not `{DTG_CONTEXT_V1}` or `{DTG_CONTEXT_V2}`"
))),
None => Err(DTGCredentialError::InvalidContext(format!(
"`{DTG_CONTEXT_V1}` is not listed second"
Expand Down Expand Up @@ -2126,6 +2151,12 @@ pub struct CredentialSubjectAuthority {

/// What the subject may do, and where.
pub authority: AuthorityGrant,

/// What the subject may use content it obtains under this authority **for**
/// (DTG Core Credentials, *Purposes of Use*). `None` states none: a root stating none
/// grants `any`, and a link stating none takes its parent's. Requires [DTG_CONTEXT_V2].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purposes: Option<purposes::PurposeScope>,
}

/// Membership Credential subject
Expand Down
Loading
Loading