diff --git a/CHANGELOG.md b/CHANGELOG.md index 9282737..ccac0b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,161 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-09-08 + +Brings the library up to **Working Draft 02** of the DTG Core Credentials +specification. Both drafts the 0.6.0 release tracked have merged — the VAC as PR #29 and +the VDC as PR #19 — and the digest encoding changed underneath them. This release is +breaking on the wire as well as in the API. + +### Changed — the digest encoding (breaking, on the wire) + +Working Draft 02 replaced the `sha256:` digest with a **base58btc +multibase multihash**, and renamed the property that carries it from `digest` to +`digestMultibase`. Every cross-credential reference in the specification now uses that one +encoding: the member-issued VMC's digest of the grant it acknowledges, the VWC's of the +edge credential it attests, a VAC's `authority.parent`, and a VDC's `delegation.parent` +and `delegation.accepts`. + +- `DTGCredential::digest_multibase` is the conformant digest, and now excludes the + top-level `proof` — it previously included it, which is why it was deprecated. It is + un-deprecated. `digest_multibase_json` is its wire-form counterpart. +- `DTGCredential::digest` and `digest_json` are **deprecated**. They still emit the + Working Draft 01 form, unchanged, so a caller migrating can recompute an old digest to + compare against one they stored. +- `CredentialSubjectMembership::digest` and `CredentialSubjectWitness::digest` are renamed + to `digest_multibase`, serializing as `digestMultibase`. The old property name `digest` + is still **accepted when parsing**, so credentials issued against Working Draft 01 still + deserialize. Their *values* do not compare — an old digest reaching a new verifier fails + with `InvalidDigest` rather than as a silent mismatch, which is the intended outcome. +- **Digests are compared as decoded bytes, never as strings.** The specification requires + it, because one digest has more than one spelling, and a string comparison would report + a mismatch where the two credentials agree. `verify_digest`, `acknowledges` and chain + verification all decode. `digests_match` and `decode_digest_multibase` are public for + callers doing the comparison themselves. +- A digest naming a hash algorithm this library does not implement is **rejected** + (`UnsupportedDigestAlgorithm`) rather than treated as a mismatch. A governing party may + require a stronger hash, and a verifier that conflated the two would silently downgrade + that choice into a failed comparison. + +### Changed — `authority.parent` is a digest, not an `id` (breaking) + +An attenuated VAC now names its parent by digest. This is the change with the most +reasoning behind it in the specification, and it is worth restating: a digest names +nothing that can be fetched. Verification cannot come to depend on network availability, +a verifier cannot be induced to make a request against an address of the *holder's* +choosing, and nobody hosting an identifier learns when a credential is used. + +It also binds a link to the exact claims its issuer narrowed from. Re-issuing a parent +with different claims orphans the VACs attenuated from it — each must be re-derived, which +for a chain of narrowing authority is the intended behaviour — while re-proofing it with +identical claims leaves them undisturbed, because the digest excludes `proof`. + +- `DTGCredential::attenuate` no longer requires the parent to carry an `id`, and + `DTGCredentialError::AttenuationParentHasNoId` is deprecated and never returned. Not + needing a top-level identifier merely in order to be referenced is precisely what the + change was for. +- `DTGCredential::attenuate_from_json` is new: attenuate a VAC that **arrived from a + counterparty**, digesting the bytes received rather than a re-serialisation of the + parse. The same distinction `new_member_vmc` has always drawn. +- `authority::AuthorityError::Digest` is new, for a digest that cannot be *read*. Distinct + from `BrokenLink` on purpose: a malformed chain and a widening one are different + findings, and a verifier that reported one as the other would mislead whoever reads the + log. + +### Added — the VDC, which was previously a type name and nothing else + +0.6.0 shipped `new_vdc` as a `DelegationCredential` type string over a bare subject. It +carried no `delegation` object, formed no edge, and had no chain verification. All of that +is now implemented. + +- `DelegationGrant` and `CredentialSubject::Delegation`: `scope`, `parent`, `maxDepth`, + `accepts`. +- `DTGCredential::new_vdc` now takes the appointment: a non-empty `scope`, a required + `valid_until`, and an optional `max_depth`. **Signature change.** +- `DTGCredential::new_delegate_vdc` — the delegate's **acceptance**, built from the + grant's wire form. The acceptance is REQUIRED: a grant alone establishes what the + delegator appointed, not what the delegate agreed to, and a delegator cannot produce the + countersignature. Same consent rule as a membership edge, for the same reason. +- `DTGCredential::accepts` — the delegation counterpart of `acknowledges`: checks the + digest *and* that the two halves are the right types and name the same parties in + mirrored roles. +- `DTGCredential::redelegate` and `redelegate_from_json`. Re-delegation is **opt-in**, + the opposite default from a VAC's attenuation. A delegate speaks in the principal's + name, so the principal keeps the register of who may do so; absence of `maxDepth` + prohibits it, and setting it above zero is the delegator's only way to authorise one. +- `delegation::verify_chain` — scope subset, expiry not beyond the parent's, each link + issued by its parent's delegate, depth budget narrowing on the way down, and a chain + terminating in a root delegation issued by the principal. + + It returns what the chain **appoints** for, and deliberately not whether the act is + permitted. A VDC moves the permission question; it does not answer it. Whether the + *principal* may perform the act is the caller's check, against whatever the act requires + of them — and the reach of a delegated act is the intersection of the two, never the + union. + +### Changed — `validUntil` is REQUIRED on a VAC and a VDC (breaking) + +`new_vac`, `attenuate`, `new_vdc` and the delegation constructors take a +`DateTime` rather than an `Option`, and both chain verifiers reject a link without +one (`NoExpiry`). For a VAC the reasoning is sharper than for a VDC: nothing about the +subject's current standing is consulted when one is verified, so authority that does not +expire is authority nobody can withdraw by waiting. + +### Added — `credentialStatus`, and unmodelled members survive a round trip + +`DTGCommon::credential_status` is modelled, and `DTGCommon::extra` captures top-level +members this library does not name at all. Both exist for the same reason: a +parse-then-re-serialise used to drop them silently, changing a credential's digest. + +This narrows, but does not close, the "digest what you received" hazard — a timestamp is +still normalized on the way out, so `2026-01-06T10:00:00.000+00:00` and the +`2026-01-06T10:00:00Z` this library re-emits are the same instant and different bytes. +The wire-form constructors remain the safe habit, and there is a test pinning exactly +that. + +Status is modelled but **not resolved**: no revocation checking is performed anywhere in +this crate. + +### Fixed + +- `new_member_vmc` probed `credentialSubject` for `digest` when deciding whether it had + been handed a grant or an acknowledgement. After the rename it would have accepted an + acknowledgement as a grant, and acknowledging one forms no edge. It now probes both + spellings. + +### Notes — what is deliberately not implemented + +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. + +- 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)). +- Key control at invocation, which **removes `audience`** as redundant + ([PR #41](https://github.com/trustoverip/dtgwg-cred-spec/pull/41)). Neither chain + verifier establishes that the party presenting a chain controls the leaf's subject + identifier; a VAC and a VDC are both non-bearer, and that demonstration belongs to the + trust task in which they are exercised. + +Correlation scope (PR #30) retired the R-DID / M-DID / C-DID / P-DID identifier types in +favour of a holder-declared `pairwise` | `directed` | `public`. Nothing to implement yet — +the specification has not named the property that carries the declaration — so this +release only drops the retired names from the documentation. + +### Changed — dependencies + +- Dependencies updated to their current releases. Three are semver-major: `sha2` 0.10 → 0.11, + and — dev-only — `chacha20poly1305` 0.10 → 0.11 and `rand` 0.8 → 0.10. The `sha2` bump is + the one worth noting: `affinidi-data-integrity` already pulls `sha2` 0.11 through + `affinidi-crypto`, so the library was linking two copies of it and hashing with the older + one. The library graph now carries a single `sha2`. Digest output is unchanged — the tests + that pin known digests pass untouched. +- `affinidi-tdk` 0.10 → 0.12 (dev-dependency; the examples' DIDs and signing). +- The `data_room` example moves to the `rand` 0.10 API (`rand::rng()`, `rand::Rng`) and off + the now-deprecated `Key::from_slice`/`Nonce::from_slice` in `chacha20poly1305`. + ## [0.6.0] - 2026-09-03 Adds the two credentials that confer rather than assert: the **VAC** (verifiable authority diff --git a/Cargo.toml b/Cargo.toml index ca25805..cf5462c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "dtg-credentials" description = "Decentralized Trust Graph (DTG) Credentials Library" -version = "0.6.0" +version = "0.7.0" edition = "2024" publish = true authors = ["Glenn Gore "] @@ -23,19 +23,19 @@ multibase = "0.9" serde = "1.0" serde_json = "1.0" serde_json_canonicalizer = "0.3" -sha2 = "0.10" +sha2 = "0.11" thiserror = "2.0" tracing = "0.1" [dev-dependencies] -affinidi-tdk = "0.10" +affinidi-tdk = "0.12" anyhow = "1.0" tokio = "1" # The `data_room` example seals records for real rather than pretending to. Dev-only: # nothing in the library itself encrypts, and adding a cipher to its dependency graph for # the sake of an example would be the wrong trade. -chacha20poly1305 = "0.10" -rand = "0.8" +chacha20poly1305 = "0.11" +rand = "0.10" # Both examples sign, and signing lives behind `affinidi-signing`. Without this # a `--no-default-features` build fails on the examples while the library it is diff --git a/README.md b/README.md index eee3b48..73f05f2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **_NOTE:_** This is an early implementation of the [DTG Core Credentials specification](https://github.com/trustoverip/dtgwg-cred-spec) (v1.0, Working -Draft 01), which supersedes the earlier v0.3 proposal draft. +Draft 02), which supersedes the earlier v0.3 proposal draft. See the [First Person Project Whitepaper](https://www.firstperson.network/white-paper) for more information. @@ -21,9 +21,15 @@ cargo run --example data_room # a whole data room, end to end `data_room` runs the room story in one process with real DIDs, real signed credentials, real AEAD and real chain verification: a room issues its owner a VAC, invites a member by VIC, completes the VMC pair on their acknowledgement, seals a record, watches that member -equip an **agent with strictly less authority than they hold themselves**, rotates the -epoch on removal, and finally prints exactly what the host can see — which is ciphertext, -an epoch number, and nothing else. +equip an **agent with strictly less authority than they hold themselves**, then appoints a +service to act **in that member's name** by VDC — the same member, two credentials, and a +verifier that can always tell which it was shown — rotates the epoch on removal, and +finally prints exactly what the host can see, which is ciphertext, an epoch number, and +nothing else. + +The credential types each have their own tests; `tests/authority_chain.rs` and +`tests/delegation_chain.rs` are mostly *attacks*, since what makes a VAC or a VDC +safe is a verifier refusing a chain that widens. ## Credential Type Hierarchy @@ -34,15 +40,28 @@ VerifiableCredential └── DTGCredential ├── MembershipCredential (VMC) ├── RelationshipCredential (VRC) + ├── DelegationCredential (VDC) ├── InvitationCredential (VIC) ├── PersonaCredential (VPC) ├── EndorsementCredential (VEC) - └── WitnessCredential (VWC) + ├── WitnessCredential (VWC) + └── AuthorityCredential (VAC) ``` +Two of those confer rather than assert, and a verifier has to be able to tell +which it was shown: + +| | Question it answers | The act is attributed to | +| --- | --- | --- | +| **VAC** (authority) | may this party do this thing, *as itself*? | the party itself | +| **VDC** (delegation) | may this party act *in another's name*? | the entity it stands in for | + +Neither implies the other, and a VDC never supplies authority the delegator did +not itself hold. See [Authority](#authority-vac) and [Delegation](#delegation-vdc). + **_NOTE:_** The relationship card (R-Card) is **not** a `DTGCredential` subtype. -Working Draft 01 reclassifies it as a verifiable data structure (VDS), to be -defined by the planned *DTG Verifiable Data Structures* specification. The +It was reclassified as a verifiable data structure (VDS) in Working Draft 01, to +be defined by the planned *DTG Verifiable Data Structures* specification. The `RCard` type, `CredentialSubjectRCard` and `new_rcard()` are deprecated in this library and will be removed in a future release. @@ -69,31 +88,37 @@ assert_eq!(vwc.task_context(), Some("thread-abc-123")); ## Digests -An edge credential can be referenced by another credential through a `digest` of -it: a member-issued VMC digests the membership grant it acknowledges, and a VWC -digests the edge credential it attests. Both use the same computation. +A credential can be referenced by another through a `digestMultibase` of it: a +member-issued VMC digests the membership grant it acknowledges, a VWC digests the +edge credential it attests, an attenuated VAC digests the VAC it narrows, and a +VDC digests the delegation it derives from or the grant it accepts. All five use +the same computation. ```Rust // A credential you received: digest the JSON as it arrived. -let digest = dtg_credentials::digest_json(&grant_json)?; +let digest = dtg_credentials::digest_multibase_json(&grant_json)?; -// A credential this library just built: `digest()` is equivalent. -let digest = grant.digest()?; +// A credential this library just built: `digest_multibase()` is equivalent. +let digest = grant.digest_multibase()?; ``` +That is the SHA-256 of the credential canonicalized with JCS (RFC 8785) and +**excluding its top-level `proof`**, wrapped in a `sha2-256` multihash and +encoded base58btc with a multibase `z` prefix — the encoding [VC Data Integrity +§2.6](https://www.w3.org/TR/vc-data-integrity/#resource-integrity) defines for +`digestMultibase`. Leaving `proof` out binds the digest to what the credential +says rather than to one signature over it, so a reference survives its referent +being re-signed, and the digest can be computed before signing. + > [!IMPORTANT] -> Digest what you **received**, not what you parsed. A credential may carry -> members this library does not model — `credentialStatus` is the common one, -> and every VMC issued against a status list has it — and a -> parse-then-re-serialise round trip drops them silently. `digest()` is safe -> only for a credential built in-process; anything that arrived from elsewhere -> goes through `digest_json()`. - -That is `sha256:` followed by the lowercase hex SHA-256 of the credential -canonicalized with JCS (RFC 8785), **excluding its top-level `proof`**. Leaving -`proof` out binds the digest to what the credential says rather than to one -signature over it, so a reference survives its referent being re-signed, and the -digest can be computed before signing. +> Digest what you **received**, not what you parsed, wherever you still hold the +> bytes. `DTGCommon` now models `credentialStatus` and preserves unmodelled +> top-level members through a round trip, so for most credentials the two agree — +> but a timestamp is normalized on the way out, and +> `2026-01-06T10:00:00.000+00:00` hashes differently from the +> `2026-01-06T10:00:00Z` this library re-emits. `digest_multibase()` is safe for a +> credential built in-process; anything that arrived from elsewhere goes through +> `digest_multibase_json()`. `verify_digest()` checks that a credential's digest matches the one it names: @@ -103,23 +128,35 @@ if vwc.verify_digest(&vrc)? { } ``` +It compares **decoded bytes**, not strings — the specification requires it, +because one digest has more than one spelling. `digests_match()` and +`decode_digest_multibase()` are exposed for callers doing the comparison +themselves. + For a membership pair, prefer `acknowledges()` — it checks the digest *and* that the two halves are of the right types and name the same parties in mirrored -roles. See [Membership edges](#membership-edges). +roles. See [Membership edges](#membership-edges). `accepts()` is its counterpart +for a delegation edge. > [!NOTE] -> `digest_multibase()` is deprecated. It emits a base58btc multibase multihash -> over the credential *including* its proof, which is not what the specification -> requires and does not interoperate. Use `digest()`. +> `digest()` and `digest_json()` are deprecated. They emit the Working Draft 01 +> `sha256:` form, which Working Draft 02 replaced. They are kept +> so a caller migrating can recompute an old digest to compare against one they +> stored; new code uses `digest_multibase()` / `digest_multibase_json()`. +> +> On the wire, `digestMultibase` is what this library emits, and the old property +> name `digest` is still accepted when parsing. A credential carrying an old +> *value* parses and then fails to compare, with `InvalidDigest` rather than a +> silent mismatch. ## Membership edges Membership is a **pair** of VMCs, not a single directed credential: -| | `issuer` | `credentialSubject.id` | `digest` | +| | `issuer` | `credentialSubject.id` | `digestMultibase` | | --- | --- | --- | --- | -| **Community-issued** (the grant) | community C-DID | member M-DID | MUST be absent | -| **Member-issued** (the acknowledgement) | member M-DID | community C-DID | MUST be present | +| **Community-issued** (the grant) | community | member | MUST be absent | +| **Member-issued** (the acknowledgement) | member | community | MUST be present | The member-issued half is the member's *consent artifact*. A community can always issue a credential naming somebody as a member; what it cannot do is @@ -156,6 +193,120 @@ different digest and the earlier acknowledgement no longer matches it. Renewal therefore forces re-acknowledgement rather than letting a stale consent carry over to a membership the member never agreed to. +## Authority (VAC) + +A VAC states what a party **may do** within a scope some node governs. Its holder +can narrow it without involving the governing party — which is what lets a member +equip an agent with four hours of read-only access instead of lending it their own +standing authority. + +```Rust +// The governing party grants Bob read+write+curate for a month. +let root = DTGCredential::new_vac( + room_did, bob_did, room_did.clone(), + vec!["read".into(), "write".into(), "curate".into()], + now, now + Duration::days(30), // validUntil is REQUIRED on a VAC +)?; + +// Bob equips his agent with strictly less, bound to that agent. +let agent = root.attenuate( + agent_did, vec!["read".into()], now, now + Duration::hours(4), Some(agent_did), +)?; +``` + +`attenuate()` refuses anything that would widen, but **the verifier's check is the +authoritative one** — nothing stops another implementation building the JSON by +hand. `authority::verify_chain` is where the security of this credential lives: + +```Rust +let permitted = verify_chain( + &[agent, root], // leaf first; the holder presents every link + room_did, room_did, "read", agent_did, Utc::now(), +)?; +``` + +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. A verifier that checks only the credential +it was handed has verified nothing. + +`parent` is a **digest**, not an identifier. So there is nothing a verifier could be +induced to fetch, verification never depends on network availability, and a link +binds to the exact claims its issuer narrowed from — re-issuing a parent with +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. + +> [!NOTE] +> Three 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. + +## Delegation (VDC) + +A VDC establishes that one party may act **in another's name**. It is not authority, +and the distinction decides which credential to reach for: ask whose name the act is +performed in. The actor's own — that is a VAC. Another entity's — that is a VDC. + +Like membership, a delegation is a **pair**: + +| | `issuer` | `credentialSubject.id` | carries | +| --- | --- | --- | --- | +| **Grant** | delegator | delegate | `scope`, optionally `maxDepth` | +| **Acceptance** | delegate | delegator | `accepts` only | + +```Rust +// Alice appoints her agent, permitting one further hop. +let grant = DTGCredential::new_vdc( + alice_did, agent_did, now, now + Duration::days(90), + vec!["schedule:read".into(), "schedule:propose".into()], + Some(1), // maxDepth; None or 0 prohibits re-delegation +)?; + +// The agent accepts. `grant_json` is the wire form, not a parse of it. +let acceptance = DTGCredential::new_delegate_vdc(&grant_json, now, valid_until)?; +assert!(acceptance.accepts(&grant)?); +``` + +The acceptance is **required**. A grant alone establishes what the delegator +appointed, not what the delegate agreed to — and a delegator cannot produce the +countersignature. It is also why a party holding only the delegate's key cannot +manufacture new appointments. + +Re-delegation is **opt-in**, the opposite default from a VAC's attenuation. A +delegate speaks in the principal's name, so the principal keeps the register of who +may do that; a delegate needing a further delegate ordinarily asks for a fresh root +delegation rather than minting one. + +```Rust +let sub = grant.redelegate(subagent_did, vec!["schedule:read".into()], now, until)?; + +let appointed = delegation::verify_chain(&[sub, grant], alice_did, "schedule:read", Utc::now())?; +assert_eq!(appointed.principal, alice_did); // the acts are attributed to Alice +``` + +### A VDC moves the permission question; it does not answer it + +`verify_chain` tells you the chain appoints this delegate to act in the principal's +name for this act. That is one of two checks. The other — *may the principal do this +thing?* — is yours to make, against whatever the act requires of them: membership, a +governance framework, an IDVC, a VAC. This crate does not answer it, and a VDC never +influences its outcome. + +The reach of a delegated act is the **intersection** of what the principal may do +and what the chain appoints for. Two consequences worth stating: nothing the +delegator holds is copied to the delegate, and withdrawing the delegator's own +permission stops every delegate at once, without revoking a single VDC. + +Not implemented here: revocation (`credentialStatus` is modelled but not resolved), +and invocation binding — a VDC is not a bearer token, and nothing in this crate +establishes that the party presenting a chain controls the leaf's subject identifier. + ## End to End Example An end-to-end example of creating, signing and verifying a DTG Credential exists diff --git a/examples/data_room.rs b/examples/data_room.rs index dc0b3f0..8b7f4f3 100644 --- a/examples/data_room.rs +++ b/examples/data_room.rs @@ -48,8 +48,8 @@ use chacha20poly1305::{ aead::{Aead, KeyInit, Payload}, }; use chrono::{Duration, Utc}; -use dtg_credentials::{DTGCredential, authority::verify_chain}; -use rand::RngCore; +use dtg_credentials::{DTGCredential, authority::verify_chain, delegation}; +use rand::Rng; // --------------------------------------------------------------------------------------- // The host @@ -132,12 +132,12 @@ fn aad(room: &str, key: &str, version: u32, epoch: u32) -> Vec { } fn seal(room_key: &[u8; 32], plaintext: &[u8], aad: &[u8]) -> Result<(Vec, [u8; 12])> { - let cipher = ChaCha20Poly1305::new(Key::from_slice(room_key)); + let cipher = ChaCha20Poly1305::new(&Key::from(*room_key)); let mut nonce_bytes = [0u8; 12]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); + rand::rng().fill_bytes(&mut nonce_bytes); let sealed = cipher .encrypt( - Nonce::from_slice(&nonce_bytes), + &Nonce::from(nonce_bytes), Payload { msg: plaintext, aad, @@ -148,15 +148,15 @@ fn seal(room_key: &[u8; 32], plaintext: &[u8], aad: &[u8]) -> Result<(Vec, [ } fn open(room_key: &[u8; 32], sealed: &[u8], nonce: &[u8; 12], aad: &[u8]) -> Result> { - let cipher = ChaCha20Poly1305::new(Key::from_slice(room_key)); + let cipher = ChaCha20Poly1305::new(&Key::from(*room_key)); cipher - .decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad }) + .decrypt(&Nonce::from(*nonce), Payload { msg: sealed, aad }) .map_err(|e| anyhow::anyhow!("open failed: {e}")) } fn new_room_key() -> [u8; 32] { let mut k = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut k); + rand::rng().fill_bytes(&mut k); k } @@ -183,6 +183,7 @@ async fn main() -> Result<()> { let (alice_did, _alice_secret) = DID::generate_did_key(KeyType::Ed25519)?; let (bob_did, bob_secret) = DID::generate_did_key(KeyType::Ed25519)?; let (agent_did, _agent_secret) = DID::generate_did_key(KeyType::Ed25519)?; + let (scheduler_did, scheduler_secret) = DID::generate_did_key(KeyType::Ed25519)?; let mut host = InMemoryHost::default(); host.set_epoch(&room_did, 1); @@ -192,7 +193,8 @@ async fn main() -> Result<()> { println!("room {room_did}"); println!("alice {alice_did} (owner)"); println!("bob {bob_did}"); - println!("agent {agent_did} (Bob's)"); + println!("agent {agent_did} (Bob's, acts as itself)"); + println!("sched {scheduler_did} (acts in Bob's name)"); // -- 1 ------------------------------------------------------------------------------ step(1, "Alice creates the room"); @@ -209,7 +211,7 @@ async fn main() -> Result<()> { "admin".into(), ], now, - Some(now + Duration::days(365)), + now + Duration::days(365), )? .with_id("urn:uuid:vac-alice"); alice_vac.sign(&room_secret, None).await?; @@ -255,7 +257,7 @@ async fn main() -> Result<()> { room_did.clone(), vec!["read".into(), "write".into()], now, - Some(now + Duration::days(30)), + now + Duration::days(30), )? .with_id("urn:uuid:vac-bob"); bob_vac.sign(&room_secret, None).await?; @@ -293,7 +295,7 @@ async fn main() -> Result<()> { agent_did.clone(), vec!["read".into()], now, - Some(now + Duration::hours(4)), + now + Duration::hours(4), Some(agent_did.clone()), )? .with_id("urn:uuid:vac-agent"); @@ -326,8 +328,78 @@ async fn main() -> Result<()> { Ok(_) => bail!("the agent must not be able to write"), } + // -- 5b ----------------------------------------------------------------------------- + step(6, "Bob appoints a scheduler to act in his name"); + // The contrast that decides which credential to reach for: *whose name is the act in?* + // + // The agent above acts as ITSELF. The room records the agent as the actor, the agent + // answers for what it does, and the chain records only who equipped it. That is + // authority, and it is a VAC. + // + // A scheduling service is the other case. When it proposes a meeting it is speaking as + // Bob — the act is attributed to him, and he is answerable for it. That is + // representation, and no amount of authority expresses it. + let mut appointment = DTGCredential::new_vdc( + bob_did.clone(), + scheduler_did.clone(), + now, + now + Duration::days(30), + vec!["schedule:read".into(), "schedule:propose".into()], + Some(0), // no re-delegation: Bob keeps the register of who speaks for him + )? + .with_id("urn:uuid:vdc-scheduler"); + appointment.sign(&bob_secret, None).await?; + println!("Bob issued a VDC: schedule:read + schedule:propose · 30 days · no re-delegation"); + + // A grant alone appoints nobody. Bob can name anyone as his delegate; what he cannot + // do is produce their signature. So the scheduler countersigns, taking on the + // accountability that comes with acting in someone else's name. + let grant_json = serde_json::to_value(appointment.credential())?; + let mut acceptance = + DTGCredential::new_delegate_vdc(&grant_json, now, now + Duration::days(30))? + .with_id("urn:uuid:vdc-scheduler-ack"); + acceptance.sign(&scheduler_secret, None).await?; + + if !acceptance.accepts(&appointment)? { + bail!("the acceptance must bind to the grant"); + } + println!("scheduler countersigned — the delegation edge is complete"); + + let appointed = delegation::verify_chain( + std::slice::from_ref(&appointment), + &bob_did, + "schedule:propose", + Utc::now(), + ) + .context("the scheduler's appointment must verify")?; + println!( + " chain verified → acts are attributed to {}, not to the scheduler", + &appointed.principal[..18] + ); + + // The rule that keeps the two credentials from reinterpreting each other. The VDC says + // the scheduler may speak in Bob's name; it says nothing about what Bob may do in the + // room, and confers none of Bob's authority on the scheduler. + match verify_chain( + std::slice::from_ref(&appointment), + &room_did, + &room_did, + "read", + &scheduler_did, + Utc::now(), + ) { + Err(e) => println!(" VDC correctly refused as authority: {e}"), + Ok(_) => bail!("a VDC must never be read as conferring authority"), + } + + // What the scheduler may actually do in the room is the intersection of the two: what + // the appointment covers, and what BOB's own authority covers — asked live, of Bob, + // at the time of the act. Revoking Bob's VAC stops the scheduler without touching the + // VDC at all. + println!(" reach = what the VDC appoints for ∩ what Bob may do — the second asked live"); + // -- 6 ------------------------------------------------------------------------------ - step(6, "Alice removes Bob"); + step(7, "Alice removes Bob"); // Removal is a rekey. The old key opens what it always did; the new one is sealed only // to who remains, so nothing written after is reachable. verify_chain( @@ -367,7 +439,7 @@ async fn main() -> Result<()> { ); // -- 7 ------------------------------------------------------------------------------ - step(7, "What the host can see"); + step(8, "What the host can see"); println!("The host stores this and nothing else. No plaintext, no member list, no"); println!("credentials — membership was never something it was told.\n"); println!(" room {room_did}"); diff --git a/src/authority.rs b/src/authority.rs index a5c1a10..9cc2730 100644 --- a/src/authority.rs +++ b/src/authority.rs @@ -21,20 +21,34 @@ //! | 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 | +//! | Every link must carry `validUntil` | authority nobody can withdraw by waiting | //! //! # Bearer-side resolution //! //! The holder presents every link. This module **never dereferences** -//! [`AuthorityGrant::parent`] to fetch a credential it was not given, and +//! [`crate::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. +//! Working Draft 02 made that structural rather than merely required: `parent` is a +//! **digest**, and a digest names nothing that can be fetched. So verification cannot come +//! to depend on availability, a verifier cannot be induced to make a request against an +//! address the *holder* chooses, and nobody hosting an identifier learns when a credential +//! is used. The digest also binds a link to the exact claims its issuer narrowed from, +//! which an identifier could not do: a parent re-issued with different claims does not +//! carry its old children with it. //! -//! Tracks a draft: `trustoverip/dtgwg-cred-spec` PR #29. +//! # Still ahead of this module +//! +//! Three 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. use chrono::{DateTime, Utc}; @@ -59,6 +73,18 @@ pub enum AuthorityError { #[error("authority chain is empty")] EmptyChain, + /// 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 + /// digest that disagrees, and a verifier that conflated the two would report a + /// malformed chain as a widening one. + #[error("digest error at index {index}: {reason}")] + Digest { index: usize, reason: String }, + + /// A link carried no `validUntil`, which a VAC MUST have. + #[error("VAC at index {index} carries no validUntil, which a VAC MUST have")] + NoExpiry { index: usize }, + /// The chain is longer than [MAX_CHAIN_DEPTH]. #[error("authority chain is {found} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}")] TooDeep { @@ -254,9 +280,14 @@ pub fn verify_chain( if c.valid_from() > at { return Err(AuthorityError::NotValidNow { index, at }); } - if let Some(until) = c.valid_until() - && until < at - { + // `validUntil` is REQUIRED on a VAC, not merely recommended. Nothing about the + // subject's current standing is consulted here, so a VAC that never expires is + // authority nobody can withdraw by waiting — and a verifier that accepted one + // would be honouring exactly that. + let Some(until) = c.valid_until() else { + return Err(AuthorityError::NoExpiry { index }); + }; + if until < at { return Err(AuthorityError::NotValidNow { index, at }); } } @@ -283,22 +314,39 @@ pub fn verify_chain( // 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(), - }); + // + // `parent` is a digest, not an identifier, so this is a hash comparison over the + // parent's claims — and the specification requires comparing decoded digest bytes + // rather than encoded strings, since one digest has more than one spelling. + let presented_digest = parent + .digest_multibase() + .map_err(|e| AuthorityError::Digest { + index: index + 1, + reason: e.to_string(), + })?; + match &grant.parent { + Some(named) => { + let matches = crate::digests_match(named, &presented_digest).map_err(|e| { + AuthorityError::Digest { + index, + reason: e.to_string(), + } + })?; + if !matches { + return Err(AuthorityError::BrokenLink { + index, + named: named.clone(), + presented: presented_digest, + }); + } } - (None, presented) => { + None => { // 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(), + presented: presented_digest, }); } } @@ -328,6 +376,7 @@ pub fn verify_chain( }); } } + // Both are present: the loop above rejected any link without one. if let (Some(until), Some(parent_until)) = ( link.credential().valid_until(), parent.credential().valid_until(), diff --git a/src/create.rs b/src/create.rs index d490ba8..d46dec9 100644 --- a/src/create.rs +++ b/src/create.rs @@ -5,9 +5,9 @@ #[allow(deprecated)] use crate::{ AuthorityGrant, CredentialSubject, CredentialSubjectAuthority, CredentialSubjectBasic, - CredentialSubjectEndorsement, CredentialSubjectMembership, CredentialSubjectRCard, - CredentialSubjectWitness, DTGCommon, DTGCredential, DTGCredentialError, DTGCredentialType, - WitnessContext, + CredentialSubjectDelegation, CredentialSubjectEndorsement, CredentialSubjectMembership, + CredentialSubjectRCard, CredentialSubjectWitness, DTGCommon, DTGCredential, DTGCredentialError, + DTGCredentialType, DelegationGrant, WitnessContext, }; use chrono::{DateTime, Utc}; use serde_json::Value; @@ -22,11 +22,11 @@ impl DTGCredential { /// but it cannot produce the acknowledgement without that party's signature. The pair /// is what makes an unconsented membership claim unprovable. /// - /// The grant MUST NOT carry a `digest` — that property is what marks the other - /// direction — and this constructor does not set one. + /// The grant MUST NOT carry a `digestMultibase` — that property is what marks the + /// other direction — and this constructor does not set one. /// - /// issuer: The C-DID of the VTC or VTN granting membership - /// subject: The M-DID of the member, or the member VTC's C-DID for VTN membership + /// issuer: The identifier of the VTC or VTN granting membership + /// subject: The member's identifier, or the member VTC's own for VTN membership /// valid_from: The datetime from which this credential is valid /// valid_until: Optional: The datetime this credential is valid until /// personhood: Whether this VMC can be used as a form of Personhood Credential @@ -49,7 +49,7 @@ impl DTGCredential { valid_until, credential_subject: CredentialSubject::Membership(CredentialSubjectMembership { id: subject, - digest: None, + digest_multibase: None, }), ..Default::default() }; @@ -71,7 +71,8 @@ impl DTGCredential { /// **acknowledgement**, the member → community half of a membership edge. /// /// The roles of [DTGCredential::new_vmc] are reversed (the member issues, the community - /// is the subject) and the subject carries a `digest` of the grant being acknowledged. + /// is the subject) and the subject carries a `digestMultibase` of the grant being + /// acknowledged. /// That digest is what binds the two halves into one edge: an acknowledgement whose /// digest matches no valid grant does not complete anything, and the binding forces an /// order — the grant must exist before this can reference it. @@ -135,10 +136,14 @@ impl DTGCredential { DTGCredentialError::NotAMembershipGrant("no `credentialSubject`".into()) })?; - if subject.contains_key("digest") { + // Both spellings: `digestMultibase` is the Working Draft 02 name, `digest` the + // Working Draft 01 one this library also accepts on the wire. Probing only the + // current name would let an acknowledgement issued against the older draft be + // acknowledged in turn, which forms no edge. + if subject.contains_key("digestMultibase") || subject.contains_key("digest") { return Err(DTGCredentialError::NotAMembershipGrant( - "the credential carries a `digest`, so it is itself a member-issued \ - acknowledgement rather than a community-issued grant" + "the credential carries a digest of another credential, so it is itself a \ + member-issued acknowledgement rather than a community-issued grant" .into(), )); } @@ -171,7 +176,7 @@ impl DTGCredential { valid_until, credential_subject: CredentialSubject::Membership(CredentialSubjectMembership { id: community, - digest: Some(crate::digest_json(grant)?), + digest_multibase: Some(crate::digest_multibase_json(grant)?), }), ..Default::default() }; @@ -250,14 +255,19 @@ impl DTGCredential { /// /// `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. + /// # `valid_until` is required + /// + /// Not optional, unlike the base structure and unlike every other `new_*` constructor + /// here. Nothing about the subject's current standing is consulted when a VAC is + /// verified, so authority that does not expire is authority nobody can withdraw by + /// waiting. pub fn new_vac( issuer: String, subject: String, scope: String, actions: Vec, valid_from: DateTime, - valid_until: Option>, + valid_until: DateTime, ) -> Result { if actions.is_empty() { return Err(DTGCredentialError::EmptyAuthorityActions); @@ -265,7 +275,7 @@ impl DTGCredential { let mut vac = DTGCommon { issuer, valid_from, - valid_until, + valid_until: Some(valid_until), credential_subject: CredentialSubject::Authority(CredentialSubjectAuthority { id: subject, authority: AuthorityGrant { @@ -292,7 +302,8 @@ impl DTGCredential { /// 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. + /// carries `parent` — the **digest** of the credential it narrows — 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 @@ -300,18 +311,25 @@ impl DTGCredential { /// 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). + /// - `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. + /// + /// # Digests the model + /// + /// The `parent` digest is computed with [DTGCredential::digest_multibase], which hashes + /// this in-memory credential. That is right for a VAC this process built and signed. + /// For one that **arrived from a counterparty**, use + /// [DTGCredential::attenuate_from_json] and give it the bytes you received — the same + /// distinction [DTGCredential::new_member_vmc] draws, and for the same reason. pub fn attenuate( &self, subject: String, actions: Vec, valid_from: DateTime, - valid_until: Option>, + valid_until: DateTime, audience: Option, ) -> Result { let parent_grant = self @@ -319,11 +337,111 @@ impl DTGCredential { .authority() .ok_or(DTGCredentialError::NotAnAuthorityCredential)?; - let parent_id = self - .id() - .ok_or(DTGCredentialError::AttenuationParentHasNoId)? + Self::attenuate_inner( + parent_grant.clone(), + self.credential().subject().to_string(), + self.credential().valid_until(), + self.digest_multibase()?, + subject, + actions, + valid_from, + valid_until, + audience, + ) + } + + /// Derive a narrower VAC from a parent in its **wire form**. + /// + /// Identical to [DTGCredential::attenuate] except that the parent is the JSON a + /// counterparty sent rather than a parsed credential, so the `parent` digest covers + /// the document the verifier will recompute it over. Use this whenever the VAC being + /// narrowed came from somewhere else. + /// + /// # Errors + /// + /// [DTGCredentialError::NotAnAuthorityCredential] if `parent` is not a JSON object + /// carrying `AuthorityCredential` in its `type` and a well-formed + /// `credentialSubject.authority`, and the same widening errors as + /// [DTGCredential::attenuate]. + pub fn attenuate_from_json( + parent: &Value, + subject: String, + actions: Vec, + valid_from: DateTime, + valid_until: DateTime, + audience: Option, + ) -> Result { + let object = parent + .as_object() + .ok_or(DTGCredentialError::NotAnAuthorityCredential)?; + + let is_authority = object + .get("type") + .and_then(Value::as_array) + .is_some_and(|types| { + types + .iter() + .filter_map(Value::as_str) + .any(|t| t == "AuthorityCredential") + }); + if !is_authority { + return Err(DTGCredentialError::NotAnAuthorityCredential); + } + + let parent_subject = object + .get("credentialSubject") + .and_then(Value::as_object) + .ok_or(DTGCredentialError::NotAnAuthorityCredential)?; + + // The holder attenuating is the parent's subject; reading it off the parent is what + // keeps a derived VAC from citing a chain its issuer never held. + let holder = parent_subject + .get("id") + .and_then(Value::as_str) + .ok_or(DTGCredentialError::NotAnAuthorityCredential)? .to_string(); + let parent_grant: AuthorityGrant = parent_subject + .get("authority") + .ok_or(DTGCredentialError::NotAnAuthorityCredential) + .and_then(|a| { + serde_json::from_value(a.clone()) + .map_err(|_| DTGCredentialError::NotAnAuthorityCredential) + })?; + + let parent_until = object + .get("validUntil") + .or_else(|| object.get("expirationDate")) + .and_then(Value::as_str) + .and_then(|t| DateTime::parse_from_rfc3339(t).ok()) + .map(|t| t.with_timezone(&Utc)); + + Self::attenuate_inner( + parent_grant, + holder, + parent_until, + crate::digest_multibase_json(parent)?, + subject, + actions, + valid_from, + valid_until, + audience, + ) + } + + /// The narrowing checks and the assembly, shared by both attenuation entry points. + #[allow(clippy::too_many_arguments)] + fn attenuate_inner( + parent_grant: AuthorityGrant, + holder: String, + parent_until: Option>, + parent_digest: String, + subject: String, + actions: Vec, + valid_from: DateTime, + valid_until: DateTime, + audience: Option, + ) -> Result { if actions.is_empty() { return Err(DTGCredentialError::EmptyAuthorityActions); } @@ -334,26 +452,26 @@ impl DTGCredential { ))); } } - if let (Some(until), Some(parent_until)) = (valid_until, self.credential().valid_until()) - && until > parent_until + if let Some(parent_until) = parent_until + && valid_until > parent_until { return Err(DTGCredentialError::AttenuationWidens(format!( - "validUntil {until} is beyond the parent's {parent_until}" + "validUntil {valid_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(), + issuer: holder, valid_from, - valid_until, + valid_until: Some(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), + parent: Some(parent_digest), audience, }, }), @@ -369,34 +487,416 @@ impl DTGCredential { }) } - /// Creates a new Verifiable Delegation Credential (VDC). + /// Creates a new Verifiable Delegation Credential (VDC) — the delegation **grant**, + /// the delegator → delegate half of a delegation edge. + /// + /// Establishes that `subject` may act **in the issuer's name**, for the acts named in + /// `scope`, until `valid_until`. Within that scope what the delegate does is + /// attributable to the delegator. + /// + /// # This is not authority /// - /// 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]. + /// A VDC never supplies permission the delegator did not itself hold. A verifier + /// substitutes the delegator for the delegate and then asks the permission question it + /// would have asked of the delegator directly — so withdrawing the delegator's own + /// permission ends the delegate's ability to act immediately, without revoking + /// anything. See [DTGCredential::new_vac] for the credential that answers that + /// question. /// - /// Tracks a draft (`trustoverip/dtgwg-cred-spec` PR #19); the shape may move. + /// # The edge is not complete without the acceptance + /// + /// This is one half. The delegate answers with [DTGCredential::new_delegate_vdc], and + /// a verifier MUST obtain and verify that half before accepting any party as acting + /// under the delegation: a grant alone establishes what the delegator appointed, not + /// what the delegate agreed to. Same consent rule as a membership edge, and for the + /// same reason — a delegator can always name someone as its delegate, but cannot + /// produce the countersignature. + /// + /// `scope` MUST NOT be empty: a VDC cannot express an unbounded appointment by + /// omitting it. + /// + /// `max_depth` is the number of further re-delegations permitted below this one. + /// `None` and `Some(0)` both prohibit re-delegation — the default is a single hop, and + /// setting it above zero is the delegator's explicit authorisation, of which there is + /// no other kind. + /// + /// # `valid_until` is required + /// + /// An appointment with no expiry cannot be reasoned about by a verifier that cannot + /// reach the delegator. + /// + /// # Errors + /// + /// [DTGCredentialError::MalformedDelegation] if `scope` is empty. pub fn new_vdc( issuer: String, subject: String, valid_from: DateTime, - valid_until: Option>, - ) -> Self { + valid_until: DateTime, + scope: Vec, + max_depth: Option, + ) -> Result { + if scope.is_empty() { + return Err(DTGCredentialError::MalformedDelegation( + "a grant MUST carry at least one `scope` entry — a VDC cannot express an \ + unbounded appointment by emptying it" + .into(), + )); + } + let mut vdc = DTGCommon { issuer, valid_from, + valid_until: Some(valid_until), + credential_subject: CredentialSubject::Delegation(CredentialSubjectDelegation { + id: subject, + delegation: DelegationGrant { + scope: Some(scope), + parent: None, + max_depth, + accepts: None, + }, + }), + ..Default::default() + }; + + vdc.type_.push(DTGCredentialType::Delegation.to_string()); + + Ok(DTGCredential { + credential: vdc, + type_: DTGCredentialType::Delegation, + version: crate::W3CVCVersion::V2_0, + }) + } + + /// Derive a further VDC from one this delegate already holds — a **re-delegation**. + /// + /// Only permitted where the held VDC sets `maxDepth` above zero, and only for a subset + /// of the acts it was itself appointed for. The default is a single hop: a delegate + /// that needs a further delegate and is not authorised to re-delegate asks the + /// principal, who issues a fresh root delegation directly — so that the principal + /// always holds the complete register of who may speak in its name. + /// + /// The derived VDC carries `parent`, the digest of the VDC it derives from, and a + /// `maxDepth` one less than its parent's. + /// + /// Like [DTGCredential::attenuate], this digests the in-memory model; for a grant that + /// arrived from a counterparty, use [DTGCredential::redelegate_from_json]. + /// + /// # Errors + /// + /// [DTGCredentialError::MalformedDelegation] if `self` is not a delegation grant, if + /// it does not permit re-delegation, if `scope` is empty or not a subset of the + /// parent's, or if `valid_until` is later than the parent's. + pub fn redelegate( + &self, + subject: String, + scope: Vec, + valid_from: DateTime, + valid_until: DateTime, + ) -> Result { + let parent = self.credential().delegation().ok_or_else(|| { + DTGCredentialError::MalformedDelegation("not a DelegationCredential".into()) + })?; + + Self::redelegate_inner( + parent.clone(), + self.credential().subject().to_string(), + self.credential().valid_until(), + self.digest_multibase()?, + subject, + scope, + valid_from, valid_until, - credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: subject }), + ) + } + + /// Derive a further VDC from a parent grant in its **wire form**. + /// + /// Identical to [DTGCredential::redelegate] except that the parent is the JSON the + /// delegator sent, so the `parent` digest covers the document a verifier will + /// recompute it over. + pub fn redelegate_from_json( + parent: &Value, + subject: String, + scope: Vec, + valid_from: DateTime, + valid_until: DateTime, + ) -> Result { + let (delegate, grant, parent_until) = Self::read_delegation_json(parent)?; + + Self::redelegate_inner( + grant, + delegate, + parent_until, + crate::digest_multibase_json(parent)?, + subject, + scope, + valid_from, + valid_until, + ) + } + + /// The narrowing checks and the assembly, shared by both re-delegation entry points. + #[allow(clippy::too_many_arguments)] + fn redelegate_inner( + parent_grant: DelegationGrant, + holder: String, + parent_until: Option>, + parent_digest: String, + subject: String, + scope: Vec, + valid_from: DateTime, + valid_until: DateTime, + ) -> Result { + if parent_grant.accepts.is_some() { + return Err(DTGCredentialError::MalformedDelegation( + "the parent is an acceptance, not a grant — an acceptance appoints nobody \ + and cannot be re-delegated from" + .into(), + )); + } + + // Absence prohibits re-delegation just as `0` does. This is the opposite default + // from a VAC, deliberately: a delegate speaks in the principal's name, so the + // principal keeps the register of who may do so. + let parent_depth = parent_grant.max_depth.unwrap_or(0); + if parent_depth == 0 { + return Err(DTGCredentialError::MalformedDelegation( + "the parent does not permit re-delegation — `maxDepth` is absent or zero, \ + and setting it above zero is the delegator's only way to authorise one" + .into(), + )); + } + + if scope.is_empty() { + return Err(DTGCredentialError::MalformedDelegation( + "a grant MUST carry at least one `scope` entry".into(), + )); + } + let parent_scope = parent_grant.scope.as_deref().unwrap_or(&[]); + for act in &scope { + if !parent_scope.contains(act) { + return Err(DTGCredentialError::MalformedDelegation(format!( + "`{act}` is not in the scope this delegation derives from" + ))); + } + } + if let Some(parent_until) = parent_until + && valid_until > parent_until + { + return Err(DTGCredentialError::MalformedDelegation(format!( + "validUntil {valid_until} is beyond the parent's {parent_until}" + ))); + } + + let mut vdc = DTGCommon { + issuer: holder, + valid_from, + valid_until: Some(valid_until), + credential_subject: CredentialSubject::Delegation(CredentialSubjectDelegation { + id: subject, + delegation: DelegationGrant { + scope: Some(scope), + parent: Some(parent_digest), + max_depth: Some(parent_depth - 1), + accepts: None, + }, + }), ..Default::default() }; vdc.type_.push(DTGCredentialType::Delegation.to_string()); - DTGCredential { + Ok(DTGCredential { + credential: vdc, + type_: DTGCredentialType::Delegation, + version: crate::W3CVCVersion::V2_0, + }) + } + + /// Creates the delegate-issued half of a delegation edge — the **acceptance**. + /// + /// The roles of [DTGCredential::new_vdc] are reversed (the delegate issues, the + /// delegator is the subject) and the subject carries `accepts`, the digest of the + /// grant being taken on. That digest is what binds the two halves into one edge. + /// + /// An acceptance carries no `scope` of its own. What the delegate consented to is the + /// scope of the grant it names, which a verifier holds in any case; restating it would + /// require an equality check across the two credentials that cannot be satisfied under + /// selective disclosure of either. + /// + /// This is the delegate's consent artifact, and its accountability for acting in + /// another's name. Because a delegator cannot produce it, a party holding only the + /// delegate's key cannot manufacture appointments either. + /// + /// # Takes the grant in its wire form, deliberately + /// + /// Same reasoning as [DTGCredential::new_member_vmc]: the digest has to cover the + /// document the delegator will recompute it over. Keep the bytes you were given and + /// pass them here. + /// + /// # Errors + /// + /// [DTGCredentialError::NotADelegationGrant] if `grant` is not a JSON object carrying + /// `DelegationCredential` in its `type`, has no `issuer` or `credentialSubject.id`, or + /// already carries `accepts` — that last is itself an acceptance, and accepting one + /// forms no edge. + pub fn new_delegate_vdc( + grant: &Value, + valid_from: DateTime, + valid_until: DateTime, + ) -> Result { + let object = grant + .as_object() + .ok_or_else(|| DTGCredentialError::NotADelegationGrant("not a JSON object".into()))?; + + let is_delegation = object + .get("type") + .and_then(Value::as_array) + .is_some_and(|types| { + types + .iter() + .filter_map(Value::as_str) + .any(|t| t == "DelegationCredential") + }); + if !is_delegation { + return Err(DTGCredentialError::NotADelegationGrant( + "`type` does not include `DelegationCredential`".into(), + )); + } + + let subject = object + .get("credentialSubject") + .and_then(Value::as_object) + .ok_or_else(|| { + DTGCredentialError::NotADelegationGrant("no `credentialSubject`".into()) + })?; + + let delegation = subject + .get("delegation") + .and_then(Value::as_object) + .ok_or_else(|| { + DTGCredentialError::NotADelegationGrant("no `credentialSubject.delegation`".into()) + })?; + + if delegation.contains_key("accepts") { + return Err(DTGCredentialError::NotADelegationGrant( + "the credential carries `accepts`, so it is itself an acceptance rather \ + than a grant" + .into(), + )); + } + if !delegation.contains_key("scope") { + return Err(DTGCredentialError::NotADelegationGrant( + "the grant carries no `scope`, so there is no appointment to accept".into(), + )); + } + + // The delegate is the grant's subject and the delegator its issuer. Reading both + // off the grant is what keeps the two halves naming the same pair — taking them as + // parameters would let a caller accept one grant while naming the parties of + // another, which verifies as a digest match and means nothing. + let delegate = subject + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| { + DTGCredentialError::NotADelegationGrant("no `credentialSubject.id`".into()) + })? + .to_string(); + + // `issuer` is a string or an object with an `id`, per the W3C data model. + let delegator = object + .get("issuer") + .and_then(|i| { + i.as_str() + .map(str::to_string) + .or_else(|| i.get("id").and_then(Value::as_str).map(str::to_string)) + }) + .ok_or_else(|| DTGCredentialError::NotADelegationGrant("no `issuer`".into()))?; + + let mut vdc = DTGCommon { + issuer: delegate, + valid_from, + valid_until: Some(valid_until), + credential_subject: CredentialSubject::Delegation(CredentialSubjectDelegation { + id: delegator, + delegation: DelegationGrant { + scope: None, + parent: None, + max_depth: None, + accepts: Some(crate::digest_multibase_json(grant)?), + }, + }), + ..Default::default() + }; + + vdc.type_.push(DTGCredentialType::Delegation.to_string()); + + Ok(DTGCredential { credential: vdc, type_: DTGCredentialType::Delegation, version: crate::W3CVCVersion::V2_0, + }) + } + + /// Reads the delegate, the grant, and the parent's expiry off a VDC in its wire form. + fn read_delegation_json( + doc: &Value, + ) -> Result<(String, DelegationGrant, Option>), DTGCredentialError> { + let object = doc + .as_object() + .ok_or_else(|| DTGCredentialError::MalformedDelegation("not a JSON object".into()))?; + + let is_delegation = object + .get("type") + .and_then(Value::as_array) + .is_some_and(|types| { + types + .iter() + .filter_map(Value::as_str) + .any(|t| t == "DelegationCredential") + }); + if !is_delegation { + return Err(DTGCredentialError::MalformedDelegation( + "`type` does not include `DelegationCredential`".into(), + )); } + + let subject = object + .get("credentialSubject") + .and_then(Value::as_object) + .ok_or_else(|| { + DTGCredentialError::MalformedDelegation("no `credentialSubject`".into()) + })?; + + let delegate = subject + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| { + DTGCredentialError::MalformedDelegation("no `credentialSubject.id`".into()) + })? + .to_string(); + + let grant: DelegationGrant = subject + .get("delegation") + .ok_or_else(|| { + DTGCredentialError::MalformedDelegation("no `credentialSubject.delegation`".into()) + }) + .and_then(|d| { + serde_json::from_value(d.clone()).map_err(|e| { + DTGCredentialError::MalformedDelegation(format!("malformed `delegation`: {e}")) + }) + })?; + + let until = object + .get("validUntil") + .or_else(|| object.get("expirationDate")) + .and_then(Value::as_str) + .and_then(|t| DateTime::parse_from_rfc3339(t).ok()) + .map(|t| t.with_timezone(&Utc)); + + Ok((delegate, grant, until)) } /// Creates a new Verified Persona Credential (VPC) @@ -461,17 +961,18 @@ impl DTGCredential { } /// Creates a new Verified Witness Credential (VWC) - /// issuer: The issuer DID of the credential - an M-DID, or the DID of a VTA acting - /// according to VTC policy + /// issuer: The issuer DID of the credential - a member's identifier, or the DID of a + /// VTA acting according to VTC policy /// subject: The DID of the observed party. For a witnessed bi-directional exchange this /// MUST be the issuer of the VRC that this VWC attests (the VRC referenced by - /// `digest`), so that the two VWCs of an exchange are unambiguously bound to + /// `digestMultibase`), so that the two VWCs of an exchange are unambiguously bound to /// their respective directions. The witness should issue one VWC per direction. /// valid_from: The datetime from which this credential is valid /// valid_until: Optional: The datetime this credential is valid until /// task_context: Required `threadId` of the trust task exchange the witnessing occurred in /// digest: Cryptographic hash of the witnessed edge credential, binding this VWC to the - /// specific edge. Produce it with [DTGCredential::digest] on that credential. + /// specific edge. Produce it with [DTGCredential::digest_multibase] on that + /// credential, or [crate::digest_multibase_json] on the bytes you received. /// REQUIRED by the specification; `Option` here because a VWC that predates the /// requirement still has to deserialize. A VWC without one identifies the /// observed party and the exchange, but not which edge was witnessed. @@ -492,7 +993,7 @@ impl DTGCredential { task_context: Some(task_context), credential_subject: CredentialSubject::Witness(CredentialSubjectWitness { id: subject, - digest, + digest_multibase: digest, witness_context, }), ..Default::default() @@ -953,7 +1454,7 @@ mod tests { "taskContext": "thread-abc-123", "credentialSubject": { "id": "did:example:subject", - "digest": "zQmbGXRT3v1RmfWkQ7Y3Z5Uj9pKq2NcXhLd8sVtA4eB6nMw", + "digestMultibase": "zQmbGXRT3v1RmfWkQ7Y3Z5Uj9pKq2NcXhLd8sVtA4eB6nMw", "witnessContext": { "event": "EthDenver 2024", "sessionId": "session-8822-nonce", diff --git a/src/delegation.rs b/src/delegation.rs new file mode 100644 index 0000000..abc8eb7 --- /dev/null +++ b/src/delegation.rs @@ -0,0 +1,375 @@ +//! Verifying a chain of Verifiable Delegation Credentials. +//! +//! # What a VDC chain establishes, and what it does not +//! +//! A delegation chain answers exactly one question: *may this party act in the +//! principal's name, for this act?* It does **not** answer whether the act is permitted. +//! A VDC neither carries authority nor confers it — a verifier substitutes the principal +//! for the delegate and then asks the permission question it would have asked of the +//! principal directly, live, at the time of the act. +//! +//! [`verify_chain`] therefore returns a [`VerifiedDelegation`] naming the principal and +//! the acts the chain appoints for. That is one of two checks. The other — may the +//! *principal* do this? — is the caller's, and a governing party may add a third: whether +//! the delegate must independently qualify. The reach of a delegated act is the +//! **intersection** of what the principal may do and what the chain appoints for; never +//! the union, and never more than either. +//! +//! # Re-delegation is opt-in, unlike attenuation +//! +//! A VAC may be attenuated by default; a VDC may be re-delegated only where its `maxDepth` +//! says so, and absence prohibits it. The asymmetry is deliberate. A delegate speaks in +//! the principal's name, so the principal keeps the register of who may do that and can +//! withdraw any of them; a delegate that needs a further delegate ordinarily asks for a +//! fresh root delegation rather than minting one. Re-delegation exists for when that round +//! trip is unavailable. +//! +//! # Bearer-side resolution +//! +//! As in [`crate::authority`], the holder presents every link and this module never +//! dereferences a `parent` to fetch one. Presenting a derived VDC discloses the whole +//! ancestry, including the principal's identity — which is the other reason a single hop +//! is the default. +//! +//! # Not implemented here +//! +//! **The acceptance.** A delegation edge is complete only when the delegate has +//! countersigned, and a verifier MUST obtain and verify that half before accepting any +//! party as acting under the delegation. This module verifies the grant chain; +//! [`crate::DTGCredential::accepts`] checks an acceptance against its grant, and a +//! verifier needs both. +//! +//! **Revocation.** A VDC's `credentialStatus` is a live lookup this crate does not +//! perform. See [`crate::DTGCommon::credential_status`]. +//! +//! **Invocation binding.** A VDC is not a bearer token. Nothing here establishes that the +//! party presenting the chain controls the leaf's `credentialSubject.id`; that +//! demonstration belongs to the trust task in which the delegation is exercised. + +use chrono::{DateTime, Utc}; + +use crate::{DTGCredential, DTGCredentialType}; + +/// Why a delegation chain was refused. +/// +/// Each variant names a specific way of acquiring representation that was not granted. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DelegationError { + /// The chain was empty. Nothing to verify. + #[error("delegation chain is empty")] + EmptyChain, + + /// The chain is longer than [MAX_CHAIN_DEPTH]. + #[error("delegation chain is {found} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}")] + TooDeep { found: usize }, + + /// A link was not a DelegationCredential carrying a grant. + #[error("credential at index {index} is a {found}, not a delegation grant")] + NotADelegationGrant { index: usize, found: String }, + + /// A link carried `accepts`, making it an acceptance rather than a grant. + /// + /// An acceptance appoints nobody; it consents to an appointment made elsewhere. A + /// chain built from them establishes no representation. + #[error("credential at index {index} is an acceptance, not a grant")] + AcceptanceInChain { index: usize }, + + /// A link's `scope` was empty or absent. + #[error("delegation at index {index} carries no scope, and so appoints for nothing")] + NoScope { index: usize }, + + /// A link carried no `validUntil`, which a VDC MUST have. + #[error("VDC at index {index} carries no validUntil, which a VDC MUST have")] + NoExpiry { index: usize }, + + /// A link was outside its validity window at the instant asked about. + #[error("delegation at index {index} is not valid at {at}")] + NotValidNow { index: usize, at: DateTime }, + + /// A link's `parent` did not match the credential presented above it. + #[error("delegation at index {index} names parent {named}, but {presented} was presented")] + BrokenLink { + index: usize, + named: String, + presented: String, + }, + + /// A link's digest could not be computed, or one it carries could not be read. + #[error("digest error at index {index}: {reason}")] + Digest { index: usize, reason: String }, + + /// A link was issued by someone other than the delegate its parent appointed. + #[error( + "delegation at index {index} is issued by {issuer}, but its parent appointed {subject}" + )] + IssuerNotParentSubject { + index: usize, + issuer: String, + subject: String, + }, + + /// A link appointed for an act absent from its parent's scope. + #[error("delegation at index {index} adds `{act}`, which its parent does not appoint for")] + WidensScope { index: usize, act: String }, + + /// A link outlived the delegation it derives from. + #[error( + "delegation at index {index} is valid until {until}, beyond its parent's {parent_until}" + )] + OutlivesParent { + index: usize, + until: DateTime, + parent_until: DateTime, + }, + + /// A re-delegation was made below a VDC that does not permit one. + /// + /// `maxDepth` absent or `0` prohibits it, and there is no other way to authorise one. + #[error( + "delegation at index {index} re-delegates below a parent whose maxDepth is {parent_depth}" + )] + RedelegationNotPermitted { index: usize, parent_depth: u32 }, + + /// A link's `maxDepth` exceeded one less than its parent's. + #[error( + "delegation at index {index} bears maxDepth {depth}, above its parent's {parent_depth} - 1" + )] + DepthNotNarrowed { + index: usize, + depth: u32, + parent_depth: u32, + }, + + /// The chain's root was not issued by the principal the verifier intends to deal with. + #[error("chain root is issued by {root_issuer}, not the principal {expected}")] + RootNotPrincipal { + root_issuer: String, + expected: String, + }, + + /// The leaf does not appoint for the act asked about. + #[error("the delegation does not appoint for `{act}`")] + ActNotAppointed { act: String }, +} + +/// Maximum number of VDCs in a chain, including the root delegation. +/// +/// The specification bounds depth per-ancestor through `maxDepth` rather than globally, +/// and a conforming chain is bounded by that. This ceiling is a second, blunter bound on +/// the same denial-of-service surface the VAC's has: verification is linear in depth and +/// runs on every presentation. +pub const MAX_CHAIN_DEPTH: usize = 8; + +/// What a verified delegation chain establishes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedDelegation { + /// The delegate the chain appoints — the leaf's subject. + pub delegate: String, + + /// The acts the chain appoints the delegate for, which is never more than the root + /// delegation conferred. + pub scope: Vec, + + /// The entity in whose name the acts would be performed, and to whom they are + /// attributed. The root delegation's issuer. + pub principal: String, +} + +/// Verify a chain of delegation grants and return what it appoints for. +/// +/// `chain` is **leaf first**: `chain[0]` is the credential being presented, and the last +/// element must be the root delegation issued by `principal`. Every link the holder relies +/// on must be present — this function never fetches one. +/// +/// The signature on each credential is *not* checked here, nor is any acceptance, nor any +/// revocation status. Verify those separately; this function answers whether a set of +/// otherwise valid grants adds up to the representation claimed. +/// +/// # This establishes representation, not permission +/// +/// A successful return means the delegate may act in `principal`'s name for +/// `requested_act`. Whether *`principal`* may perform that act is a separate question this +/// crate does not answer, and a VDC never influences its outcome. +pub fn verify_chain( + chain: &[DTGCredential], + principal: &str, + requested_act: &str, + at: DateTime, +) -> Result { + if chain.is_empty() { + return Err(DelegationError::EmptyChain); + } + if chain.len() > MAX_CHAIN_DEPTH { + return Err(DelegationError::TooDeep { found: chain.len() }); + } + + // Every link must be a delegation *grant* carrying a scope and an expiry. + for (index, link) in chain.iter().enumerate() { + if !matches!(link.type_(), DTGCredentialType::Delegation) { + return Err(DelegationError::NotADelegationGrant { + index, + found: link.type_().to_string(), + }); + } + let grant = + link.credential() + .delegation() + .ok_or_else(|| DelegationError::NotADelegationGrant { + index, + found: "DelegationCredential without a delegation object".to_string(), + })?; + + // An acceptance consents to an appointment; it does not make one. A chain of them + // establishes nothing. + if grant.accepts.is_some() { + return Err(DelegationError::AcceptanceInChain { index }); + } + if grant.scope.as_ref().is_none_or(|s| s.is_empty()) { + return Err(DelegationError::NoScope { index }); + } + + let c = link.credential(); + if c.valid_from() > at { + return Err(DelegationError::NotValidNow { index, at }); + } + // REQUIRED on a VDC: an appointment with no expiry cannot be reasoned about by a + // verifier that cannot reach the delegator. + let Some(until) = c.valid_until() else { + return Err(DelegationError::NoExpiry { index }); + }; + if until < at { + return Err(DelegationError::NotValidNow { index, at }); + } + } + + // 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().delegation().expect("checked above"); + let parent_grant = parent.credential().delegation().expect("checked above"); + + // The link must point at the credential presented as its parent, by digest — + // compared as decoded bytes, never as strings. + let presented_digest = parent + .digest_multibase() + .map_err(|e| DelegationError::Digest { + index: index + 1, + reason: e.to_string(), + })?; + match &grant.parent { + Some(named) => { + let matches = crate::digests_match(named, &presented_digest).map_err(|e| { + DelegationError::Digest { + index, + reason: e.to_string(), + } + })?; + if !matches { + return Err(DelegationError::BrokenLink { + index, + named: named.clone(), + presented: presented_digest, + }); + } + } + None => { + // A link with no `parent` claims to be a root delegation, but something + // was presented above it. + return Err(DelegationError::BrokenLink { + index, + named: "".to_string(), + presented: presented_digest, + }); + } + } + + // Only the party a delegation appointed may re-delegate it. + if link.credential().issuer() != parent.credential().subject() { + return Err(DelegationError::IssuerNotParentSubject { + index, + issuer: link.credential().issuer().to_string(), + subject: parent.credential().subject().to_string(), + }); + } + + // Re-delegation must have been authorised, and each step must narrow the budget. + let parent_depth = parent_grant.max_depth.unwrap_or(0); + if parent_depth == 0 { + return Err(DelegationError::RedelegationNotPermitted { + index, + parent_depth, + }); + } + if let Some(depth) = grant.max_depth + && depth > parent_depth - 1 + { + return Err(DelegationError::DepthNotNarrowed { + index, + depth, + parent_depth, + }); + } + + // Scope is set inclusion over exact matches: the specification defines no wildcard, + // prefix or hierarchical semantics, so a governing vocabulary wanting structure + // must put it in the terms themselves. + let parent_scope = parent_grant.scope.as_deref().expect("checked above"); + for act in grant.scope.as_deref().expect("checked above") { + if !parent_scope.contains(act) { + return Err(DelegationError::WidensScope { + index, + act: act.clone(), + }); + } + } + + // Both are present: the loop above rejected any link without one. + if let (Some(until), Some(parent_until)) = ( + link.credential().valid_until(), + parent.credential().valid_until(), + ) && until > parent_until + { + return Err(DelegationError::OutlivesParent { + index, + until, + parent_until, + }); + } + } + + // The chain must terminate in a root delegation issued by the principal — the entity + // in whose name the acts would ultimately be performed. A chain that cannot be + // resolved to such a root establishes no representation. + let root = chain.last().expect("non-empty"); + let root_grant = root.credential().delegation().expect("checked above"); + if root.credential().issuer() != principal { + return Err(DelegationError::RootNotPrincipal { + root_issuer: root.credential().issuer().to_string(), + expected: principal.to_string(), + }); + } + if let Some(named) = &root_grant.parent { + // The chain was truncated: its "root" derives from something not presented. + return Err(DelegationError::BrokenLink { + index: chain.len() - 1, + named: named.clone(), + presented: "".to_string(), + }); + } + + let leaf = &chain[0]; + let leaf_grant = leaf.credential().delegation().expect("checked above"); + let scope = leaf_grant.scope.clone().expect("checked above"); + if !scope.iter().any(|a| a == requested_act) { + return Err(DelegationError::ActNotAppointed { + act: requested_act.to_string(), + }); + } + + Ok(VerifiedDelegation { + delegate: leaf.credential().subject().to_string(), + scope, + principal: principal.to_string(), + }) +} diff --git a/src/lib.rs b/src/lib.rs index b6460fc..aa7a27a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ use thiserror::Error; pub mod authority; pub mod create; +pub mod delegation; /// What W3C VC Format is the credential using? #[derive(Clone, Copy, Debug)] @@ -71,12 +72,43 @@ pub enum DTGCredentialError { /// [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. + /// No longer produced. Working Draft 02 makes `authority.parent` a **digest** of the + /// parent rather than its `id`, precisely so that no credential needs a top-level + /// identifier merely in order to be referenced. + #[deprecated( + since = "0.7.0", + note = "Never returned. `authority.parent` is a digest as of Working Draft 02, so a \ + parent VAC no longer needs an `id` to be attenuated. This variant will be \ + removed in a future release." + )] #[error("cannot attenuate a credential with no id — the derived VAC could not name it")] AttenuationParentHasNoId, + /// A digest value was not a well-formed `digestMultibase`. + /// + /// Either the multibase envelope or the multihash inside it failed to decode. A + /// `sha256:` value produced against Working Draft 01 lands here, which is the + /// intended outcome: it is reported rather than silently compared as unequal. + #[error("not a well-formed digestMultibase value: {0}")] + InvalidDigest(String), + + /// A digest named a hash algorithm this library does not implement. + /// + /// The specification permits a governing party to require a stronger hash, and carries + /// the algorithm in the value itself. A verifier MUST reject an algorithm it does not + /// accept rather than treating it as a mismatch — hence a distinct error. + #[error("digest uses multihash algorithm 0x{0:x}, which this library does not accept")] + UnsupportedDigestAlgorithm(u64), + + /// A DelegationCredential (VDC) was not a well-formed grant or acceptance. + #[error("malformed DelegationCredential: {0}")] + MalformedDelegation(String), + + /// A delegation acknowledgement was built against something that is not a + /// delegation grant. + #[error("Not a delegation grant: {0}")] + NotADelegationGrant(String), + /// An attenuation attempted to confer more than its parent held. #[error("attenuation would widen the parent grant: {0}")] AttenuationWidens(String), @@ -174,14 +206,17 @@ impl DTGCredential { self.credential.task_context() } - /// This credential's digest, as the `digest` property of a credential that references - /// it — a member-issued VMC acknowledging a membership grant, or a VWC attesting an - /// edge credential. + /// This credential's digest, in the encoding a credential that references it carries — + /// a member-issued VMC acknowledging a membership grant, a VWC attesting an edge + /// credential, or the `parent` of an attenuated VAC. + /// + /// Per DTG Core Credentials [Digest Encoding], that is the SHA-256 hash of the + /// credential's JSON representation **excluding its top-level `proof` member**, + /// canonicalized with the JSON Canonicalization Scheme + /// ([JCS, RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)), wrapped in a + /// `sha2-256` multihash and encoded base58btc with a multibase `z` prefix. /// - /// Per DTG Core Credentials, the digest is the SHA-256 hash of the credential's JSON - /// representation **excluding its top-level `proof` member**, canonicalized with the - /// JSON Canonicalization Scheme ([JCS, RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)), - /// encoded as `sha256:` followed by the lowercase hexadecimal digest. + /// [Digest Encoding]: https://github.com/trustoverip/dtgwg-cred-spec /// /// # Why `proof` is excluded /// @@ -190,17 +225,32 @@ impl DTGCredential { /// re-signed grant carrying identical claims still satisfies an acknowledgement made /// against the earlier signature. It also means the digest can be computed before the /// referent is signed, and is stable whichever of its proofs a holder happens to have. - /// # ⚠️ Only for a credential this library built /// - /// This digests the *model*, and the model does not carry every member a - /// credential may have — `credentialStatus`, for one, which every VMC issued - /// against a status list carries and which `DTGCommon` does not model. Digesting a - /// credential that was **received** rather than built here therefore hashes a - /// document with those members missing, producing a digest the sender will not - /// recognise. + /// # Prefer the wire form for a credential you received /// - /// For a credential that arrived from somewhere else, digest the JSON you received - /// with [`digest_json`] — never a re-serialisation of a parse of it. + /// This digests the model. [`DTGCommon::extra`] carries top-level members this library + /// does not model through a round trip, so for most received credentials the two agree + /// — but a member *inside* `credentialSubject` that the subject types do not model is + /// still not represented. Where you still hold the bytes a counterparty sent, digest + /// those with [`digest_multibase_json`]. + pub fn digest_multibase(&self) -> Result { + let unsigned = DTGCommon { + proof: None, + ..self.credential.clone() + }; + let value = serde_json::to_value(&unsigned) + .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?; + digest_multibase_json(&value) + } + + /// This credential's digest in the superseded `sha256:` encoding. + #[deprecated( + since = "0.7.0", + note = "Working Draft 02 replaced the `sha256:` digest with a base58btc \ + multibase multihash under the property name `digestMultibase`. Use \ + DTGCredential::digest_multibase. This method will be removed in a future \ + release." + )] pub fn digest(&self) -> Result { let unsigned = DTGCommon { proof: None, @@ -208,61 +258,62 @@ impl DTGCredential { }; let value = serde_json::to_value(&unsigned) .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?; + #[allow(deprecated)] digest_json(&value) } /// The digest this credential carries of the credential it references, if it carries one. /// - /// `Some` for a member-issued VMC (which MUST carry one) and for a VWC bound to the edge - /// credential it attests; `None` for a community-issued VMC, which MUST omit it, and for - /// every credential type that has no `digest` property. + /// `Some` for a member-issued VMC (which MUST carry one), for a VWC bound to the edge + /// credential it attests, for an attenuated VAC (`authority.parent`), and for a + /// derived or accepting VDC (`delegation.parent` / `delegation.accepts`). `None` for a + /// community-issued VMC, which MUST omit it, and for a credential that references + /// nothing. pub fn subject_digest(&self) -> Option<&str> { match &self.credential.credential_subject { - CredentialSubject::Membership(subject) => subject.digest.as_deref(), - CredentialSubject::Witness(subject) => subject.digest.as_deref(), + CredentialSubject::Membership(subject) => subject.digest_multibase.as_deref(), + CredentialSubject::Witness(subject) => subject.digest_multibase.as_deref(), + CredentialSubject::Authority(subject) => subject.authority.parent.as_deref(), + CredentialSubject::Delegation(subject) => subject + .delegation + .accepts + .as_deref() + .or(subject.delegation.parent.as_deref()), _ => None, } } - /// Computes the digest of this credential in the multibase multihash encoding. - /// - /// The underlying hash differs from [DTGCredential::digest] in two ways: it is encoded as - /// a base58btc multibase multihash rather than `sha256:`, and it covers the - /// credential *including* its `proof`. - #[deprecated( - since = "0.4.0", - note = "This encoding is not what DTG Core Credentials specifies, so digests \ - produced by it do not interoperate. Use DTGCredential::digest, which \ - returns the conformant `sha256:` over the proofless JCS \ - canonical form. This method will be removed in a future release." - )] - pub fn digest_multibase(&self) -> Result { - let canonical = serde_json_canonicalizer::to_vec(&self.credential) - .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?; - - // multihash prefix: 0x12 = sha2-256, 0x20 = 32 byte digest length - let mut multihash = Vec::with_capacity(34); - multihash.extend_from_slice(&[0x12, 0x20]); - multihash.extend_from_slice(&Sha256::digest(&canonical)); - - Ok(multibase::encode(Base::Base58Btc, &multihash)) - } - - /// Checks that this credential's `digest` matches the credential it claims to reference. + /// Checks that the digest this credential carries matches the credential it claims to + /// reference. /// /// Answers one question only — whether the hashes agree. It does not check that the two /// credentials are of the types the reference requires, nor that their issuers and /// subjects line up. For a membership acknowledgement, [DTGCredential::acknowledges] /// checks all of that together and is what a verifier completing an edge should call. /// + /// # Compares bytes, not strings + /// + /// The specification requires a verifier to decode the multibase envelope and the + /// multihash inside it, and to compare the algorithm identifier and the raw digest — + /// never the encoded strings. Two equal digests can be written differently, and a + /// string comparison would report a mismatch where the credentials agree. + /// /// Returns `Ok(false)` if the digests do not match, or if this credential carries no - /// `digest`, in which case there is nothing to rely on. + /// digest, in which case there is nothing to rely on. + /// + /// # Errors + /// + /// [DTGCredentialError::InvalidDigest] if the carried value is not a well-formed + /// `digestMultibase` — a Working Draft 01 `sha256:` value among them — and + /// [DTGCredentialError::UnsupportedDigestAlgorithm] if it names a hash this library + /// does not implement. Both are reported rather than folded into `Ok(false)`: a digest + /// that cannot be read is not a digest that disagrees. pub fn verify_digest(&self, referenced: &DTGCredential) -> Result { - let Some(digest) = self.subject_digest() else { + let Some(carried) = self.subject_digest() else { return Ok(false); }; - Ok(digest == referenced.digest()?) + digests_match(carried, &referenced.digest_multibase()?) } /// Does this member-issued VMC acknowledge `grant`, completing that membership edge? @@ -308,6 +359,57 @@ impl DTGCredential { self.verify_digest(grant) } + /// Does this delegate-issued VDC accept `grant`, completing that delegation edge? + /// + /// A delegation edge is complete only when both VDCs exist and are valid: the + /// delegator's grant, and the delegate's acceptance of it. This checks everything that + /// binds the two together: + /// + /// 1. `grant` is a `DelegationCredential` carrying `scope` and no `accepts` — a grant + /// 2. `self` is a `DelegationCredential` carrying `accepts` — an acceptance + /// 3. the two name the same pair of parties in mirrored roles: this credential's issuer + /// is the grant's subject, and its subject is the grant's issuer + /// 4. the `accepts` digest matches the grant + /// + /// Returns `Ok(false)` where any of those does not hold, rather than distinguishing + /// them: a caller deciding whether an edge is complete has one decision to make, and + /// every failing case answers it the same way. + /// + /// # What this does not check + /// + /// Neither credential's proof, neither validity window, and neither's revocation + /// status. Nor does it establish that the *delegator* may perform the act in question + /// — that is a separate question, asked of the delegator at the time of the act, which + /// a VDC moves but never answers. This covers the binding. + pub fn accepts(&self, grant: &DTGCredential) -> Result { + if !matches!(self.type_, DTGCredentialType::Delegation) + || !matches!(grant.type_, DTGCredentialType::Delegation) + { + return Ok(false); + } + + let (Some(acceptance), Some(appointment)) = + (self.credential.delegation(), grant.credential.delegation()) + else { + return Ok(false); + }; + + // The grant is the half carrying `scope` and no `accepts`; accepting an acceptance + // is not an edge. + if appointment.accepts.is_some() || appointment.scope.is_none() { + return Ok(false); + } + let Some(carried) = &acceptance.accepts else { + return Ok(false); + }; + + if self.issuer() != grant.subject() || self.subject() != grant.issuer() { + return Ok(false); + } + + digests_match(carried, &grant.digest_multibase()?) + } + /// Returns the proof value if signed else None pub fn proof_value(&self) -> Option<&str> { if let Some(proof) = &self.credential.proof { @@ -380,21 +482,48 @@ impl DTGCredential { } } -/// The digest a DTG credential carries of another credential, computed over a +/// The `sha2-256` multihash code, per the [multicodec] table. +/// +/// [multicodec]: https://www.w3.org/TR/cid-1.0/#multihash +const MULTIHASH_SHA2_256: u64 = 0x12; + +/// Strips a credential's top-level `proof` member, if it has one. +fn proofless(doc: &Value) -> Value { + match doc { + Value::Object(members) => { + let mut members = members.clone(); + members.remove("proof"); + Value::Object(members) + } + // Not an object: canonicalize as-is. A shape check belongs to the caller, which + // has a better error to give than this would. + other => other.clone(), + } +} + +/// The digest a DTG credential carries of another credential, computed over that /// credential in its **wire form**. /// -/// SHA-256 over the RFC 8785 (JCS) canonicalization of `doc` with its top-level `proof` -/// member removed, encoded as `sha256:` followed by the lowercase hexadecimal digest. -/// This is what a member-issued VMC carries of the grant it acknowledges, and what a VWC -/// carries of the edge credential it attests. +/// This is the encoding DTG Core Credentials calls `digestMultibase`, and every +/// cross-credential reference in the specification uses it: the member-issued VMC's +/// `digestMultibase` of the grant it acknowledges, the VWC's of the edge credential it +/// attests, an attenuated VAC's `authority.parent`, and a VDC's `delegation.parent` and +/// `delegation.accepts`. +/// +/// Four steps, per [CID v1.0](https://www.w3.org/TR/cid-1.0/): +/// +/// 1. canonicalize `doc` with its top-level `proof` member removed, using JCS (RFC 8785); +/// 2. SHA-256 the resulting UTF-8 bytes; +/// 3. prefix the `sha2-256` multihash header (`0x12`) and the length (`0x20`); +/// 4. encode base58btc with the multibase `z` prefix. /// /// # Digest what you received, not what you parsed /// -/// Take the document as it arrived. A credential may carry members this library does not -/// model — `credentialStatus` is the common one — and a parse-then-re-serialise round -/// trip drops them silently, so the digest would not match the one its issuer computed. -/// [`DTGCredential::digest`] is safe only for a credential built in-process; anything -/// received goes through this. +/// Take the document as it arrived. [`DTGCommon::extra`] preserves unmodelled *top-level* +/// members through a round trip, but the subject types do not model every member a +/// `credentialSubject` may carry, so a parse-then-re-serialise of an unusual credential +/// can still differ from the bytes its issuer hashed. Where you hold those bytes, hash +/// them. /// /// # Why `proof` is excluded /// @@ -402,19 +531,84 @@ impl DTGCredential { /// reference survives its referent being re-signed. A re-issued credential carries /// different claims and therefore a different digest, which is what makes renewal force /// re-acknowledgement. -pub fn digest_json(doc: &Value) -> Result { - let proofless = match doc { - Value::Object(members) => { - let mut members = members.clone(); - members.remove("proof"); - Value::Object(members) - } - // Not an object: canonicalize as-is. A shape check belongs to the caller, which - // has a better error to give than this would. - other => other.clone(), - }; +pub fn digest_multibase_json(doc: &Value) -> Result { + let canonical = serde_json_canonicalizer::to_vec(&proofless(doc)) + .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?; + + let digest = Sha256::digest(&canonical); + + // multihash prefix: 0x12 = sha2-256, 0x20 = 32 byte digest length. Both are varints, + // and both are single-byte at these values. + let mut multihash = Vec::with_capacity(2 + digest.len()); + multihash.push(MULTIHASH_SHA2_256 as u8); + multihash.push(digest.len() as u8); + multihash.extend_from_slice(&digest); - let canonical = serde_json_canonicalizer::to_vec(&proofless) + Ok(multibase::encode(Base::Base58Btc, &multihash)) +} + +/// Decodes a `digestMultibase` value into the algorithm it names and the raw digest bytes. +/// +/// The specification requires verifiers to compare digests this way rather than as +/// strings, so that two encodings of the same digest are recognised as equal and an +/// algorithm the verifier does not accept is *rejected* rather than reported as a +/// mismatch. +/// +/// # Errors +/// +/// [DTGCredentialError::InvalidDigest] if the multibase or multihash envelope is +/// malformed, or if the declared length does not match the bytes present. +/// [DTGCredentialError::UnsupportedDigestAlgorithm] if the multihash names anything other +/// than `sha2-256`. +pub fn decode_digest_multibase(digest: &str) -> Result<(u64, Vec), DTGCredentialError> { + let (_, bytes) = multibase::decode(digest) + .map_err(|e| DTGCredentialError::InvalidDigest(format!("multibase: {e}")))?; + + // Both the code and the length are varints. Every algorithm this library accepts has a + // single-byte code and a single-byte length, so a two-byte header is all that is read; + // a continuation bit in either is an algorithm we would reject anyway. + let (&code, rest) = bytes + .split_first() + .ok_or_else(|| DTGCredentialError::InvalidDigest("empty multihash".into()))?; + if code & 0x80 != 0 { + return Err(DTGCredentialError::InvalidDigest( + "multi-byte multihash code, which names no algorithm this library accepts".into(), + )); + } + let (&length, raw) = rest + .split_first() + .ok_or_else(|| DTGCredentialError::InvalidDigest("multihash has no length".into()))?; + + if code as u64 != MULTIHASH_SHA2_256 { + return Err(DTGCredentialError::UnsupportedDigestAlgorithm(code as u64)); + } + if length as usize != raw.len() { + return Err(DTGCredentialError::InvalidDigest(format!( + "multihash declares {length} bytes but carries {}", + raw.len() + ))); + } + + Ok((code as u64, raw.to_vec())) +} + +/// Do two `digestMultibase` values refer to the same credential? +/// +/// Decodes both and compares the algorithm and the raw digest bytes, as +/// [`decode_digest_multibase`] describes. Never compares the encoded strings. +pub fn digests_match(left: &str, right: &str) -> Result { + Ok(decode_digest_multibase(left)? == decode_digest_multibase(right)?) +} + +/// A credential's digest in the superseded `sha256:` encoding. +#[deprecated( + since = "0.7.0", + note = "Working Draft 02 replaced the `sha256:` digest with a base58btc multibase \ + multihash under the property name `digestMultibase`. Use \ + digest_multibase_json. This function will be removed in a future release." +)] +pub fn digest_json(doc: &Value) -> Result { + let canonical = serde_json_canonicalizer::to_vec(&proofless(doc)) .map_err(|e| DTGCredentialError::Canonicalization(e.to_string()))?; const HEX: &[u8; 16] = b"0123456789abcdef"; @@ -441,15 +635,17 @@ pub enum DTGCredentialType { /// 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. + /// 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). 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. + /// Merged into DTG Core Credentials at Working Draft 02 + /// (`trustoverip/dtgwg-cred-spec` PR #19). Delegation, /// R-Card is no longer a DTG credential type. @@ -585,9 +781,46 @@ pub struct DTGCommon { /// The assertion between the entities involved pub credential_subject: CredentialSubject, + /// A W3C VC status mechanism through which a verifier determines whether this + /// credential has been revoked. + /// + /// Held as an opaque [`Value`]: the mechanism is chosen by the governing VTC or VTN, + /// and this library neither selects one nor resolves it. `BitstringStatusListEntry` is + /// the common choice. + /// + /// CONDITIONAL on a VDC — REQUIRED where the appointment outlives the freshness window + /// the governing party defines for delegations, and permitted to be absent otherwise, + /// with short validity and re-issuance preferred wherever the delegator is reachable. + /// A status check is a live lookup that reveals the verification event to whoever + /// hosts the status list. + /// + /// # Modelled so that digests survive a round trip + /// + /// Every VMC issued against a status list carries this, and before it was modelled a + /// parse-then-re-serialise dropped it silently — producing a digest its issuer would + /// not recognise. See [`DTGCommon::extra`], which closes the same gap for members this + /// library does not name at all. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub credential_status: Option, + /// Cryptographic proof of credential authenticity #[serde(skip_serializing_if = "Option::is_none", default)] pub proof: Option, + + /// Top-level members this library does not model, preserved verbatim. + /// + /// A DTG credential may legitimately carry properties beyond the ones named here — + /// `credentialSchema`, `termsOfUse`, `evidence`, an extension a governing party + /// defines. Without somewhere to keep them, a parse-then-re-serialise round trip drops + /// them, and the digest computed over the result matches nothing the issuer signed. + /// + /// Capturing them makes [DTGCredential::digest_multibase] agree with + /// [`digest_multibase_json`] over the wire form for any credential whose extra members + /// are top-level. It is not a complete answer — the `credentialSubject` types still + /// reject members they do not model — so where you hold the bytes a counterparty sent, + /// hashing those remains the safe habit. + #[serde(flatten)] + pub extra: serde_json::Map, } impl DTGCommon { @@ -617,6 +850,7 @@ impl DTGCommon { CredentialSubject::Witness(subject) => &subject.id, CredentialSubject::Membership(subject) => &subject.id, CredentialSubject::Authority(subject) => &subject.id, + CredentialSubject::Delegation(subject) => &subject.id, CredentialSubject::RCard(subject) => &subject.id, } } @@ -645,6 +879,30 @@ impl DTGCommon { } } + /// The `delegation` object, when this credential is a VDC. + /// + /// `None` for every other credential type, for the same reason [DTGCommon::authority] + /// is fallible: a caller handed a credential of unknown type can ask without first + /// matching on `type_`. + pub fn delegation(&self) -> Option<&DelegationGrant> { + match &self.credential_subject { + CredentialSubject::Delegation(subject) => Some(&subject.delegation), + _ => None, + } + } + + /// Mutable access to the `delegation` object, when this credential is a VDC. + /// + /// Present for the same reason as [DTGCommon::authority_mut]: a verifier must be + /// testable against chains this library's own constructors would refuse to build, + /// since nothing stops another implementation emitting such JSON. + pub fn delegation_mut(&mut self) -> Option<&mut DelegationGrant> { + match &mut self.credential_subject { + CredentialSubject::Delegation(subject) => Some(&mut subject.delegation), + _ => None, + } + } + /// The credential is valid from this timestamp pub fn valid_from(&self) -> DateTime { self.valid_from @@ -681,7 +939,9 @@ impl Default for DTGCommon { credential_subject: CredentialSubject::Basic(CredentialSubjectBasic { id: String::new(), }), + credential_status: None, proof: None, + extra: serde_json::Map::new(), } } } @@ -706,7 +966,7 @@ impl TryFrom for DTGCredential { // `{ id }` — the community-issued grant, which MUST omit `digest`. CredentialSubject::Basic(subject) => CredentialSubjectMembership { id: subject.id.clone(), - digest: None, + digest_multibase: None, }, // `{ id, digest }` — the member-issued acknowledgement. Shape-identical @@ -717,7 +977,7 @@ impl TryFrom for DTGCredential { CredentialSubject::Witness(subject) if subject.witness_context.is_none() => { CredentialSubjectMembership { id: subject.id.clone(), - digest: subject.digest.clone(), + digest_multibase: subject.digest_multibase.clone(), } } @@ -782,7 +1042,7 @@ impl TryFrom for DTGCredential { credential_subject: CredentialSubject::Witness( CredentialSubjectWitness { id: subject.id.clone(), - digest: None, + digest_multibase: None, witness_context: None, }, ), @@ -815,11 +1075,63 @@ impl TryFrom for DTGCredential { _ => Err(DTGCredentialError::UnknownCredential), } } - DTGCredentialType::Delegation => Ok(DTGCredential { - type_: DTGCredentialType::Delegation, - version: value.context.as_slice().try_into()?, - credential: value, - }), + DTGCredentialType::Delegation => { + // A VDC's subject must carry the appointment. `Basic` — a bare `{ id }` — + // is where a caller lands when `delegation` is missing entirely, and a + // credential that appoints nobody to nothing is malformed rather than + // merely empty. + match &value.credential_subject { + CredentialSubject::Delegation(subject) => { + let d = &subject.delegation; + + // The two halves are distinguished by `accepts`, and each half has + // exactly one shape. Refusing the mixtures here means a caller + // cannot construct one by deserialization either. + match (&d.accepts, &d.scope) { + (Some(_), Some(_)) => { + return Err(DTGCredentialError::MalformedDelegation( + "carries both `accepts` and `scope`: an acceptance \ + consents to the scope of the grant it names rather \ + than restating it" + .into(), + )); + } + (Some(_), None) => { + if d.parent.is_some() || d.max_depth.is_some() { + return Err(DTGCredentialError::MalformedDelegation( + "an acceptance carries `accepts` and nothing else".into(), + )); + } + } + (None, Some(scope)) => { + if scope.is_empty() { + return Err(DTGCredentialError::MalformedDelegation( + "a grant's `scope` MUST contain at least one \ + entry — emptying it is not how an unbounded \ + appointment is expressed, because there is no \ + way to express one" + .into(), + )); + } + } + (None, None) => { + return Err(DTGCredentialError::MalformedDelegation( + "carries neither `scope` nor `accepts`, so it is \ + neither a grant nor an acceptance" + .into(), + )); + } + } + + Ok(DTGCredential { + type_: DTGCredentialType::Delegation, + version: value.context.as_slice().try_into()?, + credential: value, + }) + } + _ => Err(DTGCredentialError::UnknownCredential), + } + } DTGCredentialType::RCard => match &value.credential_subject { CredentialSubject::RCard { .. } => Ok(DTGCredential { type_: DTGCredentialType::RCard, @@ -897,6 +1209,12 @@ pub enum CredentialSubject { /// landing here. Authority(CredentialSubjectAuthority), + /// Verifiable Delegation Credential subject. + /// + /// Unambiguous for the same reason as [CredentialSubject::Authority]: `delegation` is + /// carried by no other DTG subject. + Delegation(CredentialSubjectDelegation), + /// Membership Credential subject, carrying the OPTIONAL `digest` that a member-issued /// VMC MUST set. /// @@ -929,8 +1247,8 @@ pub struct CredentialSubjectBasic { /// # 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 +/// attenuated VAC sets [AuthorityGrant::parent] to the **digest** 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 @@ -952,10 +1270,22 @@ pub struct AuthorityGrant { /// `write` unless both are listed. pub actions: Vec, - /// The `id` of the VAC this one was attenuated from. + /// The **digest** of the VAC this one was attenuated from, as + /// [DTGCredential::digest_multibase] computes it. /// /// Absent means this VAC was issued directly by the party governing the scope, and is /// therefore a chain root. + /// + /// # A digest, not an identifier + /// + /// Working Draft 02 made this deliberate rather than incidental. A digest names + /// nothing that can be fetched, so verification cannot come to depend on network + /// availability, a verifier cannot be induced to make a request against an address of + /// the holder's choosing, and nobody hosting an identifier learns when a credential is + /// used. It also binds an attenuated VAC to the exact claims its issuer narrowed from: + /// re-issuing a parent with different claims does not re-parent the children of the + /// old one, while re-proofing it with identical claims leaves them undisturbed, + /// because the digest excludes `proof`. #[serde(skip_serializing_if = "Option::is_none")] pub parent: Option, @@ -963,10 +1293,84 @@ pub struct AuthorityGrant { /// /// 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. +/// +/// A VDC is one of a **pair**. The delegator issues a *grant* — carrying `scope`, and +/// optionally `parent` and `maxDepth` — and the delegate answers with an *acceptance* +/// carrying `accepts` and nothing else. The two together form a complete DTG edge, and a +/// verifier MUST have both: a grant alone establishes what the delegator appointed, not +/// what the delegate agreed to. +/// +/// # A VDC is not authority +/// +/// It never supplies permission the delegator did not itself hold. A verifier presented +/// with one substitutes the delegator for the delegate and then asks the permission +/// question it would have asked of the delegator directly — live, at the time of the act. +/// The reach of a delegated act is the *intersection* of what the delegator may do and +/// what the chain appoints the delegate for. See [AuthorityGrant] for the credential that +/// answers the permission question. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DelegationGrant { + /// The acts the delegate may perform in the delegator's name. + /// + /// REQUIRED on a grant and MUST contain at least one entry — a VDC MUST NOT express an + /// unbounded appointment by omitting or emptying it. MUST be omitted on an acceptance, + /// which consents to the scope of the grant it names rather than restating it. + /// + /// Entries are opaque strings compared for exact equality. The specification defines no + /// wildcard, prefix or hierarchical semantics, so the subset test on a chain is set + /// inclusion over exact matches; a governing vocabulary that wants structure must put + /// it in the terms themselves. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub scope: Option>, + + /// The digest of the VDC this delegation was derived from, when the delegator is + /// itself acting under a delegation. A VDC with no `parent` is a **root delegation**. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub parent: Option, + + /// The number of further re-delegations permitted below this one. + /// + /// `0` prohibits re-delegation, and so does **absence** — the default is a single hop. + /// Setting it above `0` is the delegator's explicit authorisation to re-delegate; + /// there is no other. Note that this is the opposite default from a VAC, where + /// attenuation is permitted unless forbidden: a delegate speaks in the principal's + /// name, so the principal keeps the register of who may do so. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub max_depth: Option, + + /// The digest of the grant being accepted. + /// + /// REQUIRED on an acceptance and MUST be omitted on a grant. Its presence is what + /// distinguishes the two halves of a delegation edge. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub accepts: Option, +} + +/// Delegation Credential subject +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CredentialSubjectDelegation { + /// DID of the delegate on a grant; DID of the delegator on an acceptance. + pub id: String, + + /// The appointment itself. + pub delegation: DelegationGrant, +} + /// Verifiable Authority Credential (VAC) subject. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -983,21 +1387,31 @@ pub struct CredentialSubjectAuthority { /// The two directions of a membership edge share this shape and are told apart by /// `digest`: a community-issued VMC (the membership grant) MUST omit it, and a /// member-issued VMC (the membership acknowledgement) MUST carry it. Where both endpoints -/// are C-DIDs, as in VTN membership, `digest` is the only discriminator — the issuer and -/// subject rules cannot separate the directions. +/// are community identifiers, as in VTN membership, `digestMultibase` is the only +/// discriminator — the issuer and subject rules cannot separate the directions. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CredentialSubjectMembership { pub id: String, /// Digest of the community-issued VMC this acknowledges, as - /// [DTGCredential::digest] computes it. + /// [DTGCredential::digest_multibase] computes it. /// /// REQUIRED on the member-issued VMC, and MUST be omitted on the community-issued VMC. /// `Option` rather than two structs because the same property distinguishes the two /// directions: a type that could not represent both could not deserialize the pair. - #[serde(skip_serializing_if = "Option::is_none", default)] - pub digest: Option, + /// + /// Serializes as `digestMultibase`. The Working Draft 01 name `digest` is accepted on + /// the wire so that credentials issued against that draft still parse; the *value* + /// encoding also changed, so such a credential parses and then fails to compare, with + /// [DTGCredentialError::InvalidDigest] rather than a silent mismatch. + #[serde( + rename = "digestMultibase", + alias = "digest", + skip_serializing_if = "Option::is_none", + default + )] + pub digest_multibase: Option, } /// Endorsement Credential subject @@ -1015,8 +1429,19 @@ pub struct CredentialSubjectEndorsement { pub struct CredentialSubjectWitness { pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub digest: Option, + /// Digest of the witnessed edge credential, as [DTGCredential::digest_multibase] + /// computes it. REQUIRED by the specification — a VWC without one names the observed + /// party and the exchange, but not which edge was witnessed. + /// + /// Serializes as `digestMultibase`; the Working Draft 01 name `digest` is accepted on + /// the wire. + #[serde( + rename = "digestMultibase", + alias = "digest", + skip_serializing_if = "Option::is_none", + default + )] + pub digest_multibase: Option, /// There is no spec for the witness context content, so we use a generic JSON value #[serde(skip_serializing_if = "Option::is_none")] @@ -1057,10 +1482,13 @@ pub struct CredentialSubjectRCard { mod tests { use crate::{ CredentialSubject, CredentialSubjectRCard, DTGCommon, DTGCredential, DTGCredentialError, - DTGCredentialType, W3CVCVersion, digest_json, + DTGCredentialType, W3CVCVersion, decode_digest_multibase, digest_multibase_json, + digests_match, }; use chrono::{DateTime, Utc}; + use multibase::Base; use serde_json::Value; + use sha2::{Digest, Sha256}; #[test] fn test_vmc_vc_1_deserialize() { @@ -1323,7 +1751,7 @@ mod tests { "issuer": "did:example:governmentAgencyDid", "validFrom": "2024-06-18T10:00:00Z", "taskContext": "thread-abc-123", - "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "witnessContext": {} } + "credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "witnessContext": {} } }"#, ) { Ok(vwc) => vwc, @@ -1346,7 +1774,7 @@ mod tests { "issuer": "did:example:governmentAgencyDid", "validFrom": "2024-06-18T10:00:00Z", "taskContext": "thread-abc-123", - "credentialSubject": { "id": "did:example:citizenRDid", "digest": "abcdf", "wrongContext": {} } + "credentialSubject": { "id": "did:example:citizenRDid", "digestMultibase": "abcdf", "wrongContext": {} } }"#, ).is_ok() { panic!("Should have failed due to wrong CredentialSubject!"); @@ -1671,7 +2099,7 @@ mod tests { valid_from, None, "thread-abc-123".to_string(), - Some(vrc.digest().unwrap()), + Some(vrc.digest_multibase().unwrap()), None, ); @@ -1715,11 +2143,11 @@ mod tests { } /// The digest encoding is the interoperability surface: a credential referencing another - /// is compared byte-for-byte against a string some other implementation produced. Pinned - /// against a literal rather than a recomputation, because a test that recomputes agrees - /// with whatever the code does and would follow the encoding silently if it drifted. + /// is compared against a value some other implementation produced. Pinned against a + /// literal rather than a recomputation, because a test that recomputes agrees with + /// whatever the code does and would follow the encoding silently if it drifted. #[test] - fn test_digest_is_sha256_hex_over_the_proofless_jcs_form() { + fn test_digest_is_a_base58btc_multihash_over_the_proofless_jcs_form() { let vmc = DTGCredential::new_vmc( "did:example:community".to_string(), "did:example:member".to_string(), @@ -1731,29 +2159,102 @@ mod tests { ) .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52"); - let digest = vmc.digest().unwrap(); + let digest = vmc.digest_multibase().unwrap(); - let (scheme, hex) = digest.split_once(':').expect("`sha256:` prefixed"); - assert_eq!(scheme, "sha256"); - assert_eq!(hex.len(), 64, "32 bytes, hex encoded"); - assert!( - hex.chars() - .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)), - "lowercase hex only, got {hex}" - ); + // Multibase base58btc. + assert!(digest.starts_with('z'), "multibase base58btc prefix"); - // Independently computed over the JCS canonical form of the credential above. - // Computed outside this crate over the JCS canonical form of the document above: + // Decodes to a sha2-256 multihash: 0x12 0x20 followed by 32 digest bytes. + let (base, bytes) = multibase::decode(&digest).unwrap(); + assert_eq!(base, Base::Base58Btc); + assert_eq!(bytes.len(), 34); + assert_eq!(&bytes[..2], &[0x12, 0x20]); + + // Computed outside this crate over the JCS canonical form of the document below, + // then wrapped per CID v1.0 §2.4-2.5: // {"@context":[...],"credentialSubject":{"id":"did:example:member"}, // "id":"urn:uuid:2a4e...","issuer":"did:example:community", // "type":[...],"validFrom":"2025-12-11T00:00:00Z"} + // whose SHA-256 is 49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2. + assert_eq!(digest, "zQmTJgyPT2ShMQ2AvCHGDoPGjEWyRC7ZNT3MBpe5PP6Vpvu"); + + // Stable across calls. + assert_eq!(digest, vmc.digest_multibase().unwrap()); + } + + /// The superseded encoding still produces what it always did, so a caller migrating can + /// recompute a Working Draft 01 digest to compare against one they stored. + #[test] + #[allow(deprecated)] + fn the_superseded_hex_digest_is_unchanged() { + let vmc = DTGCredential::new_vmc( + "did:example:community".to_string(), + "did:example:member".to_string(), + DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z") + .unwrap() + .with_timezone(&Utc), + None, + false, + ) + .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52"); + assert_eq!( - digest, + vmc.digest().unwrap(), "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2" ); + } - // Stable across calls. - assert_eq!(digest, vmc.digest().unwrap()); + /// A Working Draft 01 digest reaching a Working Draft 02 verifier is *reported*, not + /// silently treated as a mismatch. The two say different things: one is a credential + /// that disagrees, the other a credential that cannot be read at all. + #[test] + fn a_superseded_digest_value_is_rejected_as_malformed() { + let err = decode_digest_multibase( + "sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2", + ) + .unwrap_err(); + + assert!( + matches!(err, DTGCredentialError::InvalidDigest(_)), + "expected InvalidDigest, got {err:?}" + ); + } + + /// Digests are compared as decoded bytes, never as strings — the specification requires + /// it, because one digest has more than one spelling. + #[test] + fn digests_are_compared_by_bytes_not_by_string() { + // The same sha2-256 multihash, encoded base58btc and base16. Identical bytes, + // different strings. + let multihash = { + let mut v = vec![0x12u8, 0x20]; + v.extend_from_slice(&Sha256::digest(b"an edge credential")); + v + }; + let b58 = multibase::encode(Base::Base58Btc, &multihash); + let b16 = multibase::encode(Base::Base16Lower, &multihash); + + assert_ne!(b58, b16, "the two spellings differ as strings"); + assert!( + digests_match(&b58, &b16).unwrap(), + "but name the same digest" + ); + } + + /// An algorithm the library does not implement is *rejected*, not reported as a + /// mismatch. A verifier that conflated the two would silently downgrade a governing + /// party's choice of a stronger hash into a failed comparison. + #[test] + fn an_unaccepted_hash_algorithm_is_rejected_rather_than_mismatched() { + // 0x13 is sha2-512 in the multicodec table. + let mut multihash = vec![0x13u8, 0x40]; + multihash.extend_from_slice(&[0u8; 64]); + let encoded = multibase::encode(Base::Base58Btc, &multihash); + + assert!(matches!( + decode_digest_multibase(&encoded), + Err(DTGCredentialError::UnsupportedDigestAlgorithm(0x13)) + )); } /// The digest binds to what a credential says, not to a signature over it, so a @@ -1774,10 +2275,10 @@ mod tests { false, ); - let before = vmc.digest().unwrap(); + let before = vmc.digest_multibase().unwrap(); vmc.sign(&secret, None).await.expect("signs"); assert!(vmc.signed()); - assert_eq!(before, vmc.digest().unwrap()); + assert_eq!(before, vmc.digest_multibase().unwrap()); } /// A grant in the wire form a member actually receives. @@ -1809,7 +2310,10 @@ mod tests { // The grant MUST omit the digest; the acknowledgement MUST carry it. assert_eq!(grant.subject_digest(), None); - assert_eq!(ack.subject_digest(), Some(grant.digest().unwrap().as_str())); + assert_eq!( + ack.subject_digest(), + Some(grant.digest_multibase().unwrap().as_str()) + ); assert!(ack.acknowledges(&grant).unwrap()); } @@ -1872,19 +2376,11 @@ mod tests { assert!(!grant.acknowledges(&grant).unwrap()); } - /// The bug this API shape exists to prevent. - /// - /// A real grant carries `credentialStatus` — every VMC issued against a status list - /// does — and `DTGCommon` does not model it, so a parse-then-re-serialise round trip - /// drops it. An acknowledgement built by digesting the *parsed* grant would carry a - /// digest over a document the community never issued, and the community would - /// rightly refuse it. Silently: both credentials verify, and only the digest - /// comparison fails, with nothing to say why. - /// - /// So `new_member_vmc` takes the wire form, and this pins that it digests what it - /// was handed rather than what it could parse. + /// `credentialStatus` used to be dropped by a parse-then-re-serialise round trip, which + /// silently changed a credential's digest. [`DTGCommon::credential_status`] models it, + /// and this pins that it survives. #[test] - fn the_acknowledgement_digests_members_the_model_does_not_know() { + fn credential_status_survives_a_round_trip() { let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z") .unwrap() .with_timezone(&Utc); @@ -1896,40 +2392,116 @@ mod tests { None, false, )); - grant["credentialStatus"] = serde_json::json!({ + let status = serde_json::json!({ "id": "https://community.example/status#7", "type": "BitstringStatusListEntry", "statusPurpose": "revocation", "statusListIndex": "7" }); + grant["credentialStatus"] = status.clone(); - // The parse drops it — this is the hazard, asserted rather than assumed. let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses"); - assert!( - wire(&parsed).get("credentialStatus").is_none(), - "the model is expected NOT to carry credentialStatus; if it now does, this \ - test has stopped guarding anything and the API can be simplified" + assert_eq!( + parsed.credential().credential_status.as_ref(), + Some(&status) + ); + assert_eq!(wire(&parsed).get("credentialStatus"), Some(&status)); + assert_eq!( + parsed.digest_multibase().unwrap(), + digest_multibase_json(&grant).unwrap(), + "the digest must not change under a round trip that preserves every member" + ); + } + + /// Top-level members this library does not model at all are preserved too, by + /// [`DTGCommon::extra`]. `credentialSchema` stands in for the open set of them. + #[test] + fn unmodelled_top_level_members_survive_a_round_trip() { + let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + + let mut grant = wire(&DTGCredential::new_vmc( + "did:example:community".to_string(), + "did:example:member".to_string(), + valid_from, + None, + false, + )); + let schema = serde_json::json!({ + "id": "https://community.example/schemas/vmc", + "type": "JsonSchema" + }); + grant["credentialSchema"] = schema.clone(); + + let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses"); + assert_eq!( + parsed.credential().extra.get("credentialSchema"), + Some(&schema) + ); + assert_eq!( + parsed.digest_multibase().unwrap(), + digest_multibase_json(&grant).unwrap() + ); + } + + /// # Why the wire form is still what gets digested + /// + /// [`DTGCommon::extra`] closed the dropped-member hazard, but not the whole of it. A + /// timestamp is *normalized* on the way out — `2025-12-11T00:00:00.000+00:00` and + /// `2025-12-11T00:00:00Z` are the same instant and parse to the same + /// [`chrono::DateTime`], and this library re-serializes both as the latter. The + /// document that comes back out is therefore equivalent to the one that went in, and + /// hashes differently. + /// + /// An acknowledgement built by digesting the *parsed* grant would carry a digest over a + /// document the community never issued, and the community would rightly refuse it. + /// Silently: both credentials verify, and only the digest comparison fails, with + /// nothing to say why. + /// + /// So `new_member_vmc` takes the wire form, and this pins that it digests what it was + /// handed rather than what it could parse. + #[test] + fn the_acknowledgement_digests_the_grant_as_it_arrived() { + let valid_from = DateTime::parse_from_rfc3339("2025-12-11T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + + let mut grant = wire(&DTGCredential::new_vmc( + "did:example:community".to_string(), + "did:example:member".to_string(), + valid_from, + None, + false, + )); + // The same instant, spelled the way another implementation might. + grant["validFrom"] = Value::String("2025-12-11T00:00:00.000+00:00".to_string()); + + // The parse normalizes it — this is the hazard, asserted rather than assumed. + let parsed: DTGCredential = serde_json::from_value(grant.clone()).expect("parses"); + assert_ne!( + wire(&parsed).get("validFrom"), + grant.get("validFrom"), + "the model is expected to normalize the timestamp; if it now round-trips \ + verbatim, this test has stopped guarding anything" ); let ack = DTGCredential::new_member_vmc(&grant, valid_from, None).expect("builds"); assert_eq!( ack.subject_digest(), - Some(digest_json(&grant).unwrap().as_str()), + Some(digest_multibase_json(&grant).unwrap().as_str()), "the acknowledgement must digest the grant as received" ); assert_ne!( ack.subject_digest(), - Some(parsed.digest().unwrap().as_str()), + Some(parsed.digest_multibase().unwrap().as_str()), "digesting the parsed model would produce a digest the community cannot match" ); } - /// `digest_json` and `digest` must agree for a credential with nothing outside the - /// model — otherwise the two entry points would quietly disagree for the easy case - /// too, and no caller could tell which to trust. #[test] - fn digest_json_agrees_with_digest_where_the_model_is_complete() { + fn digest_multibase_json_agrees_with_digest_where_the_model_is_complete() { let vmc = DTGCredential::new_vmc( "did:example:community".to_string(), "did:example:member".to_string(), @@ -1941,7 +2513,10 @@ mod tests { ) .with_id("urn:uuid:2a4e1d90-6e0c-4d3f-9a4a-6d0a8f7c1b52"); - assert_eq!(vmc.digest().unwrap(), digest_json(&wire(&vmc)).unwrap()); + assert_eq!( + vmc.digest_multibase().unwrap(), + digest_multibase_json(&wire(&vmc)).unwrap() + ); } /// `acknowledges` answers only about VMC pairs. A VRC edge is completed by its own @@ -1976,7 +2551,7 @@ mod tests { valid_from, None, "thread-abc-123".to_string(), - Some(grant.digest().unwrap()), + Some(grant.digest_multibase().unwrap()), None, ); assert!(vwc.verify_digest(&grant).unwrap(), "the digest does match"); @@ -2034,7 +2609,7 @@ mod tests { "validFrom": "2024-06-18T10:00:00Z", "credentialSubject": { "id": "did:example:community", - "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + "digestMultibase": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } }"#, ) @@ -2064,7 +2639,7 @@ mod tests { "validFrom": "2024-06-18T10:00:00Z", "credentialSubject": { "id": "did:example:community", - "digest": "sha256:e3b0c4", + "digestMultibase": "sha256:e3b0c4", "witnessContext": { "event": "not a membership property" } } }"#, @@ -2072,7 +2647,7 @@ mod tests { assert!(result.is_err()); } - /// The two halves must be distinguishable on the wire by `digest` alone — that is the + /// The two halves must be distinguishable on the wire by `digestMultibase` alone — that is the /// only discriminator where both endpoints are C-DIDs, as in VTN membership. #[test] fn test_the_two_halves_round_trip_over_the_wire() { @@ -2091,14 +2666,16 @@ mod tests { let grant_json = serde_json::to_value(&grant).unwrap(); assert!( - grant_json["credentialSubject"].get("digest").is_none(), - "the grant MUST omit `digest`: {grant_json}" + grant_json["credentialSubject"] + .get("digestMultibase") + .is_none(), + "the grant MUST omit `digestMultibase`: {grant_json}" ); let ack_json = serde_json::to_value(&ack).unwrap(); assert_eq!( - ack_json["credentialSubject"]["digest"], - Value::String(grant.digest().unwrap()), + ack_json["credentialSubject"]["digestMultibase"], + Value::String(grant.digest_multibase().unwrap()), ); // And the pair still binds after a round trip through JSON, which is how each side diff --git a/tests/authority_chain.rs b/tests/authority_chain.rs index f06c339..51bf2f9 100644 --- a/tests/authority_chain.rs +++ b/tests/authority_chain.rs @@ -28,7 +28,7 @@ fn root_grant() -> DTGCredential { ROOM.into(), vec!["read".into(), "write".into(), "curate".into()], t(0), - Some(t(24 * 30)), + t(24 * 30), ) .expect("root grant") .with_id("urn:uuid:root-0001") @@ -41,7 +41,7 @@ fn agent_grant(parent: &DTGCredential) -> DTGCredential { AGENT.into(), vec!["read".into()], t(0), - Some(t(4)), + t(4), Some(AGENT.into()), ) .expect("attenuation") @@ -94,7 +94,7 @@ fn a_self_issued_grant_is_refused_however_well_formed() { ROOM.into(), vec!["read".into(), "write".into(), "curate".into()], t(0), - Some(t(24)), + t(24), ) .expect("mallory can build one") .with_id("urn:uuid:forged"); @@ -114,14 +114,14 @@ fn attenuation_cannot_add_an_action_the_parent_lacks() { ROOM.into(), vec!["read".into()], t(0), - Some(t(24)), + 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) + .attenuate(AGENT.into(), vec!["write".into()], t(0), t(4), None) .unwrap_err(); assert!( format!("{err}").contains("not conferred by the parent"), @@ -136,13 +136,16 @@ fn attenuation_cannot_add_an_action_the_parent_lacks() { ROOM.into(), vec!["write".into()], t(0), - Some(t(4)), + t(4), ) .unwrap() .with_id("urn:uuid:widened"); + // The link is intact — `parent` is the root's digest — so the chain resolves and the + // widening is what the verifier has to catch. Pointing it somewhere else would fail as + // a broken link and prove nothing about narrowing. let mut widened = widened; if let Some(g) = widened.credential_mut().authority_mut() { - g.parent = Some("urn:uuid:read-only-root".into()); + g.parent = Some(root.digest_multibase().unwrap()); } let err = verify_chain(&[widened, root], ROOM, ROOM, "write", AGENT, t(1)).unwrap_err(); @@ -156,13 +159,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), - Some(t(24 * 365)), - None, - ) + .attenuate(AGENT.into(), vec!["read".into()], t(0), t(24 * 365), None) .unwrap_err(); assert!(format!("{err}").contains("beyond the parent's"), "{err}"); } @@ -178,13 +175,15 @@ fn a_link_issued_by_someone_other_than_the_parents_subject_is_refused() { ROOM.into(), vec!["read".into()], t(0), - Some(t(4)), + t(4), ) .unwrap() .with_id("urn:uuid:grafted"); + // Mallory cites Bob's root correctly: the digest matches, so the chain resolves. What + // must stop her is that she is not the party the root was granted to. let mut grafted = grafted; if let Some(g) = grafted.credential_mut().authority_mut() { - g.parent = Some("urn:uuid:root-0001".into()); + g.parent = Some(root.digest_multibase().unwrap()); } let err = verify_chain(&[grafted, root], ROOM, ROOM, "read", MALLORY, t(1)).unwrap_err(); @@ -254,7 +253,7 @@ fn an_empty_chain_confers_nothing() { /// 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) + let err = DTGCredential::new_vac(ROOM.into(), BOB.into(), ROOM.into(), vec![], t(0), t(1)) .unwrap_err(); assert!(format!("{err}").contains("confers nothing"), "{err}"); } @@ -279,7 +278,11 @@ fn a_vac_round_trips_through_json_with_its_grant_intact() { 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.parent.as_deref(), + 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()]); } @@ -306,3 +309,197 @@ fn an_empty_actions_list_is_refused_on_deserialization() { "empty actions must be refused at the deserialization boundary too: {err}" ); } + +// ------------------------------------------------------------------------------------- +// `parent` is a digest (Working Draft 02) +// ------------------------------------------------------------------------------------- + +/// A chain link names its parent by digest, not by identifier. A digest names nothing that +/// can be fetched, so verification never depends on the network and a verifier cannot be +/// induced to make a request against an address the holder chooses. +#[test] +fn a_link_names_its_parent_by_digest() { + let root = root_grant(); + let agent = agent_grant(&root); + + let parent = agent + .credential() + .authority() + .unwrap() + .parent + .as_deref() + .expect("an attenuated VAC carries a parent"); + + assert!(parent.starts_with('z'), "multibase base58btc: {parent}"); + assert!(dtg_credentials::digests_match(parent, &root.digest_multibase().unwrap()).unwrap()); +} + +/// A parent no longer needs a top-level `id` to be attenuated — which is precisely why the +/// specification made `parent` a digest. +#[test] +fn a_parent_without_an_id_can_still_be_attenuated() { + let root = DTGCredential::new_vac( + ROOM.into(), + BOB.into(), + ROOM.into(), + vec!["read".into(), "write".into()], + t(0), + t(24 * 30), + ) + .unwrap(); // deliberately no `with_id` + + assert!(root.id().is_none()); + + let agent = root + .attenuate(AGENT.into(), vec!["read".into()], t(0), t(4), None) + .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"); + assert_eq!(v.subject, AGENT); +} + +/// The digest binds to the parent's *claims*, so re-issuing a parent with different claims +/// orphans the credentials attenuated from the old one — they must be re-derived. For a +/// chain of narrowing authority that is the intended behaviour. +#[test] +fn a_reissued_parent_does_not_carry_its_children() { + let root = root_grant(); + let agent = agent_grant(&root); + + // Same id, same parties, narrower actions — a different credential. + let reissued = DTGCredential::new_vac( + ROOM.into(), + BOB.into(), + ROOM.into(), + vec!["read".into()], + t(0), + t(24 * 30), + ) + .unwrap() + .with_id("urn:uuid:root-0001"); + + let err = verify_chain(&[agent, reissued], ROOM, ROOM, "read", AGENT, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::BrokenLink { index: 0, .. }), + "got {err:?}" + ); +} + +/// Re-*proofing* a parent leaves its children undisturbed, because the digest excludes +/// `proof`. This is the property that lets a chain survive a key rotation. +#[test] +fn a_reproofed_parent_keeps_its_children() { + let root = root_grant(); + let agent = agent_grant(&root); + + let mut reproofed = root.clone(); + reproofed.credential_mut().proof = None; + + assert!( + dtg_credentials::digests_match( + &root.digest_multibase().unwrap(), + &reproofed.digest_multibase().unwrap() + ) + .unwrap(), + "the digest covers the claims, not a signature over them" + ); + + verify_chain(&[agent, reproofed], ROOM, ROOM, "read", AGENT, t(1)).expect("still verifies"); +} + +/// A digest that cannot be *read* is not a digest that disagrees. A Working Draft 01 +/// `sha256:` parent reaching this verifier is reported as malformed, not as a +/// widening chain. +#[test] +fn a_superseded_parent_digest_is_reported_as_malformed() { + let root = root_grant(); + let mut agent = agent_grant(&root); + if let Some(g) = agent.credential_mut().authority_mut() { + g.parent = + Some("sha256:49c9d5135ab4b5659a343bc79d351e37d64f05add58408cae6eef022828495c2".into()); + } + + let err = verify_chain(&[agent, root], ROOM, ROOM, "read", AGENT, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::Digest { index: 0, .. }), + "got {err:?}" + ); +} + +/// Attenuating a VAC that arrived from a counterparty must digest the bytes received, not +/// a re-serialisation of the parse. A normalized timestamp is enough to make the two differ. +#[test] +fn attenuating_from_json_digests_the_wire_form() { + let root = root_grant(); + let mut received = serde_json::to_value(root.credential()).unwrap(); + received["validFrom"] = serde_json::Value::String("2026-01-06T10:00:00.000+00:00".to_string()); + + let agent = DTGCredential::attenuate_from_json( + &received, + AGENT.into(), + vec!["read".into()], + t(0), + t(4), + Some(AGENT.into()), + ) + .expect("attenuation"); + + assert_eq!( + agent.credential().authority().unwrap().parent.as_deref(), + Some( + dtg_credentials::digest_multibase_json(&received) + .unwrap() + .as_str() + ), + "the parent digest must cover the VAC as it arrived" + ); + assert_eq!(agent.issuer(), BOB, "the holder is read off the parent"); +} + +/// The wire-form path enforces the same narrowing rules as the in-process one. +#[test] +fn attenuating_from_json_still_refuses_to_widen() { + let received = serde_json::to_value(root_grant().credential()).unwrap(); + + let err = DTGCredential::attenuate_from_json( + &received, + AGENT.into(), + vec!["delete".into()], + t(0), + t(4), + None, + ) + .unwrap_err(); + + assert!( + format!("{err}").contains("not conferred by the parent"), + "{err}" + ); +} + +/// `validUntil` is REQUIRED on a VAC. Nothing about the subject's current standing is +/// consulted here, so authority that never expires is authority nobody can withdraw by +/// waiting — and a verifier that accepted one would be honouring exactly that. +#[test] +fn a_vac_without_an_expiry_is_refused() { + 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": ["read"] } + } + }); + + let vac: DTGCredential = serde_json::from_value(json).expect("parses"); + let err = verify_chain(&[vac], ROOM, ROOM, "read", BOB, t(1)).unwrap_err(); + assert!( + matches!(err, AuthorityError::NoExpiry { index: 0 }), + "got {err:?}" + ); +} diff --git a/tests/delegation_chain.rs b/tests/delegation_chain.rs new file mode 100644 index 0000000..6cf8ef4 --- /dev/null +++ b/tests/delegation_chain.rs @@ -0,0 +1,570 @@ +//! Delegation edges and chains for Verifiable Delegation Credentials. +//! +//! A VDC establishes *representation*, not permission, and the two failure modes that +//! matter are different from a VAC's. A chain that widens lets a delegate speak for the +//! principal about more than they were appointed for; an appointment accepted by nobody +//! lets a delegator manufacture a delegate who never agreed to answer for anything. So the +//! cases below are mostly attacks, and each must be refused with a specific error. + +use chrono::{DateTime, Duration, TimeZone, Utc}; +use dtg_credentials::delegation::{DelegationError, MAX_CHAIN_DEPTH, verify_chain}; +use dtg_credentials::{DTGCredential, DTGCredentialError}; +use serde_json::Value; + +const ALICE: &str = "did:key:zAlice"; +const AGENT: &str = "did:key:zAliceAgent"; +const SUBAGENT: &str = "did:key:zAliceSubAgent"; +const MALLORY: &str = "did:key:zMallory"; + +fn t(h: i64) -> DateTime { + Utc.with_ymd_and_hms(2026, 1, 6, 10, 0, 0).unwrap() + Duration::hours(h) +} + +fn wire(c: &DTGCredential) -> Value { + serde_json::to_value(c.credential()).unwrap() +} + +/// Alice appoints her agent to schedule in her name for 90 days, with one further hop +/// permitted. +fn root_delegation() -> DTGCredential { + DTGCredential::new_vdc( + ALICE.into(), + AGENT.into(), + t(0), + t(24 * 90), + vec!["schedule:read".into(), "schedule:propose".into()], + Some(1), + ) + .expect("root delegation") + .with_id("urn:uuid:vdc-root") +} + +#[test] +fn a_root_delegation_verifies_for_what_it_appoints() { + let root = root_delegation(); + let v = verify_chain(&[root], ALICE, "schedule:propose", t(1)).expect("should verify"); + + assert_eq!(v.delegate, AGENT); + assert_eq!(v.principal, ALICE); + assert!(v.scope.contains(&"schedule:read".to_string())); +} + +#[test] +fn an_act_outside_the_appointment_is_refused() { + let root = root_delegation(); + let err = verify_chain(&[root], ALICE, "schedule:cancel", t(1)).unwrap_err(); + + assert!( + matches!(err, DelegationError::ActNotAppointed { ref act } if act == "schedule:cancel"), + "got {err:?}" + ); +} + +/// The chain must resolve to the principal the verifier intends to deal with. A chain that +/// reaches somebody else establishes no representation of Alice, however well formed. +#[test] +fn a_chain_rooted_elsewhere_establishes_nothing() { + let root = root_delegation(); + let err = verify_chain(&[root], MALLORY, "schedule:read", t(1)).unwrap_err(); + + assert!( + matches!(err, DelegationError::RootNotPrincipal { .. }), + "got {err:?}" + ); +} + +// ------------------------------------------------------------------------------------- +// The edge: a grant alone appoints nobody +// ------------------------------------------------------------------------------------- + +/// The delegate's acceptance is what makes the appointment mutually acknowledged. A +/// delegator can always name someone as its delegate; what it cannot do is produce the +/// countersignature. +#[test] +fn an_acceptance_completes_the_edge() { + let grant = root_delegation(); + let acceptance = + DTGCredential::new_delegate_vdc(&wire(&grant), t(0), t(24 * 90)).expect("accepts"); + + // Mirrored parties: the delegate issues, the delegator is the subject. + assert_eq!(acceptance.issuer(), AGENT); + assert_eq!(acceptance.subject(), ALICE); + + assert!(acceptance.accepts(&grant).unwrap()); +} + +/// An acceptance carries no scope of its own — what the delegate consented to is the scope +/// of the grant it names. Restating it would need an equality check across two credentials +/// that selective disclosure of either would defeat. +#[test] +fn an_acceptance_restates_no_scope() { + let grant = root_delegation(); + let acceptance = + DTGCredential::new_delegate_vdc(&wire(&grant), t(0), t(24 * 90)).expect("accepts"); + + let d = acceptance.credential().delegation().unwrap(); + assert!(d.scope.is_none()); + assert!(d.max_depth.is_none()); + assert!(d.accepts.is_some()); +} + +/// The acceptance binds to the grant's claims. A re-issued grant carries a different +/// digest, so consent does not carry over to an appointment the delegate never saw. +#[test] +fn a_reissued_grant_is_no_longer_accepted_by_the_old_acceptance() { + let grant = root_delegation(); + let acceptance = + DTGCredential::new_delegate_vdc(&wire(&grant), t(0), t(24 * 90)).expect("accepts"); + + // Same parties, wider appointment. + let reissued = DTGCredential::new_vdc( + ALICE.into(), + AGENT.into(), + t(0), + t(24 * 90), + vec![ + "schedule:read".into(), + "schedule:propose".into(), + "schedule:cancel".into(), + ], + Some(1), + ) + .unwrap() + .with_id("urn:uuid:vdc-root"); + + assert!(acceptance.accepts(&grant).unwrap()); + assert!( + !acceptance.accepts(&reissued).unwrap(), + "consent to one appointment is not consent to a wider one issued under the same id" + ); +} + +/// Accepting an acceptance forms no edge. +#[test] +fn an_acceptance_cannot_itself_be_accepted() { + let grant = root_delegation(); + let acceptance = + DTGCredential::new_delegate_vdc(&wire(&grant), t(0), t(24 * 90)).expect("accepts"); + + let err = DTGCredential::new_delegate_vdc(&wire(&acceptance), t(0), t(24 * 90)).unwrap_err(); + assert!( + matches!(err, DTGCredentialError::NotADelegationGrant(_)), + "got {err:?}" + ); +} + +/// A pair naming different parties is not an edge, however well the digest matches. +#[test] +fn an_acceptance_from_the_wrong_party_binds_nothing() { + let grant = root_delegation(); + let mut acceptance = + DTGCredential::new_delegate_vdc(&wire(&grant), t(0), t(24 * 90)).expect("accepts"); + acceptance.credential_mut().issuer = MALLORY.into(); + + assert!(!acceptance.accepts(&grant).unwrap()); +} + +/// An acceptance is not a grant, and a chain built from them appoints nobody to anything. +#[test] +fn an_acceptance_in_a_chain_is_refused() { + let grant = root_delegation(); + let acceptance = + DTGCredential::new_delegate_vdc(&wire(&grant), t(0), t(24 * 90)).expect("accepts"); + + let err = verify_chain(&[acceptance], ALICE, "schedule:read", t(1)).unwrap_err(); + assert!( + matches!(err, DelegationError::AcceptanceInChain { index: 0 }), + "got {err:?}" + ); +} + +// ------------------------------------------------------------------------------------- +// Re-delegation is opt-in +// ------------------------------------------------------------------------------------- + +/// The whole point: a sub-agent acting on strictly less than the agent holds. +#[test] +fn a_permitted_redelegation_verifies() { + let root = root_delegation(); + let sub = root + .redelegate(SUBAGENT.into(), vec!["schedule:read".into()], t(0), t(24)) + .expect("redelegation"); + + let v = verify_chain(&[sub, root], ALICE, "schedule:read", t(1)).expect("should verify"); + assert_eq!(v.delegate, SUBAGENT); + assert_eq!( + v.principal, ALICE, + "acts are attributed to Alice, not the agent" + ); + assert_eq!(v.scope, vec!["schedule:read".to_string()]); +} + +/// `maxDepth` narrows on the way down, so the budget cannot be topped back up. +#[test] +fn a_redelegation_spends_the_depth_budget() { + let root = root_delegation(); // maxDepth 1 + let sub = root + .redelegate(SUBAGENT.into(), vec!["schedule:read".into()], t(0), t(24)) + .unwrap(); + + assert_eq!(sub.credential().delegation().unwrap().max_depth, Some(0)); + + let err = sub + .redelegate(MALLORY.into(), vec!["schedule:read".into()], t(0), t(24)) + .unwrap_err(); + assert!( + format!("{err}").contains("does not permit re-delegation"), + "{err}" + ); +} + +/// Absence of `maxDepth` prohibits re-delegation — the default is a single hop. This is the +/// opposite default from a VAC's attenuation, deliberately: a delegate speaks in the +/// principal's name, so the principal keeps the register of who may do so. +#[test] +fn absence_of_max_depth_prohibits_redelegation() { + let root = DTGCredential::new_vdc( + ALICE.into(), + AGENT.into(), + t(0), + t(24 * 90), + vec!["schedule:read".into()], + None, + ) + .unwrap(); + + let err = root + .redelegate(SUBAGENT.into(), vec!["schedule:read".into()], t(0), t(24)) + .unwrap_err(); + assert!( + format!("{err}").contains("does not permit re-delegation"), + "{err}" + ); +} + +/// Refused at issue time... +#[test] +fn a_redelegation_cannot_widen_the_scope() { + let root = root_delegation(); + let err = root + .redelegate(SUBAGENT.into(), vec!["schedule:cancel".into()], t(0), t(24)) + .unwrap_err(); + assert!(format!("{err}").contains("not in the scope"), "{err}"); +} + +/// ...and at verification time too, for an implementation that built the JSON by hand. The +/// verifier's check is the authoritative one. +#[test] +fn a_widened_link_is_refused_by_the_verifier() { + let root = root_delegation(); + let mut widened = DTGCredential::new_vdc( + AGENT.into(), + SUBAGENT.into(), + t(0), + t(24), + vec!["schedule:cancel".into()], + Some(0), + ) + .unwrap(); + + // The link itself is intact — the digest matches — so the widening is what must be + // caught. + if let Some(d) = widened.credential_mut().delegation_mut() { + d.parent = Some(root.digest_multibase().unwrap()); + } + + let err = verify_chain(&[widened, root], ALICE, "schedule:cancel", t(1)).unwrap_err(); + assert!( + matches!(err, DelegationError::WidensScope { ref act, .. } if act == "schedule:cancel"), + "got {err:?}" + ); +} + +/// Only the party a delegation appointed may re-delegate it — otherwise anyone could graft +/// someone else's appointment onto their own chain. +#[test] +fn a_link_issued_by_someone_other_than_the_parents_delegate_is_refused() { + let root = root_delegation(); // appoints AGENT + let mut grafted = DTGCredential::new_vdc( + MALLORY.into(), // not AGENT + MALLORY.into(), + t(0), + t(24), + vec!["schedule:read".into()], + Some(0), + ) + .unwrap(); + if let Some(d) = grafted.credential_mut().delegation_mut() { + d.parent = Some(root.digest_multibase().unwrap()); + } + + let err = verify_chain(&[grafted, root], ALICE, "schedule:read", t(1)).unwrap_err(); + assert!( + matches!(err, DelegationError::IssuerNotParentSubject { .. }), + "got {err:?}" + ); +} + +/// A re-delegation may not outlive the appointment it derives from, or an expiry could be +/// escaped simply by re-delegating past it. +#[test] +fn a_redelegation_cannot_outlive_its_parent() { + let root = root_delegation(); + let err = root + .redelegate( + SUBAGENT.into(), + vec!["schedule:read".into()], + t(0), + t(24 * 365), + ) + .unwrap_err(); + assert!(format!("{err}").contains("beyond the parent's"), "{err}"); +} + +/// A link must name the credential presented above it. Without this a holder could +/// interleave links from unrelated chains. +#[test] +fn a_link_naming_a_different_parent_is_refused() { + let root = root_delegation(); + let mut sub = root + .redelegate(SUBAGENT.into(), vec!["schedule:read".into()], t(0), t(24)) + .unwrap(); + if let Some(d) = sub.credential_mut().delegation_mut() { + // A well-formed digest of something else entirely. + d.parent = Some( + dtg_credentials::digest_multibase_json(&serde_json::json!({"not": "the parent"})) + .unwrap(), + ); + } + + let err = verify_chain(&[sub, root], ALICE, "schedule:read", t(1)).unwrap_err(); + assert!( + matches!(err, DelegationError::BrokenLink { index: 0, .. }), + "got {err:?}" + ); +} + +/// A truncated chain establishes no representation of the principal. Presenting only the +/// derived link, the agent is the issuer of what is offered as the root — so the chain +/// resolves to the agent, not to Alice, and that is what the verifier says. +#[test] +fn a_truncated_chain_does_not_resolve_to_the_principal() { + let root = root_delegation(); + let sub = root + .redelegate(SUBAGENT.into(), vec!["schedule:read".into()], t(0), t(24)) + .unwrap(); + + let err = verify_chain(&[sub], ALICE, "schedule:read", t(1)).unwrap_err(); + assert!( + matches!(err, DelegationError::RootNotPrincipal { .. }), + "got {err:?}" + ); +} + +/// The other half of the same rule: a credential issued by the principal but still naming a +/// `parent` is not a root delegation, and a chain ending there has been cut short. +#[test] +fn a_root_that_names_a_parent_is_refused() { + let mut root = root_delegation(); + if let Some(d) = root.credential_mut().delegation_mut() { + d.parent = Some( + dtg_credentials::digest_multibase_json(&serde_json::json!({"some": "ancestor"})) + .unwrap(), + ); + } + + let err = verify_chain(&[root], ALICE, "schedule:read", t(1)).unwrap_err(); + assert!( + matches!(err, DelegationError::BrokenLink { .. }), + "got {err:?}" + ); +} + +// ------------------------------------------------------------------------------------- +// Shape rules +// ------------------------------------------------------------------------------------- + +/// A VDC cannot express an unbounded appointment by emptying its scope, at construction... +#[test] +fn an_empty_scope_is_refused_at_construction() { + let err = + DTGCredential::new_vdc(ALICE.into(), AGENT.into(), t(0), t(24), vec![], None).unwrap_err(); + assert!( + matches!(err, DTGCredentialError::MalformedDelegation(_)), + "got {err:?}" + ); +} + +/// ...nor by deserialization, which would otherwise bypass the constructor's guard. +#[test] +fn an_empty_scope_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", "DelegationCredential"], + "issuer": ALICE, + "validFrom": "2026-01-06T10:00:00Z", + "validUntil": "2026-04-06T10:00:00Z", + "credentialSubject": { "id": AGENT, "delegation": { "scope": [] } } + }); + + let err = serde_json::from_value::(json).unwrap_err(); + assert!(err.to_string().contains("at least one"), "{err}"); +} + +/// Neither a grant nor an acceptance: a `delegation` with neither `scope` nor `accepts` +/// says nothing at all. +#[test] +fn a_delegation_that_is_neither_half_is_refused() { + let json = serde_json::json!({ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://firstperson.network/credentials/dtg/v1" + ], + "type": ["VerifiableCredential", "DTGCredential", "DelegationCredential"], + "issuer": ALICE, + "validFrom": "2026-01-06T10:00:00Z", + "validUntil": "2026-04-06T10:00:00Z", + "credentialSubject": { "id": AGENT, "delegation": { "maxDepth": 2 } } + }); + + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string() + .contains("neither a grant nor an acceptance"), + "{err}" + ); +} + +/// An acceptance restating a scope would need a cross-credential equality check that +/// selective disclosure defeats, so carrying both is malformed rather than redundant. +#[test] +fn a_credential_carrying_both_scope_and_accepts_is_refused() { + let json = serde_json::json!({ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://firstperson.network/credentials/dtg/v1" + ], + "type": ["VerifiableCredential", "DTGCredential", "DelegationCredential"], + "issuer": AGENT, + "validFrom": "2026-01-06T10:00:00Z", + "validUntil": "2026-04-06T10:00:00Z", + "credentialSubject": { + "id": ALICE, + "delegation": { + "scope": ["schedule:read"], + "accepts": "zQmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n" + } + } + }); + + let err = serde_json::from_value::(json).unwrap_err(); + assert!( + err.to_string().contains("both `accepts` and `scope`"), + "{err}" + ); +} + +/// `validUntil` is REQUIRED on a VDC: an appointment with no expiry cannot be reasoned +/// about by a verifier that cannot reach the delegator. +#[test] +fn a_delegation_without_an_expiry_is_refused() { + let json = serde_json::json!({ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://firstperson.network/credentials/dtg/v1" + ], + "type": ["VerifiableCredential", "DTGCredential", "DelegationCredential"], + "issuer": ALICE, + "validFrom": "2026-01-06T10:00:00Z", + "credentialSubject": { "id": AGENT, "delegation": { "scope": ["schedule:read"] } } + }); + + let vdc: DTGCredential = serde_json::from_value(json).expect("parses"); + let err = verify_chain(&[vdc], ALICE, "schedule:read", t(1)).unwrap_err(); + assert!( + matches!(err, DelegationError::NoExpiry { index: 0 }), + "got {err:?}" + ); +} + +/// Verification is linear in depth and runs on every presentation, so depth is bounded. +#[test] +fn an_over_deep_chain_is_refused() { + let root = root_delegation(); + let chain: Vec = std::iter::repeat_n(root, MAX_CHAIN_DEPTH + 1).collect(); + + let err = verify_chain(&chain, ALICE, "schedule:read", t(1)).unwrap_err(); + assert!( + matches!(err, DelegationError::TooDeep { found } if found == MAX_CHAIN_DEPTH + 1), + "got {err:?}" + ); +} + +/// An expired link is refused wherever it sits — a chain is only as live as its +/// shortest-lived member. +#[test] +fn an_expired_link_is_refused() { + let root = root_delegation(); + let err = verify_chain(&[root], ALICE, "schedule:read", t(24 * 365)).unwrap_err(); + assert!( + matches!(err, DelegationError::NotValidNow { index: 0, .. }), + "got {err:?}" + ); +} + +/// A VDC round trips through JSON with its appointment intact, in both halves. +#[test] +fn a_vdc_round_trips_through_json() { + let root = root_delegation(); + let back: DTGCredential = serde_json::from_str(&serde_json::to_string(&root).unwrap()).unwrap(); + let d = back.credential().delegation().expect("grant survives"); + + assert_eq!( + d.scope.as_deref().unwrap(), + ["schedule:read", "schedule:propose"] + ); + assert_eq!(d.max_depth, Some(1)); + assert!(d.parent.is_none(), "a root delegation carries no parent"); + assert!(d.accepts.is_none(), "a grant carries no accepts"); + + let acceptance = + DTGCredential::new_delegate_vdc(&wire(&root), t(0), t(24 * 90)).expect("accepts"); + let back: DTGCredential = + serde_json::from_str(&serde_json::to_string(&acceptance).unwrap()).unwrap(); + assert!( + back.accepts(&root).unwrap(), + "the edge survives the round trip" + ); +} + +/// The wire form is what a counterparty digests, so a derivation from a received grant must +/// hash the bytes rather than the parse. A normalized timestamp is enough to make the two +/// differ. +#[test] +fn redelegating_from_json_digests_the_wire_form() { + let mut grant = wire(&root_delegation()); + grant["validFrom"] = Value::String("2026-01-06T10:00:00.000+00:00".to_string()); + + let sub = DTGCredential::redelegate_from_json( + &grant, + SUBAGENT.into(), + vec!["schedule:read".into()], + t(0), + t(24), + ) + .expect("redelegation"); + + assert_eq!( + sub.credential().delegation().unwrap().parent.as_deref(), + Some( + dtg_credentials::digest_multibase_json(&grant) + .unwrap() + .as_str() + ), + "the parent digest must cover the grant as it arrived" + ); + assert_eq!(sub.issuer(), AGENT, "the delegate is read off the grant"); +}