diff --git a/CHANGELOG.md b/CHANGELOG.md index ccac0b1..0a23984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] - 2026-09-09 + +**A VAC is not a bearer credential.** This release implements the rule and removes the +field that was standing in for it. + +### Changed — `verify_chain` requires the presenter to be the leaf's subject (breaking) + +`authority::verify_chain` already took a `presenter`, and used it for one thing: comparing +it against the leaf's optional `audience`. A leaf without an `audience` was therefore +accepted from **anybody**, which made a captured presentation a bearer token — the chain +names what may be done, not who is doing it. + +It now requires the leaf to grant to `presenter`, and refuses otherwise with the new +`AuthorityError::NotThePresenter`. This is the rule +[PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41) states normatively: a +verifier MUST NOT accept a party as holding the authority a VAC confers unless that party +demonstrates control of the verification method associated with the presented VAC's +`credentialSubject.id`. Only the leaf's subject demonstrates anything — the links above it +are not present and are asked for nothing, which is what keeps attenuation working. + +Both known consumers were already doing this by hand, having each hit the gap +independently: `vti-rooms-dtg`'s chain verifier and its nomination check both call +`verify_chain` and then re-compare the subject themselves, with a comment explaining why +they must. Two copies of a check is one place for it to be forgotten, so it moves here. + +`presenter` must be an identifier whose key control the caller has already established for +that request. Passing a value read out of the request body reduces the check to a string +comparison an attacker chooses both sides of. + +### Removed — `authority.audience` (breaking, on the wire and in the API) + +Gone from `AuthorityGrant`, from `attenuate` and `attenuate_from_json` (which each lose +their trailing `Option` parameter), and from the verifier along with +`AuthorityError::WrongAudience`. + +Once the presenter must be the subject, an `audience` can only name that same subject — +adding nothing — or name somebody else, which no presentation can ever satisfy. PR #41 +removes it for exactly that reason, and it is removed here rather than kept as a weaker +second check. + +The property was also being read two incompatible ways, which is what brought this +forward: this library compared it to the **presenter**, while +`trusttasks.org/spec/rooms/keys/present/0.1` described it as the party the presentation is +**for** — "a host's identifier, normally". An implementation that followed the registry +minted leaves the verifier refused every time. See +[dtgwg-trust-tasks-tf#414](https://github.com/trustoverip/dtgwg-trust-tasks-tf/issues/414). +The destination question is real and is answered one layer up, by the trust task +document's `recipient` member, which its `proof` covers. + +**Migrating.** Drop the final argument from `attenuate`/`attenuate_from_json` calls. Where +you passed the agent's DID, it was already redundant with `subject`. Where you passed a +verifier's or host's identifier, that binding now belongs on the request document, not the +credential. Callers already comparing the leaf's subject to the presenter can delete that +check. + + ## [0.7.0] - 2026-09-08 Brings the library up to **Working Draft 02** of the DTG Core Credentials @@ -134,6 +190,7 @@ this crate. Three changes to the VAC are in flight upstream and are not here. `audience` is kept until the last of them lands, rather than removing a shipped field twice. +*(Superseded by 0.8.0, which implements PR #41 and removes `audience`.)* - Revocation via `credentialStatus`, cascading to everything attenuated below ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)). diff --git a/Cargo.toml b/Cargo.toml index cf5462c..b151e5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dtg-credentials" description = "Decentralized Trust Graph (DTG) Credentials Library" -version = "0.7.0" +version = "0.8.0" edition = "2024" publish = true authors = ["Glenn Gore "] diff --git a/README.md b/README.md index 73f05f2..df32cd3 100644 --- a/README.md +++ b/README.md @@ -237,15 +237,23 @@ different claims orphans its children, while re-proofing it leaves them alone. For a VAC that arrived from a counterparty, use `attenuate_from_json()` and give it the bytes you received. +**A VAC is not a bearer credential.** `verify_chain()` takes the presenter and requires +the leaf to grant to it, so a captured presentation is worthless to whoever captured it. +Pass an identifier whose key control you have already established for *this* request — the +DID a transport authenticated, or one a signature over the request proved — never one read +out of the request body. + +That rule is why there is no `audience`. Equipping an agent means naming the agent in +`subject`; a second field naming who may present could then only repeat the subject or +contradict it. Where a presentation may be *sent* is a different question, and it belongs +to the trust task carrying it rather than to the credential. + > [!NOTE] -> Three upstream changes to the VAC are **not** implemented yet: revocation via +> Two upstream changes to the VAC are **not** implemented yet: revocation via > `credentialStatus`, cascading to everything attenuated below -> ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); a -> `maxAttenuation` ceiling ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)); -> and a key-control demonstration at invocation, which removes `audience` as -> redundant ([PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41)). -> `audience` is kept until that lands rather than being removed twice. A verified -> chain is not by itself evidence that the party presenting it is the leaf's subject. +> ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); and a +> `maxAttenuation` ceiling +> ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)). ## Delegation (VDC) diff --git a/examples/data_room.rs b/examples/data_room.rs index 8b7f4f3..c37b802 100644 --- a/examples/data_room.rs +++ b/examples/data_room.rs @@ -15,8 +15,9 @@ //! 2. Alice invites Bob — a **VIC**, issued by the room. //! 3. Bob presents it and receives a **VMC pair** (membership) and a **VAC** (what he may do). //! 4. Bob writes a record. Sealed under the epoch key, AAD-bound to its location. -//! 5. Bob attenuates a **read-only, four-hour, audience-bound VAC to his agent**, which -//! recalls the record. The agent never holds Bob's own authority. +//! 5. Bob attenuates a **read-only, four-hour VAC to his agent**, which recalls the +//! record. The agent never holds Bob's own authority, and — because a VAC is not a +//! bearer credential — nobody but the agent can present what the agent was granted. //! 6. Alice removes Bob. The epoch rotates and the new key is sealed only to who remains. //! Bob's agent can still read what it already could — and nothing written after. //! 7. The host view: every byte the operator holds. @@ -296,11 +297,10 @@ async fn main() -> Result<()> { vec!["read".into()], now, now + Duration::hours(4), - Some(agent_did.clone()), )? .with_id("urn:uuid:vac-agent"); agent_vac.sign(&bob_secret, None).await?; - println!("Bob issued his agent a VAC: read only · 4 hours · audience-bound to the agent"); + println!("Bob issued his agent a VAC: read only · 4 hours · presentable only by the agent"); // The agent presents the whole chain; the verifier walks it to the room. let chain = vec![agent_vac.clone(), bob_vac.clone()]; diff --git a/src/authority.rs b/src/authority.rs index 9cc2730..d5a253f 100644 --- a/src/authority.rs +++ b/src/authority.rs @@ -19,7 +19,7 @@ //! | 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 | +//! | The leaf's subject must be the presenter | a captured presentation replayed by whoever caught it | //! | Depth is bounded | a denial-of-service against the verifier, which walks every link | //! | Every link must carry `validUntil` | authority nobody can withdraw by waiting | //! @@ -37,18 +37,41 @@ //! which an identifier could not do: a parent re-issued with different claims does not //! carry its old children with it. //! +//! # A VAC is not a bearer credential +//! +//! [`verify_chain`] takes a `presenter` and requires the leaf to grant to it. That is the +//! rule [PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41) states normatively +//! — *a verifier MUST NOT accept a party as holding the authority a VAC confers unless that +//! party demonstrates control of the verification method associated with the presented +//! VAC's `credentialSubject.id`* — and it is why this module no longer has an `audience`. +//! +//! An earlier draft of the VAC carried an OPTIONAL `audience` naming the DID that had to +//! present the credential, and this module compared it against `presenter`. Once the +//! presenter must be the subject, that field can only name the same party (adding nothing) +//! or a different one (satisfiable by nobody), so it was removed rather than kept as a +//! weaker second check. The destination question it was sometimes read as answering — +//! *where* may this be presented — is not the credential's to answer; it belongs to the +//! trust task carrying the presentation, which binds its own recipient. +//! +//! **What `presenter` must be.** The identifier of a party whose key control the caller has +//! already established for *this request* — the DID a transport authenticated, or one a +//! signature over the request proved. Passing an identifier the caller merely read out of +//! the request body reduces this check to a string comparison an attacker chooses both +//! sides of. +//! +//! **Only the leaf's subject demonstrates anything.** The parties named in the links above +//! it are not present and are asked for nothing. Requiring otherwise would defeat +//! attenuation, whose whole purpose is that the party who attenuated is not in the loop +//! when its agent acts. +//! //! # Still ahead of this module //! -//! Three changes to the VAC are in flight upstream and are **not** implemented here: +//! Two changes to the VAC are in flight upstream and are **not** implemented here: //! revocation via `credentialStatus`, cascading to everything attenuated below -//! ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); a `maxAttenuation` -//! ceiling bounding depth per-ancestor rather than only globally -//! ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)); and a key-control -//! demonstration at invocation, which removes `audience` as redundant -//! ([PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41)). Until they land, a -//! caller wanting revocation must check [`crate::DTGCommon::credential_status`] itself, and -//! a chain verified here is not evidence that the party presenting it is the leaf's -//! subject. +//! ([PR #39](https://github.com/trustoverip/dtgwg-cred-spec/pull/39)); and a +//! `maxAttenuation` ceiling bounding depth per-ancestor rather than only globally +//! ([PR #40](https://github.com/trustoverip/dtgwg-cred-spec/pull/40)). Until they land, a +//! caller wanting revocation must check [`crate::DTGCommon::credential_status`] itself. use chrono::{DateTime, Utc}; @@ -187,13 +210,15 @@ pub enum AuthorityError { 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, + /// The leaf grants to somebody other than the party presenting it. + /// + /// A VAC is evidence that authority was conferred on somebody. It is not evidence that + /// whoever handed it over is that somebody, and a verifier that conflated the two would + /// authorize every captured presentation. + #[error("the chain's leaf grants to `{subject}`, but it was presented by `{presenter}`")] + NotThePresenter { + /// Who the leaf grants to. + subject: String, /// Who presented it. presenter: String, }, @@ -292,15 +317,18 @@ pub fn verify_chain( } } - // The leaf must be presentable by whoever is presenting it. + // Key control at invocation: the leaf must grant to whoever is presenting it. + // + // Without this a presentation is a bearer object — it names what may be done, not who + // is doing it — so anyone who observes one inherits everything it confers. The check is + // only as good as `presenter`: see the module docs on what a caller must have + // established before passing one. 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(), + let leaf_subject = leaf.credential().subject(); + if leaf_subject != presenter { + return Err(AuthorityError::NotThePresenter { + subject: leaf_subject.to_string(), presenter: presenter.to_string(), }); } diff --git a/src/create.rs b/src/create.rs index d46dec9..cc56b5c 100644 --- a/src/create.rs +++ b/src/create.rs @@ -282,7 +282,6 @@ impl DTGCredential { scope, actions, parent: None, - audience: None, }, }), ..Default::default() @@ -314,8 +313,13 @@ impl DTGCredential { /// - `self` must be a VAC. /// - `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. + /// + /// # Binding the derivative to the agent is `subject`, not a separate field + /// + /// A VAC is not a bearer credential: [crate::authority::verify_chain] requires the + /// party presenting the leaf to be its subject. So equipping an agent means naming the + /// agent in `subject`, and there is nothing further to bind. An earlier version of this + /// method took an `audience` for that job; it was removed with the property. /// /// # Digests the model /// @@ -330,7 +334,6 @@ impl DTGCredential { actions: Vec, valid_from: DateTime, valid_until: DateTime, - audience: Option, ) -> Result { let parent_grant = self .credential() @@ -346,7 +349,6 @@ impl DTGCredential { actions, valid_from, valid_until, - audience, ) } @@ -369,7 +371,6 @@ impl DTGCredential { actions: Vec, valid_from: DateTime, valid_until: DateTime, - audience: Option, ) -> Result { let object = parent .as_object() @@ -425,7 +426,6 @@ impl DTGCredential { actions, valid_from, valid_until, - audience, ) } @@ -440,7 +440,6 @@ impl DTGCredential { actions: Vec, valid_from: DateTime, valid_until: DateTime, - audience: Option, ) -> Result { if actions.is_empty() { return Err(DTGCredentialError::EmptyAuthorityActions); @@ -472,7 +471,6 @@ impl DTGCredential { scope: parent_grant.scope.clone(), actions, parent: Some(parent_digest), - audience, }, }), ..Default::default() diff --git a/src/lib.rs b/src/lib.rs index aa7a27a..425e8fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -636,9 +636,11 @@ pub enum DTGCredentialType { /// specified actions within a named scope governed by the issuer. /// /// Merged into DTG Core Credentials at Working Draft 02 - /// (`trustoverip/dtgwg-cred-spec` PR #29). Three further changes to the VAC are in - /// flight and not implemented here — revocation (PR #39), a `maxAttenuation` ceiling - /// (PR #40), and key-control at invocation, which removes `audience` (PR #41). + /// (`trustoverip/dtgwg-cred-spec` PR #29). Key control at invocation — a VAC is not a + /// bearer credential — is implemented in [crate::authority::verify_chain], ahead of + /// PR #41 which states it normatively and removes the `audience` property it made + /// redundant. Two further changes are in flight and not implemented here: revocation + /// (PR #39) and a `maxAttenuation` ceiling (PR #40). Authority, /// Verifiable Delegation Credential (VDC) — establishes that one entity may act in @@ -1288,21 +1290,6 @@ pub struct AuthorityGrant { /// because the digest excludes `proof`. #[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. - /// - /// # Slated for removal upstream - /// - /// `trustoverip/dtgwg-cred-spec` PR #41 removes this property, having made it - /// redundant: a VAC is not a bearer credential, and requiring the leaf's subject to - /// demonstrate key control at invocation already establishes that the presenter is the - /// subject. It is kept here until that lands, because removing a shipped field twice - /// is worse than removing it once. - #[serde(skip_serializing_if = "Option::is_none")] - pub audience: Option, } /// The `delegation` object a [CredentialSubject::Delegation] carries. diff --git a/tests/authority_chain.rs b/tests/authority_chain.rs index 51bf2f9..8e8537b 100644 --- a/tests/authority_chain.rs +++ b/tests/authority_chain.rs @@ -34,16 +34,11 @@ fn root_grant() -> DTGCredential { .with_id("urn:uuid:root-0001") } -/// Bob equips his agent with read-only for four hours, bound to the agent. +/// Bob equips his agent with read-only for four hours. Naming the agent as `subject` is +/// the whole of the binding: only the agent can present what only the agent is granted. fn agent_grant(parent: &DTGCredential) -> DTGCredential { parent - .attenuate( - AGENT.into(), - vec!["read".into()], - t(0), - t(4), - Some(AGENT.into()), - ) + .attenuate(AGENT.into(), vec!["read".into()], t(0), t(4)) .expect("attenuation") .with_id("urn:uuid:agent-0001") } @@ -121,7 +116,7 @@ fn attenuation_cannot_add_an_action_the_parent_lacks() { // Refused at issue time... let err = root - .attenuate(AGENT.into(), vec!["write".into()], t(0), t(4), None) + .attenuate(AGENT.into(), vec!["write".into()], t(0), t(4)) .unwrap_err(); assert!( format!("{err}").contains("not conferred by the parent"), @@ -159,7 +154,7 @@ fn attenuation_cannot_add_an_action_the_parent_lacks() { fn attenuation_cannot_outlive_its_parent() { let root = root_grant(); let err = root - .attenuate(AGENT.into(), vec!["read".into()], t(0), t(24 * 365), None) + .attenuate(AGENT.into(), vec!["read".into()], t(0), t(24 * 365)) .unwrap_err(); assert!(format!("{err}").contains("beyond the parent's"), "{err}"); } @@ -193,15 +188,47 @@ fn a_link_issued_by_someone_other_than_the_parents_subject_is_refused() { ); } -/// Audience binding is what makes a leaked agent credential useless to whoever picks it up. +/// A VAC is not a bearer credential: the leaf must grant to whoever presents it. This is +/// what makes a captured presentation useless to whoever captured it. #[test] -fn an_audience_bound_credential_refuses_another_presenter() { +fn a_chain_presented_by_someone_other_than_its_subject_is_refused() { 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), + matches!( + err, + AuthorityError::NotThePresenter { ref subject, ref presenter } + if subject == AGENT && presenter == MALLORY + ), + "got {err:?}" + ); +} + +/// The same rule applies to a root presented directly — the case with no attenuation at +/// all, which is where a bearer reading would be easiest to reach for. +#[test] +fn a_root_presented_by_someone_other_than_its_subject_is_refused() { + let err = verify_chain(&[root_grant()], ROOM, ROOM, "write", MALLORY, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::NotThePresenter { ref subject, .. } if subject == BOB), + "got {err:?}" + ); +} + +/// The principal may not present what they gave away. Bob holds `read`/`write`/`curate` at +/// the room, but the *leaf* of this chain grants to his agent — so the chain says the agent +/// is acting, and Bob presenting it is as wrong as Mallory doing so. Bob presents his own +/// root instead; that is a different chain. +#[test] +fn the_attenuating_holder_cannot_present_their_agents_chain() { + let root = root_grant(); + let agent = agent_grant(&root); + + let err = verify_chain(&[agent, root], ROOM, ROOM, "read", BOB, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::NotThePresenter { ref presenter, .. } if presenter == BOB), "got {err:?}" ); } @@ -273,7 +300,7 @@ fn a_vac_round_trips_through_json_with_its_grant_intact() { 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. + // And the attenuated form keeps its chain link. let agent = agent_grant(&root); let json = serde_json::to_string(&agent).unwrap(); let back: DTGCredential = serde_json::from_str(&json).unwrap(); @@ -283,7 +310,6 @@ fn a_vac_round_trips_through_json_with_its_grant_intact() { Some(root.digest_multibase().unwrap().as_str()), "`parent` is the digest of the credential attenuated from, not its id" ); - assert_eq!(grant.audience.as_deref(), Some(AGENT)); assert_eq!(grant.actions, vec!["read".to_string()]); } @@ -351,7 +377,7 @@ fn a_parent_without_an_id_can_still_be_attenuated() { assert!(root.id().is_none()); let agent = root - .attenuate(AGENT.into(), vec!["read".into()], t(0), t(4), None) + .attenuate(AGENT.into(), vec!["read".into()], t(0), t(4)) .expect("attenuation does not need the parent to have an id"); let v = verify_chain(&[agent, root], ROOM, ROOM, "read", AGENT, t(1)).expect("verifies"); @@ -440,7 +466,6 @@ fn attenuating_from_json_digests_the_wire_form() { vec!["read".into()], t(0), t(4), - Some(AGENT.into()), ) .expect("attenuation"); @@ -467,7 +492,6 @@ fn attenuating_from_json_still_refuses_to_widen() { vec!["delete".into()], t(0), t(4), - None, ) .unwrap_err();