From ebdc8ff2de06a62c828c66ba3f98769d4897214e Mon Sep 17 00:00:00 2001 From: rongquan1 <85145303+rongquan1@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:30:55 +0800 Subject: [PATCH 1/5] feat(w3c): add Verifiable Presentation sign/verify + fragments Consume @trustvc/w3c-vc 2.3.0 and expose an opinionated VP surface: - signW3CPresentation / verifyW3CPresentation (src/w3c/presentation.ts) that ENFORCE trustvc policies: fullDisclosure (auto-derive underived credentials), holder binding (signer DID == holder == every credentialSubject.id), a mandatory lifetime, and a locked v2 envelope. TransferableRecords credentials are blocked. - Three VP verification fragments (src/verify/fragments/presentation) wired into verifyDocument(): W3CVpSignatureIntegrity (requires a holder proof + enforces holder binding), W3CVpCredentialStatus (VP expiry + embedded revocation), W3CVpIssuerIdentity (embedded issuers resolve). - Bump @trustvc/w3c* 2.2.0 -> 2.3.0. - Tests (28): create/sign/verify, invalid-key matrix, TransferableRecords block, underived auto-disclosure, missing subject id, different (did:web) issuer, v1.1-in-v2 envelope, revoked/not-revoked status, full verifyDocument() pipeline. - CLAUDE.md: repo guidance, VP policies, gotchas, and a self-maintenance rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 154 +++++++ package-lock.json | 339 +++++++------- package.json | 10 +- src/__tests__/core/verify.pol.test.ts | 9 +- src/__tests__/w3c/presentation.test.ts | 413 ++++++++++++++++++ src/__tests__/w3c/vpFragments.test.ts | 177 ++++++++ src/verify/fragments/index.ts | 8 + .../fragments/presentation/w3cVpVerifier.ts | 300 +++++++++++++ src/verify/verify.ts | 9 + src/w3c/index.ts | 1 + src/w3c/presentation.ts | 105 +++++ 11 files changed, 1341 insertions(+), 184 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/__tests__/w3c/presentation.test.ts create mode 100644 src/__tests__/w3c/vpFragments.test.ts create mode 100644 src/verify/fragments/presentation/w3cVpVerifier.ts create mode 100644 src/w3c/presentation.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e5cc40a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,154 @@ +# CLAUDE.md + +Guidance for working in this repo — for human developers and for Claude Code. + +> **Keep this file alive.** It's only useful if it stays true. Treat it as part of the +> code: when a change makes something here wrong or incomplete, update it *in the same +> commit/PR*. See [Maintaining this file](#maintaining-this-file). + +## What this repo is + +`@trustvc/trustvc` is the **umbrella SDK** that ties together document issuance, +signing and verification across **two worlds**: + +- **OpenAttestation / OpenCert** — hash-based documents, token registry / document + store, on-chain transferable records (ethers + hardhat). +- **W3C Verifiable Credentials / Presentations** — Data Integrity proofs, selective + disclosure, DID-based issuers/holders. + +It depends on the **published** `@trustvc/w3c*` packages (the crypto core lives in the +separate `w3c` monorepo — see [w3c section](#relationship-to-the-w3c-monorepo)): + +``` +@trustvc/w3c @trustvc/w3c-vc @trustvc/w3c-context +@trustvc/w3c-credential-status @trustvc/w3c-issuer → all pinned ^2.3.0 +``` + +Source map (`src/`): + +| Path | What | +| --- | --- | +| `src/core/verify.ts` | **`verifyDocument()`** — the unified verify entry point (OA + W3C). | +| `src/verify/verify.ts` | `verificationBuilder`, `openAttestationVerifiers`, `w3cVerifiers`. | +| `src/verify/fragments/` | Verifier fragments by dimension: `document-integrity`, `document-status`, `issuer-identity`, `presentation`. | +| `src/w3c/` | The W3C surface: `sign`, `derive`, `verify`, **`presentation`** (VP wrappers), `types`. | + +## Commands + +Node **≥ 20** — `engines` is enforced. Use `nvm use 20`; on Node 18 the install/tests fail. + +```bash +npm test # vitest --run --test-timeout=15000 +npm run type-check # tsc --noEmit +npm run lint # eslint, --max-warnings=0 (CI fails on ANY warning) +npm run build # clean + tsup +npm run test:e2e # hardhat node + on-chain tests (token registry / document store) + +# One file / one test: +npx vitest --run src/__tests__/w3c/presentation.test.ts +npx vitest --run -t "does not match the holder" +``` + +**Before "done": run `npm run type-check` AND `npm run lint`.** `lint` is +`--max-warnings=0`; a single warning is a red build. + +**Tests hit the network.** did:web resolution and StatusList fetches go to +`trustvc.github.io` (e.g. `.../did/1`, `.../credentials/statuslist/1`). These are real +integration checks — don't mock them away. On-chain tests need the hardhat node +(`test:e2e`). + +## Verifiable Presentations (`src/w3c/presentation.ts`) + +The trustvc layer is **opinionated**: it wraps the raw `@trustvc/w3c-vc` primitives and +**enforces policies so callers can't disable them.** + +**`signW3CPresentation(credentials, keyPair, options)`** — create **and** sign in one +call. Enforced: +- **`fullDisclosure`** — a base (non-derived) SD credential is **auto-full-disclosed**; + callers may pass underived credentials. +- **`checkHolderBinding`** — signing-key DID **==** holder **==** every + `credentialSubject.id`. The **issuer is deliberately NOT part of this** — a credential + issued by a different party (e.g. a did:web issuer) is fine. +- **Mandatory lifetime** — the caller MUST pass `expiresInSeconds` or `validUntil`. +- **`version: 'v2'`** — the presentation **envelope is always VC Data Model v2.0** + (`validFrom`/`validUntil`); `version` is dropped from the caller options. Embedded + credentials keep their own version (a v1.1 VC can sit inside a v2 envelope). +- Suite is `ecdsa-rdfc-2019` (a non-ECDSA key → error). `challenge` → `authentication` + proof; no challenge → `assertionMethod`. + +**`verifyW3CPresentation(presentation, options)`** — enforces **`checkHolderBinding`**; +verifies each embedded credential (signature **+ expiry + revocation**) and the holder +proof. An unsigned VP **fails** here. + +**When you add or change a policy, change it in the wrapper — not by trusting callers — +and keep create/verify symmetric** (if create rejects something, verify must too). + +## VP verification fragments (`src/verify/fragments/presentation/`) + +Three aggregate verifiers plug into `verifyDocument()`'s pipeline via `w3cVerifiers`: + +- `w3cVpSignatureIntegrity` (DOCUMENT_INTEGRITY) — **requires a holder proof** (unsigned + → INVALID), verifies the proof crypto, and enforces **holder binding** in-fragment. + Freshness (challenge/domain) is intentionally out of scope — a stateless pipeline + can't check it. +- `w3cVpCredentialStatus` (DOCUMENT_STATUS) — VP expiry + each embedded credential's + StatusList revocation. +- `w3cVpIssuerIdentity` (ISSUER_IDENTITY) — each embedded issuer resolves. + +`isVpDocument()` (the `test()` gate) routes on shape only (`type` includes +`VerifiablePresentation` + has `verifiableCredential`) — it does **not** look at `proof`, +so an unsigned VP is still routed in and then judged INVALID by the integrity fragment. + +**Consistency note:** the fragment pipeline and `verifyW3CPresentation` were deliberately +aligned to both enforce proof-presence + holder binding. If you touch one, keep the other +in step. + +## Gotchas (hard-won — add to this list) + +- **Selective disclosure keeps the subject `id`.** If a credential was issued *with* a + `credentialSubject.id`, deriving it (even revealing only other fields) **retains that + id**. To test/produce a credential with *no* subject id, it must be issued without one. +- **Holder binding is string-equality of DIDs and is method-agnostic** (did:key and + did:web both work). It's independent of the issuer. +- **StatusList test indices** on `.../statuslist/1`: index **5 → revoked**, index + **10 → not revoked**. Reuse these instead of inventing new ones. +- **Test fixtures share key material** across did:key and did:web (the same ECDSA key is + published under `did:key:zDnae…` and `did:web:trustvc.github.io:did:1#multikey-1`). Handy + for tests, but it means "different DID" ≠ "different key" in fixtures. +- **`VerificationFragment` is a union** — `reason`/`data` aren't on every member; narrow or + cast when asserting on them in tests. + +## Relationship to the w3c monorepo + +The VP/VC crypto logic lives in `@trustvc/w3c-vc` (separate repo, `../w3c-10`). To test an +**unpublished** w3c-vc change here, `npm pack` it there and install the tarball; once +published, repoint the dep to the version (`^2.3.0`). Real changes should be validated +here because this repo resolves the packages' full dep tree (and stricter jsonld) — a +green w3c-vc build alone doesn't prove integration. + +## Conventions + +- **No `!` non-null assertions** in tests — use the `assertDefined` helper. +- Prefer `as never` at test boundaries for intentionally loose fixture typing; avoid `any`. +- Conventional commits (commitlint + semantic-release drive versioning/CHANGELOG). +- Match the surrounding file's style; keep comments explaining *why* for the subtle rules + above. + +## Maintaining this file + +**Documentation-as-code. Keep it in sync in the same change that makes it stale — not +"later".** Update this file when your change touches: + +- **A public export or its behavior** (a new/renamed function, changed signature). +- **A VP policy or invariant** — the enforced flags, holder binding, v2 lock, create/verify + symmetry, the fragment pipeline's proof-presence check. +- **Commands, tooling, or Node/engine requirements** — keep the Commands section runnable. +- **A gotcha you just spent time on** — new gotchas are the highest-value additions. + +Guidelines: small-and-true beats big-and-stale (delete guidance that no longer holds); +keep it repo-specific and actionable; link to source-of-truth over duplicating detail; +preserve the *why* on load-bearing rules. + +**For Claude Code specifically:** at the end of a task that changed any of the above, +check whether this file is now inaccurate and propose the edit as part of the same +work — don't wait to be asked. diff --git a/package-lock.json b/package-lock.json index 9d0b104..a5ea2b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,11 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.2.0", - "@trustvc/w3c-context": "^2.2.0", - "@trustvc/w3c-credential-status": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", - "@trustvc/w3c-vc": "^2.2.0", + "@trustvc/w3c": "^2.3.0", + "@trustvc/w3c-context": "^2.3.0", + "@trustvc/w3c-credential-status": "^2.3.0", + "@trustvc/w3c-issuer": "^2.3.0", + "@trustvc/w3c-vc": "^2.3.0", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", @@ -1310,59 +1310,34 @@ "node": ">=18" } }, - "node_modules/@digitalbazaar/bbs-2023-cryptosuite/node_modules/@digitalbazaar/bls12-381-multikey": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@digitalbazaar/bls12-381-multikey/-/bls12-381-multikey-2.1.0.tgz", - "integrity": "sha512-JelU85fNhvHl2/mqRdmrtrE2ZQJ0//+UwI0l/YFmvsOr6YN2GuKPzdkfXjpm7f3UvnBqz5f8QKFTb9mVa7mVVg==", + "node_modules/@digitalbazaar/bbs-signatures": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/bbs-signatures/-/bbs-signatures-3.1.0.tgz", + "integrity": "sha512-wx86l/PFOaRcoLBPmzwpF9Oo4uYJrm4uq/B1rHX5OHD15NakmUINfRr8NAGDG356GeTBDGZkMsBFgNj1x0dc+g==", "license": "BSD-3-Clause", "dependencies": { - "@digitalbazaar/bbs-signatures": "^3.0.0", - "@noble/curves": "^1.3.0", - "base58-universal": "^2.0.0", - "base64url-universal": "^2.0.0", - "cborg": "^4.2.0" + "@noble/curves": "^2.2.0", + "@noble/hashes": "^2.2.0" }, "engines": { "node": ">=18" } }, - "node_modules/@digitalbazaar/bbs-2023-cryptosuite/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "node_modules/@digitalbazaar/bbs-signatures/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.8.0" + "@noble/hashes": "2.2.0" }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, - "node_modules/@digitalbazaar/bbs-2023-cryptosuite/node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "bin": { - "cborg": "lib/bin.js" - } - }, - "node_modules/@digitalbazaar/bbs-signatures": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@digitalbazaar/bbs-signatures/-/bbs-signatures-3.1.0.tgz", - "integrity": "sha512-wx86l/PFOaRcoLBPmzwpF9Oo4uYJrm4uq/B1rHX5OHD15NakmUINfRr8NAGDG356GeTBDGZkMsBFgNj1x0dc+g==", - "license": "BSD-3-Clause", - "dependencies": { - "@noble/curves": "^2.2.0", - "@noble/hashes": "^2.2.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@digitalbazaar/bbs-signatures/node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -1375,6 +1350,22 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@digitalbazaar/bls12-381-multikey": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/bls12-381-multikey/-/bls12-381-multikey-2.1.0.tgz", + "integrity": "sha512-JelU85fNhvHl2/mqRdmrtrE2ZQJ0//+UwI0l/YFmvsOr6YN2GuKPzdkfXjpm7f3UvnBqz5f8QKFTb9mVa7mVVg==", + "license": "BSD-3-Clause", + "dependencies": { + "@digitalbazaar/bbs-signatures": "^3.0.0", + "@noble/curves": "^1.3.0", + "base58-universal": "^2.0.0", + "base64url-universal": "^2.0.0", + "cborg": "^4.2.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@digitalbazaar/data-integrity": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@digitalbazaar/data-integrity/-/data-integrity-2.5.0.tgz", @@ -1466,9 +1457,9 @@ } }, "node_modules/@digitalbazaar/di-sd-primitives/node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -1487,6 +1478,90 @@ "node": ">=18" } }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/-/ecdsa-rdfc-2019-cryptosuite-1.3.0.tgz", + "integrity": "sha512-Rhg++GnGWHJ29QyWTFW0tRqd/uGLADIsLVEq10zEIAY7D9ScoSLtyYzwZ/zBueBTXHXrtDUEL5SfrSvcKTLFyg==", + "license": "BSD-3-Clause", + "dependencies": { + "@digitalbazaar/ecdsa-multikey": "^1.6.0", + "jsonld": "^9.0.0", + "rdf-canonize": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/@digitalbazaar/http-client": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-4.3.0.tgz", + "integrity": "sha512-6lMpxpt9BOmqHKGs9Xm6DP4LlZTBFer/ZjHvP3FcW3IaUWYIWC7dw5RFZnvw4fP57kAVcm1dp3IF+Y50qhBvAw==", + "license": "BSD-3-Clause", + "dependencies": { + "ky": "^1.14.2", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/canonicalize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz", + "integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/jsonld": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonld/-/jsonld-9.0.0.tgz", + "integrity": "sha512-pjMIdkXfC1T2wrX9B9i2uXhGdyCmgec3qgMht+TDj+S0qX3bjWMQUfL7NeqEhuRTi8G5ESzmL9uGlST7nzSEWg==", + "license": "BSD-3-Clause", + "dependencies": { + "@digitalbazaar/http-client": "^4.2.0", + "canonicalize": "^2.1.0", + "lru-cache": "^6.0.0", + "rdf-canonize": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/ky": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", + "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/rdf-canonize": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/rdf-canonize/-/rdf-canonize-5.0.0.tgz", + "integrity": "sha512-g8OUrgMXAR9ys/ZuJVfBr05sPPoMA7nHIVs8VEvg9QwM5W4GR2qSFEEHjsyHF1eWlBaf8Ev40WNjQFQ+nJTO3w==", + "license": "BSD-3-Clause", + "dependencies": { + "setimmediate": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@digitalbazaar/ecdsa-rdfc-2019-cryptosuite/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@digitalbazaar/ecdsa-sd-2023-cryptosuite": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@digitalbazaar/ecdsa-sd-2023-cryptosuite/-/ecdsa-sd-2023-cryptosuite-3.4.1.tgz", @@ -1504,15 +1579,6 @@ "node": ">=18" } }, - "node_modules/@digitalbazaar/ecdsa-sd-2023-cryptosuite/node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "bin": { - "cborg": "lib/bin.js" - } - }, "node_modules/@digitalbazaar/http-client": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-3.4.1.tgz", @@ -3692,27 +3758,15 @@ } }, "node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.2.0" - }, - "engines": { - "node": ">= 20.19.0" + "@noble/hashes": "1.8.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/curves/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -6831,24 +6885,24 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.2.0.tgz", - "integrity": "sha512-2WhAoYZW7JLt9H1OrT1qcwTL0g6LymMVg9uiZ3xxlaRgYzOmEqClN3Pq6apo1kRZTzJMHcKWECKADkHGa0Jgqw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.3.0.tgz", + "integrity": "sha512-F/9YT9Dvb6cD5uWE3+eV/DCvLyjpPbqxKExPGo4O2kpYKXwovEVVqf4MDTJskh3BIYzJQnQ5d6Gf8z1951uERQ==", "license": "Apache-2.0", "dependencies": { - "@trustvc/w3c-context": "^2.2.0", - "@trustvc/w3c-credential-status": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", - "@trustvc/w3c-vc": "^2.2.0" + "@trustvc/w3c-context": "^2.3.0", + "@trustvc/w3c-credential-status": "^2.3.0", + "@trustvc/w3c-issuer": "^2.3.0", + "@trustvc/w3c-vc": "^2.3.0" }, "engines": { "node": ">=18.x" } }, "node_modules/@trustvc/w3c-context": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-context/-/w3c-context-2.2.0.tgz", - "integrity": "sha512-p9mtIWZ1v1hhqiGLJ5Fu+2PK9ClIRsdo04vgCVC8BxhIjwUU7ZHb95sYF1E8Ay9pP2BRyFujBdoaYXHH8n5v4A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-context/-/w3c-context-2.3.0.tgz", + "integrity": "sha512-Zm9yVJaU6PYBqJsGdl6BT1ydow7xNmchFlct0oiI2BnMaY5+5+E+98OV/YQxipy9ir0EE/UVxnKadu2mIIJXcw==", "license": "Apache-2.0", "dependencies": { "did-resolver": "^4.1.0", @@ -6865,13 +6919,13 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c-credential-status": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-credential-status/-/w3c-credential-status-2.2.0.tgz", - "integrity": "sha512-lfgnvAUSwdi5hWnuf+wqTkpPTYxmZyZ8kdzVPRQeKWqb0ysdWN+n32ROoNpQAFSPqYlSL0pLWuI/vg35WuhnEA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-credential-status/-/w3c-credential-status-2.3.0.tgz", + "integrity": "sha512-mPJvrYeBP9ZVJvey5GnJ1DEBLOTOxjgh3hSvZyB3dfG7nJsg5chhrnIkuBuBV2b/Qg6k5GyOIliIDB0F+/EDQw==", "license": "Apache-2.0", "dependencies": { - "@trustvc/w3c-context": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", + "@trustvc/w3c-context": "^2.3.0", + "@trustvc/w3c-issuer": "^2.3.0", "base64url-universal": "^2.0.0", "pako": "^2.1.0" }, @@ -6880,9 +6934,9 @@ } }, "node_modules/@trustvc/w3c-issuer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-issuer/-/w3c-issuer-2.2.0.tgz", - "integrity": "sha512-o5XWh52c3KeNqrrIpSvjPt+3zwZ/wwh2hlGOst6PZXVzS9nMab+jUwhs52d+HBhe2r8BL4Z81sdMGA8YAEnk6Q==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-issuer/-/w3c-issuer-2.3.0.tgz", + "integrity": "sha512-J/Rlae2s/ihkF0q6OmBofmzdRjDI98ED9lRFIB6Uh2WaEl8eZP7+FoStip+t1Piy6ZbO/6MwaS0QCw/6N6viMA==", "license": "Apache-2.0", "dependencies": { "@digitalbazaar/bls12-381-multikey": "^2.1.0", @@ -6897,46 +6951,6 @@ "node": ">=18.x" } }, - "node_modules/@trustvc/w3c-issuer/node_modules/@digitalbazaar/bls12-381-multikey": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@digitalbazaar/bls12-381-multikey/-/bls12-381-multikey-2.1.0.tgz", - "integrity": "sha512-JelU85fNhvHl2/mqRdmrtrE2ZQJ0//+UwI0l/YFmvsOr6YN2GuKPzdkfXjpm7f3UvnBqz5f8QKFTb9mVa7mVVg==", - "license": "BSD-3-Clause", - "dependencies": { - "@digitalbazaar/bbs-signatures": "^3.0.0", - "@noble/curves": "^1.3.0", - "base58-universal": "^2.0.0", - "base64url-universal": "^2.0.0", - "cborg": "^4.2.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@trustvc/w3c-issuer/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@trustvc/w3c-issuer/node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "bin": { - "cborg": "lib/bin.js" - } - }, "node_modules/@trustvc/w3c-issuer/node_modules/did-resolver": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz", @@ -6944,19 +6958,20 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c-vc": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.2.0.tgz", - "integrity": "sha512-QAfoEgNndi2X+V0Nz9nBGiUk4Ko0XUFVLn0BY6qJK8GHJndMMCEcZ14PlaHyKG8nIossQeKZbsBwnavm2jRFdg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.3.0.tgz", + "integrity": "sha512-Heb3YYpiXJBEyhB5mEagUULrOL5ZnKSXw35EfufySY8V+rTBn5kFtWagp06x8sFiyqPPxETZBgaR3yGbusZUMQ==", "license": "Apache-2.0", "dependencies": { "@digitalbazaar/bbs-2023-cryptosuite": "^2.0.1", "@digitalbazaar/bls12-381-multikey": "^2.1.0", "@digitalbazaar/data-integrity": "^2.5.0", "@digitalbazaar/ecdsa-multikey": "^1.8.0", + "@digitalbazaar/ecdsa-rdfc-2019-cryptosuite": "^1.3.0", "@digitalbazaar/ecdsa-sd-2023-cryptosuite": "^3.4.1", "@mattrglobal/jsonld-signatures-bbs": "^1.2.0", - "@trustvc/w3c-credential-status": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", + "@trustvc/w3c-credential-status": "^2.3.0", + "@trustvc/w3c-issuer": "^2.3.0", "base64url-universal": "^2.0.0", "cbor": "^9.0.2", "did-resolver": "^4.1.0", @@ -6972,46 +6987,6 @@ "jsonld": "^6.0.0" } }, - "node_modules/@trustvc/w3c-vc/node_modules/@digitalbazaar/bls12-381-multikey": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@digitalbazaar/bls12-381-multikey/-/bls12-381-multikey-2.1.0.tgz", - "integrity": "sha512-JelU85fNhvHl2/mqRdmrtrE2ZQJ0//+UwI0l/YFmvsOr6YN2GuKPzdkfXjpm7f3UvnBqz5f8QKFTb9mVa7mVVg==", - "license": "BSD-3-Clause", - "dependencies": { - "@digitalbazaar/bbs-signatures": "^3.0.0", - "@noble/curves": "^1.3.0", - "base58-universal": "^2.0.0", - "base64url-universal": "^2.0.0", - "cborg": "^4.2.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@trustvc/w3c-vc/node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@trustvc/w3c-vc/node_modules/cborg": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", - "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", - "license": "Apache-2.0", - "bin": { - "cborg": "lib/bin.js" - } - }, "node_modules/@trustvc/w3c-vc/node_modules/did-resolver": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz", @@ -8676,6 +8651,15 @@ "node": ">=16" } }, + "node_modules/cborg": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.5.8.tgz", + "integrity": "sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==", + "license": "Apache-2.0", + "bin": { + "cborg": "lib/bin.js" + } + }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -14696,9 +14680,9 @@ } }, "node_modules/jsonld-signatures/node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -23657,6 +23641,7 @@ "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, "license": "BSD-2-Clause", "optional": true, "bin": { diff --git a/package.json b/package.json index 1886eb0..59cbd09 100644 --- a/package.json +++ b/package.json @@ -122,11 +122,11 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.2.0", - "@trustvc/w3c-context": "^2.2.0", - "@trustvc/w3c-credential-status": "^2.2.0", - "@trustvc/w3c-issuer": "^2.2.0", - "@trustvc/w3c-vc": "^2.2.0", + "@trustvc/w3c": "^2.3.0", + "@trustvc/w3c-context": "^2.3.0", + "@trustvc/w3c-credential-status": "^2.3.0", + "@trustvc/w3c-issuer": "^2.3.0", + "@trustvc/w3c-vc": "^2.3.0", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", diff --git a/src/__tests__/core/verify.pol.test.ts b/src/__tests__/core/verify.pol.test.ts index 8722190..79aa2b8 100644 --- a/src/__tests__/core/verify.pol.test.ts +++ b/src/__tests__/core/verify.pol.test.ts @@ -36,8 +36,13 @@ describe('Polygon (POL) network support', () => { describe('W3C_TRANSFERABLE_RECORD_POL fixture structure', () => { it('should have chain POL and chainId 137 in credentialStatus', () => { - expect(W3C_TRANSFERABLE_RECORD_POL.credentialStatus.tokenNetwork.chain).toBe('POL'); - expect(W3C_TRANSFERABLE_RECORD_POL.credentialStatus.tokenNetwork.chainId).toBe(137); + // credentialStatus is typed as the CredentialStatus | CredentialStatus[] union; + // this fixture uses a single TransferableRecords status object. + const credentialStatus = W3C_TRANSFERABLE_RECORD_POL.credentialStatus as unknown as { + tokenNetwork: { chain: string; chainId: number }; + }; + expect(credentialStatus.tokenNetwork.chain).toBe('POL'); + expect(credentialStatus.tokenNetwork.chainId).toBe(137); }); it('should have a DataIntegrityProof with ecdsa-sd-2023 cryptosuite', () => { diff --git a/src/__tests__/w3c/presentation.test.ts b/src/__tests__/w3c/presentation.test.ts new file mode 100644 index 0000000..f638110 --- /dev/null +++ b/src/__tests__/w3c/presentation.test.ts @@ -0,0 +1,413 @@ +import { describe, expect, it } from 'vitest'; +import { VerificationType } from '@trustvc/w3c-issuer'; +import { createPresentation } from '@trustvc/w3c-vc'; +import { + W3C_RAW_CREDENTIAL_V1_1, + W3C_RAW_CREDENTIAL_V2_0, + W3C_TRANSFERABLE_RECORD, +} from '../fixtures/fixtures'; +import { deriveW3C, signW3C, signW3CPresentation, verifyW3CPresentation } from '../..'; + +// Asserts a value is defined and returns it narrowed (avoids `!` assertions). +const assertDefined = (value: T | undefined, message: string): T => { + if (value === undefined) throw new Error(message); + return value; +}; + +// ECDSA-SD-2023 P-256 Multikey (same material as the sign tests), expressed as a did:key. +const ECDSA_PUB_MB = 'zDnaemDNwi4G5eTzGfRooFFu5Kns3be6yfyVNtiaMhWkZbwtc'; +const ECDSA_SEC_MB = 'z42tmUXTVn3n9BihE6NhdMpvVBTnFTgmb6fw18o5Ud6puhRW'; +const HOLDER_DID = `did:key:${ECDSA_PUB_MB}`; +const holderKey = { + id: `${HOLDER_DID}#${ECDSA_PUB_MB}`, + controller: HOLDER_DID, + type: VerificationType.Multikey, + publicKeyMultibase: ECDSA_PUB_MB, + secretKeyMultibase: ECDSA_SEC_MB, +}; +const CHALLENGE = 'trustvc-vp-test-challenge'; +const DOMAIN = 'verifier.example.com'; + +// A DIFFERENT issuing party: the hosted did:web issuer (resolves live at trustvc.github.io). +// Its verification method is `#multikey-1` (an ECDSA-SD Multikey). This is a distinct DID +// from the did:key holder — proving the issuer is independent of the holder/subject. +const ISSUER_DID = 'did:web:trustvc.github.io:did:1'; +const issuerKey = { + id: `${ISSUER_DID}#multikey-1`, + controller: ISSUER_DID, + type: VerificationType.Multikey, + publicKeyMultibase: ECDSA_PUB_MB, + secretKeyMultibase: ECDSA_SEC_MB, +}; + +describe('W3C Verifiable Presentation (via @trustvc/trustvc)', () => { + // Build a derived, presentable credential whose issuer + subject are the holder did:key. + const makeDerivedVc = async () => { + const raw = { + ...W3C_RAW_CREDENTIAL_V2_0, + issuer: HOLDER_DID, + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: HOLDER_DID }, + }; + const signed = await signW3C(raw as never, holderKey as never, 'ecdsa-sd-2023'); + if (signed.error) throw new Error(`sign failed: ${signed.error}`); + const derived = await deriveW3C(assertDefined(signed.signed, 'signed'), [ + '/credentialSubject/id', + '/credentialSubject/blNumber', + ]); + if (derived.error) throw new Error(`derive failed: ${derived.error}`); + return assertDefined(derived.derived, 'derived'); + }; + + // A base (non-derived) selective-disclosure credential — used to prove the trustvc + // layer auto-full-discloses underived credentials (fullDisclosure is enforced). + const makeBaseSdVc = async () => { + const raw = { + ...W3C_RAW_CREDENTIAL_V2_0, + issuer: HOLDER_DID, + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: HOLDER_DID }, + }; + const signed = await signW3C(raw as never, holderKey as never, 'ecdsa-sd-2023'); + if (signed.error) throw new Error(`sign failed: ${signed.error}`); + return assertDefined(signed.signed, 'signed'); // NOT derived + }; + + // A derived credential issued WITHOUT any credentialSubject.id (the raw subject has no id). + // Selective disclosure retains a subject id when one exists, so it must be absent at issuance. + const makeDerivedVcNoSubjectId = async () => { + const raw = { + ...W3C_RAW_CREDENTIAL_V2_0, + issuer: HOLDER_DID, + validFrom: '2024-04-01T12:19:52Z', + // credentialSubject deliberately has NO `id`. + }; + const signed = await signW3C(raw as never, holderKey as never, 'ecdsa-sd-2023'); + if (signed.error) throw new Error(`sign failed: ${signed.error}`); + const derived = await deriveW3C(assertDefined(signed.signed, 'signed'), [ + '/credentialSubject/blNumber', + ]); + if (derived.error) throw new Error(`derive failed: ${derived.error}`); + return assertDefined(derived.derived, 'derived'); + }; + + // A derived, holder-bound v1.1 credential (issuanceDate/expirationDate data model). + const makeDerivedV1Vc = async () => { + const raw = { + ...W3C_RAW_CREDENTIAL_V1_1, + issuer: HOLDER_DID, + credentialSubject: { ...W3C_RAW_CREDENTIAL_V1_1.credentialSubject, id: HOLDER_DID }, + }; + const signed = await signW3C(raw as never, holderKey as never, 'ecdsa-sd-2023'); + if (signed.error) throw new Error(`sign failed: ${signed.error}`); + const derived = await deriveW3C(assertDefined(signed.signed, 'signed'), [ + '/credentialSubject/id', + '/credentialSubject/blNumber', + ]); + if (derived.error) throw new Error(`derive failed: ${derived.error}`); + return assertDefined(derived.derived, 'derived'); + }; + + it('creates, signs and verifies a VP end-to-end', async () => { + const vc = await makeDerivedVc(); + + // create (validated + expiry-stamped) + // (unsigned) create still available for inspecting the envelope + const vp = await createPresentation(vc, { holder: HOLDER_DID }); + expect(vp.type).toContain('VerifiablePresentation'); + expect(vp.validFrom).toBeDefined(); + expect(vp.validUntil).toBeDefined(); + + // create + sign in ONE call: pass credentials directly. + // (fullDisclosure + holder binding + expiry are ENFORCED by the trustvc layer.) + const { signed, error } = await signW3CPresentation(vc, holderKey as never, { + holder: HOLDER_DID, + challenge: CHALLENGE, + domain: DOMAIN, + expiresInSeconds: 600, // lifetime is required at the trustvc layer + }); + expect(error).toBeUndefined(); + const signedVp = assertDefined(signed, 'expected signed VP'); + expect(signedVp.proof.cryptosuite).toBe('ecdsa-rdfc-2019'); + expect(signedVp.proof.challenge).toBe(CHALLENGE); + + // verify: proof + embedded credential + holder binding + expiry + // (holder binding is ENFORCED by the trustvc layer.) + const result = await verifyW3CPresentation(signedVp, { + challenge: CHALLENGE, + domain: DOMAIN, + requireProof: true, + }); + expect(result.verified).toBe(true); + expect(result.presentationResult?.verified).toBe(true); + expect(result.credentialResults?.every((r) => r.verified)).toBe(true); + }); + + it('fails verification with the wrong challenge (anti-replay)', async () => { + const vc = await makeDerivedVc(); + const { signed } = await signW3CPresentation(vc, holderKey as never, { + holder: HOLDER_DID, + challenge: CHALLENGE, + expiresInSeconds: 600, + }); + const result = await verifyW3CPresentation(assertDefined(signed, 'signed VP'), { + challenge: 'wrong-challenge', + }); + expect(result.verified).toBe(false); + }); + + it('requires a VP lifetime (expiresInSeconds or validUntil)', async () => { + const vc = await makeDerivedVc(); + // Bypass the type to exercise the runtime guard. + const result = await signW3CPresentation( + vc, + holderKey as never, + { + holder: HOLDER_DID, + challenge: CHALLENGE, + } as never, + ); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/lifetime is required/); + }); + + it('rejects an unsigned VP (holder binding is enforced at the trustvc layer)', async () => { + const vc = await makeDerivedVc(); + const vp = await createPresentation(vc, { holder: HOLDER_DID }); + const result = await verifyW3CPresentation(vp); + expect(result.verified).toBe(false); + expect(result.error).toMatch(/holder binding requires a signed presentation/); + }); + + describe('proof modes', () => { + it('signs and verifies an assertionMethod proof (no challenge)', async () => { + const vc = await makeDerivedVc(); + const { signed, error } = await signW3CPresentation(vc, holderKey as never, { + holder: HOLDER_DID, + expiresInSeconds: 600, // no challenge → assertionMethod + }); + expect(error).toBeUndefined(); + const signedVp = assertDefined(signed, 'signed VP'); + expect(signedVp.proof.proofPurpose).toBe('assertionMethod'); + expect(signedVp.proof.challenge).toBeUndefined(); + + const result = await verifyW3CPresentation(signedVp); // no challenge needed + expect(result.verified).toBe(true); + }); + + it('rejects a domain without a challenge', async () => { + const vc = await makeDerivedVc(); + const result = await signW3CPresentation(vc, holderKey as never, { + holder: HOLDER_DID, + domain: DOMAIN, + expiresInSeconds: 600, + }); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/"domain" requires a "challenge"/); + }); + }); + + describe('invalid signing key', () => { + it('rejects when no signing key is provided', async () => { + const vc = await makeDerivedVc(); + const result = await signW3CPresentation(vc, undefined as never, { expiresInSeconds: 600 }); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/a signing key \(keyPair\) is required/); + }); + + it('rejects a structurally-invalid key (missing secretKeyMultibase)', async () => { + const vc = await makeDerivedVc(); + const noSecret = { ...holderKey } as { secretKeyMultibase?: string }; + delete noSecret.secretKeyMultibase; + const result = await signW3CPresentation(vc, noSecret as never, { + holder: HOLDER_DID, + expiresInSeconds: 600, + }); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/"secretKeyMultibase" property in keyPair is required/); + }); + + it('rejects a key that cannot be loaded as an ECDSA (P-256) Multikey', async () => { + const vc = await makeDerivedVc(); + const garbageKey = { + ...holderKey, + publicKeyMultibase: 'zGARBAGEKEYNOTVALID', + secretKeyMultibase: 'zGARBAGEKEYNOTVALID', + }; + const result = await signW3CPresentation(vc, garbageKey as never, { + holder: HOLDER_DID, + expiresInSeconds: 600, + }); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/An ECDSA \(P-256\) Multikey is required/); + }); + }); + + describe('credential policy', () => { + it('blocks a credential with a TransferableRecords status', async () => { + const result = await signW3CPresentation( + W3C_TRANSFERABLE_RECORD as never, + holderKey as never, + { + holder: HOLDER_DID, + expiresInSeconds: 600, + }, + ); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/TransferableRecords/); + }); + + it('fails when a credential is about someone other than the holder', async () => { + const vc = await makeDerivedVc(); // subject === HOLDER_DID + const result = await signW3CPresentation(vc, holderKey as never, { + holder: 'did:example:someone-else', + challenge: CHALLENGE, + expiresInSeconds: 600, + }); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/does not match the holder/); + }); + + it('rejects a credential with no credentialSubject.id (cannot be holder-bound)', async () => { + const vc = await makeDerivedVcNoSubjectId(); + const result = await signW3CPresentation(vc, holderKey as never, { + holder: HOLDER_DID, + challenge: CHALLENGE, + expiresInSeconds: 600, + }); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/no "credentialSubject\.id"/); + }); + + it('auto full-discloses an underived (base SD) credential', async () => { + const baseVc = await makeBaseSdVc(); // NOT derived + const { signed, error } = await signW3CPresentation(baseVc, holderKey as never, { + holder: HOLDER_DID, + challenge: CHALLENGE, + expiresInSeconds: 600, + }); + expect(error).toBeUndefined(); + const result = await verifyW3CPresentation(assertDefined(signed, 'signed VP'), { + challenge: CHALLENGE, + }); + expect(result.verified).toBe(true); + }); + + it('verifies a VP whose credential was issued by a DIFFERENT party (did:web issuer, did:key holder)', async () => { + // Issuer = did:web (a different DID, resolved live); subject/holder = the did:key presenter. + // The credential proof is verified against the ISSUER's did:web; the VP proof against the + // HOLDER's did:key. Holder binding checks subject == holder == VP signer only — the issuer + // is deliberately NOT part of it. + const raw = { + ...W3C_RAW_CREDENTIAL_V2_0, + issuer: ISSUER_DID, // different party + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: HOLDER_DID }, + }; + const signedCred = await signW3C(raw as never, issuerKey as never, 'ecdsa-sd-2023'); + expect(signedCred.error).toBeUndefined(); + const derived = await deriveW3C(assertDefined(signedCred.signed, 'signed'), [ + '/credentialSubject/id', + '/credentialSubject/blNumber', + ]); + const vc = assertDefined(derived.derived, 'derived'); + + // The holder (did:key) — NOT the issuer — presents and signs the VP. + const { signed, error } = await signW3CPresentation(vc, holderKey as never, { + holder: HOLDER_DID, + challenge: CHALLENGE, + expiresInSeconds: 600, + }); + expect(error).toBeUndefined(); + const result = await verifyW3CPresentation(assertDefined(signed, 'signed VP'), { + challenge: CHALLENGE, + }); + expect(result.verified).toBe(true); + expect(result.credentialResults?.every((r) => r.verified)).toBe(true); + }); + + it('wraps and verifies multiple credentials', async () => { + const [vc1, vc2] = [await makeDerivedVc(), await makeDerivedVc()]; + const { signed, error } = await signW3CPresentation([vc1, vc2], holderKey as never, { + holder: HOLDER_DID, + challenge: CHALLENGE, + expiresInSeconds: 600, + }); + expect(error).toBeUndefined(); + const result = await verifyW3CPresentation(assertDefined(signed, 'signed VP'), { + challenge: CHALLENGE, + }); + expect(result.verified).toBe(true); + expect(result.credentialResults?.length).toBe(2); + }); + }); + + describe('expiry & versioning', () => { + it('accepts an explicit validUntil as the lifetime', async () => { + const vc = await makeDerivedVc(); + const { signed, error } = await signW3CPresentation(vc, holderKey as never, { + holder: HOLDER_DID, + challenge: CHALLENGE, + validUntil: '2999-01-01T00:00:00Z', + }); + expect(error).toBeUndefined(); + expect(assertDefined(signed, 'signed VP').validUntil).toBe('2999-01-01T00:00:00Z'); + }); + + it('rejects an expired VP at verify', async () => { + const vc = await makeDerivedVc(); + // A VP that was validly created but whose window is entirely in the past. + const { signed, error } = await signW3CPresentation( + vc, + holderKey as never, + { + holder: HOLDER_DID, + challenge: CHALLENGE, + validFrom: '2020-01-01T00:00:00Z', + validUntil: '2020-01-02T00:00:00Z', + } as never, + ); + expect(error).toBeUndefined(); + const result = await verifyW3CPresentation(assertDefined(signed, 'signed VP'), { + challenge: CHALLENGE, + }); + expect(result.verified).toBe(false); + expect(result.error).toMatch(/expired/); + }); + + it('always produces a v2 envelope (caller cannot downgrade to v1)', async () => { + const vc = await makeDerivedVc(); + const { signed, error } = await signW3CPresentation( + vc, + holderKey as never, + { + holder: HOLDER_DID, + challenge: CHALLENGE, + expiresInSeconds: 600, + version: 'v1', // ignored — v2 is enforced + } as never, + ); + expect(error).toBeUndefined(); + const signedVp = assertDefined(signed, 'signed VP') as { + validFrom?: string; + issuanceDate?: string; + }; + expect(signedVp.validFrom).toBeDefined(); // v2 field + expect(signedVp.issuanceDate).toBeUndefined(); // v1 field absent + }); + + it('wraps a v1.1 credential in a v2 envelope and verifies it', async () => { + const v1Vc = await makeDerivedV1Vc(); + const { signed, error } = await signW3CPresentation(v1Vc, holderKey as never, { + holder: HOLDER_DID, + challenge: CHALLENGE, + expiresInSeconds: 600, + }); + expect(error).toBeUndefined(); + const signedVp = assertDefined(signed, 'signed VP') as { validFrom?: string }; + expect(signedVp.validFrom).toBeDefined(); // envelope is v2 even though the credential is v1.1 + const result = await verifyW3CPresentation(signedVp as never, { challenge: CHALLENGE }); + expect(result.verified).toBe(true); + }); + }); +}); diff --git a/src/__tests__/w3c/vpFragments.test.ts b/src/__tests__/w3c/vpFragments.test.ts new file mode 100644 index 0000000..27a557f --- /dev/null +++ b/src/__tests__/w3c/vpFragments.test.ts @@ -0,0 +1,177 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { VerificationType } from '@trustvc/w3c-issuer'; +import { + createPresentation, + deriveCredential, + getDocumentLoader, + signCredential, + SignedVerifiableCredential, + signPresentation, +} from '@trustvc/w3c-vc'; +import { W3C_RAW_CREDENTIAL_V2_0, W3C_VERIFIABLE_DOCUMENT } from '../fixtures/fixtures'; +import { + w3cVpCredentialStatus, + w3cVpIssuerIdentity, + w3cVpSignatureIntegrity, +} from '../../verify/fragments/presentation/w3cVpVerifier'; +import { w3cIssuerIdentity } from '../../verify/fragments/issuer-identity/w3cIssuerIdentity'; +import { verifyDocument } from '../../core/verify'; + +const PUB = 'zDnaemDNwi4G5eTzGfRooFFu5Kns3be6yfyVNtiaMhWkZbwtc'; +const SEC = 'z42tmUXTVn3n9BihE6NhdMpvVBTnFTgmb6fw18o5Ud6puhRW'; +const DID = `did:key:${PUB}`; +const holderKey = { + id: `${DID}#${PUB}`, + controller: DID, + type: VerificationType.Multikey, + publicKeyMultibase: PUB, + secretKeyMultibase: SEC, +}; + +describe('W3C VP verification fragments', () => { + const opts = async () => ({ documentLoader: await getDocumentLoader() }); + + // A derived, holder-bound credential (credentialSubject.id === the holder DID) so a + // signed VP satisfies the pipeline's holder-binding check. + let embeddedVc: SignedVerifiableCredential; + beforeAll(async () => { + const raw = { + ...W3C_RAW_CREDENTIAL_V2_0, + issuer: DID, + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: DID }, + }; + const s = await signCredential(raw as never, holderKey as never, 'ecdsa-sd-2023'); + embeddedVc = ( + await deriveCredential(s.signed!, ['/credentialSubject/id', '/credentialSubject/blNumber']) + ).derived!; + }); + + it('test() detects a VP and the VC-only issuer verifier skips it', async () => { + const vp = await createPresentation(embeddedVc as never, { holder: DID }); + const o = {} as never; + expect(w3cVpSignatureIntegrity.test(vp as never, o)).toBe(true); + expect(w3cVpCredentialStatus.test(vp as never, o)).toBe(true); + expect(w3cVpIssuerIdentity.test(vp as never, o)).toBe(true); + // A VP has no top-level issuer → the single-VC issuer verifier does not handle it. + expect(w3cIssuerIdentity.test(vp as never, o)).toBe(false); + }); + + it('emits VALID fragments for a signed VP', async () => { + const vp = await createPresentation(embeddedVc as never, { holder: DID }); + const { signed, error } = await signPresentation(vp, holderKey as never, { + challenge: 'pipeline-vp-challenge', + }); + expect(error).toBeUndefined(); + const o = await opts(); + + const integrity = await w3cVpSignatureIntegrity.verify(signed as never, o as never); + const status = await w3cVpCredentialStatus.verify(signed as never, o as never); + const issuer = await w3cVpIssuerIdentity.verify(signed as never, o as never); + + expect(integrity.type).toBe('DOCUMENT_INTEGRITY'); + expect(integrity.status).toBe('VALID'); + expect(status.type).toBe('DOCUMENT_STATUS'); + expect(status.status).toBe('VALID'); + expect(issuer.type).toBe('ISSUER_IDENTITY'); + expect(issuer.status).toBe('VALID'); + }); + + it('emits INVALID integrity for an UNSIGNED VP (no holder proof → not bound)', async () => { + const vp = await createPresentation(embeddedVc as never, { holder: DID }); + const o = await opts(); + const integrity = await w3cVpSignatureIntegrity.verify(vp as never, o as never); + expect(integrity.status).toBe('INVALID'); + expect((integrity as { reason?: { message?: string } }).reason?.message).toMatch(/not signed/); + }); + + it('emits INVALID integrity when an embedded credential is tampered', async () => { + const vp = await createPresentation(embeddedVc as never, { holder: DID }); + const { signed } = await signPresentation(vp, holderKey as never, { challenge: 'c' }); + const tampered = JSON.parse(JSON.stringify(signed)); + const sub = Array.isArray(tampered.verifiableCredential) + ? tampered.verifiableCredential[0] + : tampered.verifiableCredential; + sub.credentialSubject.blNumber = 'TAMPERED'; + const o = await opts(); + const integrity = await w3cVpSignatureIntegrity.verify(tampered, o as never); + expect(integrity.status).toBe('INVALID'); + }); + + it('emits INVALID integrity when an embedded credential has EXPIRED', async () => { + // Embedded credential expired in 2021; VP created in 2020 (so creation passes) with a + // long VP lifetime, then verified "now" (2026) → the expired credential fails. + const raw = { + ...W3C_RAW_CREDENTIAL_V2_0, + issuer: DID, + validFrom: '2020-01-01T00:00:00Z', + validUntil: '2021-01-01T00:00:00Z', + credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: DID }, + }; + const s = await signCredential(raw as never, holderKey as never, 'ecdsa-sd-2023'); + const vc = (await deriveCredential(s.signed!, ['/credentialSubject/id', '/validUntil'])) + .derived!; + const vp = await createPresentation(vc as never, { + holder: DID, + now: new Date('2020-06-01T00:00:00Z'), + expiresInSeconds: 315360000, // 10y so the VP envelope itself isn't expired + }); + const { signed } = await signPresentation(vp, holderKey as never, { challenge: 'x' }); + const o = await opts(); + const integrity = await w3cVpSignatureIntegrity.verify(signed as never, o as never); + expect(integrity.status).toBe('INVALID'); + }); + + it('w3cVpCredentialStatus resolves an embedded StatusList2021Entry (not revoked → VALID)', async () => { + // W3C_VERIFIABLE_DOCUMENT carries a real StatusList2021Entry (index 10 → not revoked). + const vp = { + type: ['VerifiablePresentation'], + verifiableCredential: [W3C_VERIFIABLE_DOCUMENT], + }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.type).toBe('DOCUMENT_STATUS'); + expect(status.status).toBe('VALID'); + }); + + it('w3cVpCredentialStatus emits INVALID when an embedded credential is REVOKED', async () => { + // Same status list, index 5 → revoked (mirrors the single-VC W3CCredentialStatus test). + const revokedVc = { + ...W3C_VERIFIABLE_DOCUMENT, + credentialStatus: { ...W3C_VERIFIABLE_DOCUMENT.credentialStatus, statusListIndex: '5' }, + }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [revokedVc] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('INVALID'); + expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/revoked/i); + }); + + it('w3cVpIssuerIdentity resolves an embedded did:web issuer (→ VALID)', async () => { + const vp = { + type: ['VerifiablePresentation'], + verifiableCredential: [W3C_VERIFIABLE_DOCUMENT], + }; + const o = await opts(); + const issuer = await w3cVpIssuerIdentity.verify(vp as never, o as never); + expect(issuer.type).toBe('ISSUER_IDENTITY'); + expect(issuer.status).toBe('VALID'); + }); + + it('runs through the full verifyDocument() pipeline for a signed VP', async () => { + const vp = await createPresentation(embeddedVc as never, { holder: DID }); + const { signed } = await signPresentation(vp, holderKey as never, { + challenge: 'pipeline-challenge', + }); + const fragments = await verifyDocument(signed as never); + const byName = (name: string) => fragments.find((f) => f.name === name); + + // The three VP verifiers ran and passed. + expect(byName('W3CVpSignatureIntegrity')?.status).toBe('VALID'); + expect(byName('W3CVpCredentialStatus')?.status).toBe('VALID'); + expect(byName('W3CVpIssuerIdentity')?.status).toBe('VALID'); + // No VP fragment errored. + const vpFragments = fragments.filter((f) => f.name?.startsWith('W3CVp')); + expect(vpFragments.every((f) => f.status !== 'ERROR')).toBe(true); + }); +}); diff --git a/src/verify/fragments/index.ts b/src/verify/fragments/index.ts index f82678d..85041d0 100644 --- a/src/verify/fragments/index.ts +++ b/src/verify/fragments/index.ts @@ -15,8 +15,16 @@ import { import { w3cCredentialStatus } from './document-status/w3cCredentialStatus'; import { w3cIssuerIdentity } from './issuer-identity/w3cIssuerIdentity'; import { w3cEmptyCredentialStatus } from './document-status/w3cEmptyCredentialStatus'; +import { + w3cVpCredentialStatus, + w3cVpIssuerIdentity, + w3cVpSignatureIntegrity, +} from './presentation/w3cVpVerifier'; export { + w3cVpCredentialStatus, + w3cVpIssuerIdentity, + w3cVpSignatureIntegrity, TRANSFERABLE_RECORDS_TYPE, credentialStatusTransferableRecordVerifier, openAttestationDidSignedDocumentStatus, diff --git a/src/verify/fragments/presentation/w3cVpVerifier.ts b/src/verify/fragments/presentation/w3cVpVerifier.ts new file mode 100644 index 0000000..4f98790 --- /dev/null +++ b/src/verify/fragments/presentation/w3cVpVerifier.ts @@ -0,0 +1,300 @@ +import { VerificationFragment, Verifier, VerifierOptions } from '@tradetrust-tt/tt-verify'; +import { DocumentLoader } from '@trustvc/w3c-context'; +import { isDidKey, parseDidKey, queryDidDocument } from '@trustvc/w3c-issuer'; +import { + BitstringStatusListCredentialStatus, + CredentialStatusType, +} from '@trustvc/w3c-credential-status'; +import { + CredentialStatus, + SignedVerifiableCredential, + VerifiablePresentation, + verifyCredentialStatus, + verifyPresentation, +} from '@trustvc/w3c-vc'; + +// A document is a Verifiable Presentation when its `type` includes +// `VerifiablePresentation` and it carries a `verifiableCredential` field. +const isVpDocument = (document: unknown): boolean => { + const doc = document as VerifiablePresentation; + if (!doc || typeof doc !== 'object') return false; + const types = Array.isArray(doc.type) ? doc.type : [doc.type]; + return types.includes('VerifiablePresentation') && 'verifiableCredential' in doc; +}; + +// Normalises `verifiableCredential` into an array. +const getCredentials = (doc: VerifiablePresentation): SignedVerifiableCredential[] => { + const vc = doc?.verifiableCredential; + if (!vc) return []; + return Array.isArray(vc) ? vc : [vc]; +}; + +const readId = (value: unknown): string | undefined => { + if (!value) return undefined; + if (typeof value === 'string') return value; + return (value as { id?: string }).id; +}; + +// Strips the fragment off a verification-method id: `did:...#key` -> `did:...`. +const getDidFromId = (id: string | undefined): string | undefined => + id ? id.split('#')[0] : undefined; + +// Returns the first credentialSubject (credentialSubject may be an object or an array). +const getFirstSubject = (cred: SignedVerifiableCredential): unknown => + Array.isArray(cred?.credentialSubject) ? cred.credentialSubject[0] : cred?.credentialSubject; + +// Holder binding: the signer's DID (from the proof's verificationMethod) must equal the +// holder and every credentialSubject.id. Returns an error message, or undefined when bound. +const checkVpHolderBinding = (doc: VerifiablePresentation): string | undefined => { + const signerDid = getDidFromId(doc.proof?.verificationMethod as string | undefined); + const holder = readId(doc.holder); + if (!signerDid) return 'the presentation proof has no "verificationMethod" to bind to.'; + if (holder && holder !== signerDid) { + return `the presentation was signed by "${signerDid}", which does not match the declared holder "${holder}".`; + } + const owner = holder ?? signerDid; + const credentials = getCredentials(doc); + for (let i = 0; i < credentials.length; i++) { + const subjectId = readId(getFirstSubject(credentials[i])); + if (!subjectId) { + return `credential at index ${i} has no "credentialSubject.id", so it cannot be bound to the holder.`; + } + if (subjectId !== owner) { + return `credentialSubject.id ("${subjectId}") of credential at index ${i} does not match the presentation holder/signer ("${owner}").`; + } + } + return undefined; +}; + +// Resolves a DID (did:key in-memory, did:web via loader/well-known). +const checkDidResolve = async (did: string, documentLoader?: DocumentLoader): Promise => { + try { + if (isDidKey(did)) { + parseDidKey(did); + return true; + } + if (documentLoader) { + return !!(await documentLoader(did)).document; + } + const { wellKnownDid } = await queryDidDocument({ did }); + return !!wellKnownDid; + } catch { + return false; + } +}; + +// --------------------------------------------------------------------------- +// DOCUMENT_INTEGRITY — the holder proof (crypto only) + every embedded credential's signature. +// NOTE: challenge/domain are NOT enforced here — they are interactive (anti-replay / audience) +// concerns that a stateless verification pipeline cannot check. Only cryptographic validity +// of the holder proof is verified. +// --------------------------------------------------------------------------- +export const w3cVpSignatureIntegrity: Verifier = { + skip: async () => ({ + type: 'DOCUMENT_INTEGRITY', + name: 'W3CVpSignatureIntegrity', + reason: { + code: 0, + codeString: 'SKIPPED', + message: 'Document is not a Verifiable Presentation.', + }, + status: 'SKIPPED', + }), + + test: (document: unknown) => isVpDocument(document), + + verify: async (document: unknown, verifierOptions: VerifierOptions) => { + const doc = document as VerifiablePresentation; + + // A VP MUST be signed: without a holder proof the presenter cannot prove ownership + // of the credentials, so an unsigned presentation fails integrity outright. + if (!doc.proof) { + return { + type: 'DOCUMENT_INTEGRITY', + name: 'W3CVpSignatureIntegrity', + reason: { + message: 'Presentation is not signed (no holder "proof"), so ownership cannot be proven.', + }, + status: 'INVALID', + }; + } + + // Pass the proof's own challenge/domain so an authentication proof verifies its crypto + // (this checks signature validity, NOT freshness — freshness is out of pipeline scope). + const result = await verifyPresentation(doc, { + challenge: doc.proof?.challenge as string | undefined, + domain: doc.proof?.domain as string | undefined, + documentLoader: verifierOptions?.documentLoader, + }); + + const credentialsValid = (result.credentialResults ?? []).every((r) => r.verified); + const proofValid = result.presentationResult?.verified === true; + // Holder binding: signer DID == holder == every credentialSubject.id. + const bindingError = checkVpHolderBinding(doc); + const valid = credentialsValid && proofValid && !bindingError; + + if (valid) { + return { + type: 'DOCUMENT_INTEGRITY', + name: 'W3CVpSignatureIntegrity', + data: { + holderProofVerified: true, + holderBound: true, + credentialResults: result.credentialResults, + }, + status: 'VALID', + }; + } + return { + type: 'DOCUMENT_INTEGRITY', + name: 'W3CVpSignatureIntegrity', + data: { + holderProofVerified: proofValid, + holderBound: !bindingError, + credentialResults: result.credentialResults, + }, + reason: { + message: !proofValid + ? (result.presentationResult?.error ?? 'Presentation proof is invalid.') + : (bindingError ?? + result.credentialResults?.find((r) => !r.verified)?.error ?? + 'An embedded credential is invalid.'), + }, + status: 'INVALID', + }; + }, +}; + +// --------------------------------------------------------------------------- +// DOCUMENT_STATUS — every embedded credential's revocation/suspension status + VP expiry. +// --------------------------------------------------------------------------- +export const w3cVpCredentialStatus: Verifier = { + skip: async () => ({ + type: 'DOCUMENT_STATUS', + name: 'W3CVpCredentialStatus', + reason: { + code: 0, + codeString: 'SKIPPED', + message: 'Document is not a Verifiable Presentation.', + }, + status: 'SKIPPED', + }), + + test: (document: unknown) => isVpDocument(document), + + verify: async (document: unknown, verifierOptions: VerifierOptions) => { + const doc = document as VerifiablePresentation; + + // VP expiry (validUntil / expirationDate). + const validUntil = (doc.validUntil ?? doc.expirationDate) as string | undefined; + if (validUntil && new Date() > new Date(validUntil)) { + return { + type: 'DOCUMENT_STATUS', + name: 'W3CVpCredentialStatus', + data: { expired: true, validUntil }, + reason: { message: `Presentation has expired (validUntil ${validUntil}).` }, + status: 'INVALID', + }; + } + + // Embedded credentials' revocation status. + const credentials = getCredentials(doc); + const statusChecks = await Promise.all( + credentials.flatMap((cred) => { + const raw = cred.credentialStatus; + const statuses = (Array.isArray(raw) ? raw : raw ? [raw] : []) as CredentialStatus[]; + return statuses + .filter((cs) => ['BitstringStatusListEntry', 'StatusList2021Entry'].includes(cs?.type)) + .map((cs) => + verifyCredentialStatus( + cs as BitstringStatusListCredentialStatus, + cs.type as CredentialStatusType, + verifierOptions, + ), + ); + }), + ); + + const revoked = statusChecks.find((r) => r.status === true); + if (revoked) { + return { + type: 'DOCUMENT_STATUS', + name: 'W3CVpCredentialStatus', + data: { revoked: true }, + reason: { + message: `An embedded credential has been revoked (status purpose "${revoked.purpose ?? 'revocation'}").`, + }, + status: 'INVALID', + }; + } + const statusError = statusChecks.find((r) => r.error); + if (statusError) { + return { + type: 'DOCUMENT_STATUS', + name: 'W3CVpCredentialStatus', + reason: { message: `Could not verify an embedded credential status: ${statusError.error}` }, + status: 'ERROR', + }; + } + return { + type: 'DOCUMENT_STATUS', + name: 'W3CVpCredentialStatus', + data: { revoked: false, checked: statusChecks.length }, + status: 'VALID', + }; + }, +}; + +// --------------------------------------------------------------------------- +// ISSUER_IDENTITY — every embedded credential's issuer DID resolves. +// --------------------------------------------------------------------------- +export const w3cVpIssuerIdentity: Verifier = { + skip: async () => ({ + type: 'ISSUER_IDENTITY', + name: 'W3CVpIssuerIdentity', + reason: { + code: 0, + codeString: 'SKIPPED', + message: 'Document is not a Verifiable Presentation.', + }, + status: 'SKIPPED', + }), + + test: (document: unknown) => isVpDocument(document), + + verify: async (document: unknown, verifierOptions: VerifierOptions) => { + const doc = document as VerifiablePresentation; + const credentials = getCredentials(doc); + const issuers = credentials.map((c) => readId(c.issuer)).filter(Boolean) as string[]; + + if (issuers.length === 0) { + return { + type: 'ISSUER_IDENTITY', + name: 'W3CVpIssuerIdentity', + reason: { message: 'No embedded credential has an issuer.' }, + status: 'INVALID', + }; + } + + const resolved = await Promise.all( + issuers.map((did) => checkDidResolve(did, verifierOptions?.documentLoader)), + ); + const allResolved = resolved.every(Boolean); + if (allResolved) { + return { + type: 'ISSUER_IDENTITY', + name: 'W3CVpIssuerIdentity', + data: { issuers }, + status: 'VALID', + }; + } + const unresolved = issuers.filter((_, i) => !resolved[i]); + return { + type: 'ISSUER_IDENTITY', + name: 'W3CVpIssuerIdentity', + data: { issuers, unresolved }, + reason: { message: `Could not resolve issuer(s): ${unresolved.join(', ')}.` }, + status: 'INVALID', + }; + }, +}; diff --git a/src/verify/verify.ts b/src/verify/verify.ts index 1e32536..433dbab 100644 --- a/src/verify/verify.ts +++ b/src/verify/verify.ts @@ -36,6 +36,11 @@ import { w3cCredentialStatus } from './fragments/document-status/w3cCredentialSt import { w3cIssuerIdentity } from './fragments/issuer-identity/w3cIssuerIdentity'; import { w3cEmptyCredentialStatus } from './fragments'; import { bbs2023W3CSignatureIntegrity } from './fragments/document-integrity/bbs2023W3CSignatureIntegrity'; +import { + w3cVpCredentialStatus, + w3cVpIssuerIdentity, + w3cVpSignatureIntegrity, +} from './fragments/presentation/w3cVpVerifier'; import { registryVerifier } from '../open-cert'; const verifiers = { @@ -68,6 +73,10 @@ const w3cVerifiers: Verifier[] = [ credentialStatusTransferableRecordVerifier, w3cEmptyCredentialStatus, w3cIssuerIdentity, + // Verifiable Presentation (aggregate) fragments + w3cVpSignatureIntegrity, + w3cVpCredentialStatus, + w3cVpIssuerIdentity, ]; export { diff --git a/src/w3c/index.ts b/src/w3c/index.ts index 6eb21aa..80a67f9 100644 --- a/src/w3c/index.ts +++ b/src/w3c/index.ts @@ -6,3 +6,4 @@ export * from './types'; export * as vc from './vc'; export * from './verify'; export * from './derive'; +export * from './presentation'; diff --git a/src/w3c/presentation.ts b/src/w3c/presentation.ts new file mode 100644 index 0000000..4374d59 --- /dev/null +++ b/src/w3c/presentation.ts @@ -0,0 +1,105 @@ +import { + createPresentation, + DocumentLoader, + PresentationSigningResult, + PresentationVerificationResult, + RawVerifiablePresentation, + signPresentation, + SignedVerifiableCredential, + verifyPresentation, +} from '@trustvc/w3c-vc'; +import { PrivateKeyPair } from './types'; + +// Combined create + sign options (createPresentation options + signPresentation options). +type CreatePresentationOptions = NonNullable[1]>; +type SignPresentationOptions = NonNullable[2]>; +type SignW3CPresentationOptions = CreatePresentationOptions & SignPresentationOptions; + +// The trustvc layer ENFORCES these policies rather than trusting the caller to pass them: +// - fullDisclosure → accept underived credentials (auto full-disclosure); already-derived +// credentials keep their selective disclosure. +// - checkHolderBinding → the signing key's DID must equal the holder and every +// credentialSubject.id (enforced at sign AND verify). +// - version 'v2' → the presentation ENVELOPE is always VC Data Model v2.0 +// (validFrom/validUntil). Embedded credentials keep their own version. +// - expiry → createPresentation always stamps validFrom/validUntil (mandatory). +// So callers cannot omit or disable them; they only supply the per-request values below. +const ENFORCED_SIGN = { + fullDisclosure: true, + checkHolderBinding: true, + version: 'v2', +} as const; +const ENFORCED_VERIFY = { checkHolderBinding: true } as const; + +// Callers may set everything EXCEPT the enforced flags, AND must specify the VP lifetime +// (either `expiresInSeconds` or an explicit `validUntil`) so an expiry is never left to a +// silent default. +type BaseSignerOptions = Omit< + SignW3CPresentationOptions, + keyof typeof ENFORCED_SIGN | 'expiresInSeconds' | 'validUntil' +>; +type SignerOptions = BaseSignerOptions & ({ expiresInSeconds: number } | { validUntil: string }); + +/** + * Creates AND signs a Verifiable Presentation in a single call, with trustvc's + * policies ENFORCED: underived credentials are auto full-disclosed, holder binding + * is required (signing key DID == holder == every credentialSubject.id), and a + * mandatory expiry is stamped. With a `challenge` an authentication proof is produced; + * without one, an assertionMethod proof. + * @param {SignedVerifiableCredential | SignedVerifiableCredential[]} verifiableCredential - Credential(s) to present. + * @param {PrivateKeyPair} keyPair - The holder's ECDSA (P-256) Multikey key pair. + * @param {object} options - Per-request options (holder, challenge, domain, ...). The VP + * lifetime is REQUIRED: pass `expiresInSeconds` OR an explicit `validUntil`. + * `fullDisclosure` and `checkHolderBinding` are enforced and cannot be set here. + * @returns {Promise} The signed presentation or an error. + */ +export const signW3CPresentation = async ( + verifiableCredential: SignedVerifiableCredential | SignedVerifiableCredential[], + keyPair: PrivateKeyPair, + options: SignerOptions, +): Promise => { + // Runtime guards (in case the types are bypassed). + if (!keyPair) { + return { error: 'a signing key (keyPair) is required to sign a presentation.' }; + } + const opts = options as { expiresInSeconds?: number; validUntil?: string }; + if (opts?.expiresInSeconds == null && !opts?.validUntil) { + return { + error: 'a VP lifetime is required: pass "expiresInSeconds" or "validUntil".', + }; + } + const enforced = { ...options, ...ENFORCED_SIGN }; + let presentation: RawVerifiablePresentation; + try { + presentation = await createPresentation(verifiableCredential, enforced); + } catch (err: unknown) { + return { error: err instanceof Error ? err.message : 'Failed to create the presentation.' }; + } + return signPresentation(presentation, keyPair, enforced); +}; + +// Per-request verify options. `checkHolderBinding` is enforced by the wrapper and so is +// deliberately NOT settable here. +type VerifierOptions = { + challenge?: string; + domain?: string; + requireProof?: boolean; + maxLifetimeSeconds?: number; + documentLoader?: DocumentLoader; +}; + +/** + * Verifies a Verifiable Presentation with trustvc's policies ENFORCED: holder binding + * is required (a valid holder proof whose key DID == holder == every credentialSubject.id) + * and the VP expiry is checked. Every embedded credential is verified too. + * @param {RawVerifiablePresentation} presentation - The presentation to verify. + * @param {VerifierOptions} [options] - Per-request options (challenge, domain, + * maxLifetimeSeconds, ...). `checkHolderBinding` is enforced and cannot be disabled. + * @returns {Promise} The aggregated verification result. + */ +export const verifyW3CPresentation = async ( + presentation: RawVerifiablePresentation, + options?: VerifierOptions, +): Promise => { + return verifyPresentation(presentation, { ...options, ...ENFORCED_VERIFY }); +}; From 060f59f003bccac92e1e1151157e93b11206f552 Mon Sep 17 00:00:00 2001 From: rongquan1 <85145303+rongquan1@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:45:38 +0800 Subject: [PATCH 2/5] fix(w3c): address PR review (CodeRabbit + SonarCloud) and Polygon RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes to the VP support: - w3cVpSignatureIntegrity: holder binding now checks EVERY credentialSubject of every credential (was only the first). - w3cVpCredentialStatus: surface unsupported credentialStatus types as ERROR instead of silently dropping them (revocation must not be silently unenforced). - w3cVpIssuerIdentity: a credential with no issuer now fails (INVALID) instead of being dropped by filter(Boolean). - fragments/index.ts: re-export the VP verifiers via `export … from` (Sonar S7763). - Extract a toArray helper (removes a nested ternary, Sonar S3358). - Tests: use assertDefined instead of `!`; +2 tests (unsupported-status ERROR, missing-issuer INVALID). Regenerate core/verify.test snapshots for the 3 VP fragments now emitted (SKIPPED) on non-VP documents. Polygon public RPC deprecation: - Replace https://rpc-amoy.polygon.technology with https://polygon-amoy-bor-rpc.publicnode.com in the affected tests. Docs: - README: add a "Verifiable Presentations (VP)" section and renumber the rest. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 65 +++++++++++--- src/__tests__/core/documentBuilder.test.ts | 16 ++-- src/__tests__/core/verify.amoy.test.ts | 2 +- src/__tests__/core/verify.test.ts | 62 ++++++++++++- src/__tests__/w3c/vpFragments.test.ts | 47 ++++++++-- src/verify/fragments/index.ts | 6 +- .../fragments/presentation/w3cVpVerifier.ts | 89 ++++++++++++++----- 7 files changed, 230 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index fc2b368..234ef11 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,18 @@ TrustVC is a comprehensive wrapper library designed to simplify the signing and - [a) OpenAttestation Signing (signOA) v2](#a-openattestation-signing-signoa-v2) - [b) TrustVC W3C Signing (signW3C)](#b-trustvc-w3c-signing-signw3c) - [3. **Deriving (Selective Disclosure)**](#3-deriving-selective-disclosure) - - [4. **Verifying**](#4-verifying) - - [5. **Encryption**](#5-encryption) - - [6. **Decryption**](#6-decryption) - - [7. **TradeTrust Token Registry**](#7-tradetrust-token-registry) + - [4. **Verifiable Presentations (VP)**](#4-verifiable-presentations-vp) + - [5. **Verifying**](#5-verifying) + - [6. **Encryption**](#6-encryption) + - [7. **Decryption**](#7-decryption) + - [8. **TradeTrust Token Registry**](#8-tradetrust-token-registry) - [Usage](#usage-2) - [TradeTrustToken](#tradetrusttoken) - [a) Token Registry v4](#a-token-registry-v4) - [b) Token Registry V5](#b-token-registry-v5) - - [8. **Document Builder**](#8-document-builder) - - [9. **Document Store**](#9-document-store) - - [10. **Transaction Cancel**](#10-transaction-cancel) + - [9. **Document Builder**](#9-document-builder) + - [10. **Document Store**](#10-document-store) + - [11. **Transaction Cancel**](#11-transaction-cancel) ## Installation @@ -342,7 +343,43 @@ const derivationResult = await deriveW3C(signedDocument, { --- -### 4. **Verifying** +### 4. **Verifiable Presentations (VP)** + +> A Verifiable Presentation lets a **holder** bundle one or more of their credentials and cryptographically prove they own them. TrustVC exposes an opinionated, single-call API. `signW3CPresentation` **creates and signs** a VP in one step, with these policies **ENFORCED** (callers cannot disable them): +> +> - **Full disclosure** — an underived selective-disclosure credential is auto-derived; you can pass credentials as-is. +> - **Holder binding** — the signing key's DID must equal the presentation `holder` and every `credentialSubject.id`. (The **issuer is independent** — a credential issued by a different party is fine.) +> - **Mandatory lifetime** — you must pass `expiresInSeconds` or an explicit `validUntil`. +> - **v2 envelope** — the presentation envelope is always VC Data Model v2.0 (embedded credentials keep their own version). +> +> Credentials carrying a `TransferableRecords` status cannot be presented (they are controlled on-chain). The holder proof uses the `ecdsa-rdfc-2019` cryptosuite and reuses the holder's ECDSA (P-256) Multikey. A `challenge` produces an `authentication` proof (anti-replay); omitting it produces an `assertionMethod` proof. + +```ts +import { signW3CPresentation, verifyW3CPresentation } from '@trustvc/trustvc'; + +// `derivedCredential` is a signed (and, for SD suites, derived) W3C VC whose +// credentialSubject.id is the holder. `holderKeyPair` is the holder's ECDSA Multikey. +const { signed, error } = await signW3CPresentation(derivedCredential, holderKeyPair, { + holder: 'did:key:zDnae...', + challenge: 'nonce-issued-by-the-verifier', // authentication proof (anti-replay) + domain: 'verifier.example.com', // optional; requires a challenge + expiresInSeconds: 600, // REQUIRED (or `validUntil`) +}); + +// Verify: holder proof + holder binding + VP expiry + every embedded credential +// (signature, expiry and revocation). Pass the same challenge the verifier issued. +const result = await verifyW3CPresentation(signed, { + challenge: 'nonce-issued-by-the-verifier', + domain: 'verifier.example.com', +}); +// result.verified, result.presentationResult, result.credentialResults +``` + +> A presentation can also flow through the unified [`verifyDocument`](#5-verifying) pipeline, which emits VP fragments: `W3CVpSignatureIntegrity` (holder proof + holder binding — an unsigned VP is INVALID), `W3CVpCredentialStatus` (VP expiry + embedded revocation) and `W3CVpIssuerIdentity` (embedded issuers resolve). + +--- + +### 5. **Verifying** > TrustVC simplifies the verification process with a single function that supports W3C Verifiable Credentials (VCs) and OpenAttestation Verifiable Documents (VDs), including OpenCert Verifiable Documents. Whether you're working with W3C standards or OpenAttestation standards, TrustVC handles the verification seamlessly. For ECDSA-SD-2023 and BBS-2023 signed documents, which normally require derivation before verification, TrustVC automatically handles this process internally - if a document is not derived, the `verifyDocument` function will automatically derive and verify the document in a single step. @@ -387,7 +424,7 @@ const resultFragments = await verifyDocument(signedDocument); --- -### 5. **Encryption** +### 6. **Encryption** > The `encrypt` function encrypts plaintext messages using the **ChaCha20** encryption algorithm, ensuring the security and integrity of the input data. It supports custom keys and nonces, returning the encrypted message in hexadecimal format. @@ -464,7 +501,7 @@ It also relies on the `ts-chacha20` library for encryption operations. --- -### 6. **Decryption** +### 7. **Decryption** > The `decrypt` function decrypts messages encrypted with the **ChaCha20** algorithm. It converts the input from a hexadecimal format back into plaintext using the provided key and nonce. @@ -547,7 +584,7 @@ It also relies on the `ts-chacha20` library for decryption operations. --- -### 7. **TradeTrust Token Registry** +### 8. **TradeTrust Token Registry** > The Electronic Bill of Lading (eBL) is a digital document that can be used to prove the ownership of goods. It is a standardized document that is accepted by all major shipping lines and customs authorities. The [Token Registry](https://github.com/TradeTrust/token-registry) repository contains both the smart contract (v4 and v5) code for token registry (in `/contracts`) as well as the node package for using this library (in `/src`). > The TrustVC library not only simplifies signing and verification but also imports and integrates existing TradeTrust libraries and smart contracts for token registry (V4 and V5), making it a versatile tool for decentralized identity and trust solutions. @@ -737,7 +774,7 @@ function rejectTransferOwners(bytes calldata _remark) external; For more information on Token Registry and Title Escrow contracts **version v5**, please visit the readme of [TradeTrust Token Registry V5](https://github.com/TradeTrust/token-registry/blob/master/README.md) -### 8. **Document Builder** +### 9. **Document Builder** > The `DocumentBuilder` class helps build and manage W3C Verifiable Credentials (VCs) with credential status features, implementing the **W3C VC Data Model 2.0** specification. It supports creating documents with two types of credential statuses: `transferableRecords` and `verifiableDocument`. It can sign the document using modern cryptographic signature schemes including **ECDSA-SD-2023** (default) and **BBS-2023**, verify its signature, and serialize the document to a JSON format. Additionally, it allows for configuration of document rendering methods and expiration dates. #### Usage @@ -935,7 +972,7 @@ const documentJson = builder.toString(); console.log(documentJson); ``` -## 9. Document Store +## 10. Document Store > TrustVC provides comprehensive Document Store functionality for managing blockchain-based document storage and verification. The Document Store module supports both standard DocumentStore and TransferableDocumentStore contracts, enabling secure document issuance, revocation, and role management on various blockchain networks. @@ -1153,7 +1190,7 @@ for (const hash of documentHashes) { --- -## 10. Transaction Cancel +## 11. Transaction Cancel TrustVC provides a utility to cancel a pending Ethereum transaction by replacing it with a 0-value transaction to the same address, using the same nonce and a higher gas price (replace-by-fee). This works with both ethers v5 and v6 signers. diff --git a/src/__tests__/core/documentBuilder.test.ts b/src/__tests__/core/documentBuilder.test.ts index 831e616..fd22f86 100644 --- a/src/__tests__/core/documentBuilder.test.ts +++ b/src/__tests__/core/documentBuilder.test.ts @@ -112,7 +112,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { chain: 'amoy', chainId: 80002, tokenRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', - rpcProviderUrl: 'https://rpc-amoy.polygon.technology', + rpcProviderUrl: 'https://polygon-amoy-bor-rpc.publicnode.com', }), ).toThrow('Configuration Error: Document is already signed.'); }); @@ -124,7 +124,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { chain: 'amoy', chainId: 80002, tokenRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', - rpcProviderUrl: 'https://rpc-amoy.polygon.technology', + rpcProviderUrl: 'https://polygon-amoy-bor-rpc.publicnode.com', }); expect(documentBuilder).toBeDefined(); }); @@ -143,7 +143,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { chain: 'amoy', chainId: 80002, tokenRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', - rpcProviderUrl: 'https://rpc-amoy.polygon.technology', + rpcProviderUrl: 'https://polygon-amoy-bor-rpc.publicnode.com', url: 'https://trustvc.github.io/did/credentials/statuslist/1', index: 10, }), @@ -167,7 +167,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { chain: 'amoy', chainId: 80002, tokenRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', - rpcProviderUrl: 'https://rpc-amoy.polygon.technology', + rpcProviderUrl: 'https://polygon-amoy-bor-rpc.publicnode.com', }); const signedDocument = await documentBuilder.sign(ECDSAtestPrivateKey); expect(signedDocument).toBeDefined(); @@ -195,7 +195,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { chain: 'amoy', chainId: 80002, tokenRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', - rpcProviderUrl: 'https://rpc-amoy.polygon.technology', + rpcProviderUrl: 'https://polygon-amoy-bor-rpc.publicnode.com', }); const signedDocument = await documentBuilder.sign(bbs2023KeyPair, CryptoSuite.Bbs2023); expect(signedDocument).toBeDefined(); @@ -246,7 +246,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { chain: 'amoy', chainId: 80002, tokenRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', - rpcProviderUrl: 'https://rpc-amoy.polygon.technology', + rpcProviderUrl: 'https://polygon-amoy-bor-rpc.publicnode.com', }); const signedDocument = await documentBuilder.sign(ECDSAtestPrivateKey); expect(signedDocument).toBeDefined(); @@ -260,7 +260,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { chain: 'amoy', chainId: 80002, tokenRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', - rpcProviderUrl: 'https://rpc-amoy.polygon.technology', + rpcProviderUrl: 'https://polygon-amoy-bor-rpc.publicnode.com', }); const signedDocument = await documentBuilder.sign(bbs2023KeyPair, CryptoSuite.Bbs2023); expect(signedDocument).toBeDefined(); @@ -274,7 +274,7 @@ describe('DocumentBuilder data model 2.0 using ECDSA', () => { chain: 'unknown-chain', chainId: 999999, // Invalid chainId tokenRegistry: '0x71D28767662cB233F887aD2Bb65d048d760bA694', - rpcProviderUrl: 'https://rpc-amoy.polygon.technology', + rpcProviderUrl: 'https://polygon-amoy-bor-rpc.publicnode.com', }); await expect(documentBuilder.sign(ECDSAtestPrivateKey)).rejects.toThrow( 'Unsupported Chain: Chain ID 999999 is not supported.', diff --git a/src/__tests__/core/verify.amoy.test.ts b/src/__tests__/core/verify.amoy.test.ts index 4152650..6d6c49d 100644 --- a/src/__tests__/core/verify.amoy.test.ts +++ b/src/__tests__/core/verify.amoy.test.ts @@ -9,7 +9,7 @@ import { w3cTransferableRecordMintedTests, } from './verify.polygon-network.helpers'; -const AMOY_RPC_URL = process.env.AMOY_RPC || 'https://rpc-amoy.polygon.technology/'; +const AMOY_RPC_URL = process.env.AMOY_RPC || 'https://polygon-amoy-bor-rpc.publicnode.com/'; describe('Polygon Amoy (testnet) network support', () => { describe('CHAIN_ID and SUPPORTED_CHAINS', () => { diff --git a/src/__tests__/core/verify.test.ts b/src/__tests__/core/verify.test.ts index 424c8af..4dd9005 100644 --- a/src/__tests__/core/verify.test.ts +++ b/src/__tests__/core/verify.test.ts @@ -18,7 +18,7 @@ import { import { W3CCredentialStatusCode } from '../../verify/fragments/document-status/w3cCredentialStatus'; import { openAttestationDidSignedDocumentStatus } from '@tradetrust-tt/tt-verify'; -const providerUrl = 'https://rpc-amoy.polygon.technology'; +const providerUrl = 'https://polygon-amoy-bor-rpc.publicnode.com'; describe.concurrent('W3C verify', () => { describe.concurrent('W3C_VERIFIABLE_DOCUMENT', () => { @@ -88,6 +88,36 @@ describe.concurrent('W3C verify', () => { "status": "VALID", "type": "ISSUER_IDENTITY", }, + { + "name": "W3CVpSignatureIntegrity", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document is not a Verifiable Presentation.", + }, + "status": "SKIPPED", + "type": "DOCUMENT_INTEGRITY", + }, + { + "name": "W3CVpCredentialStatus", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document is not a Verifiable Presentation.", + }, + "status": "SKIPPED", + "type": "DOCUMENT_STATUS", + }, + { + "name": "W3CVpIssuerIdentity", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document is not a Verifiable Presentation.", + }, + "status": "SKIPPED", + "type": "ISSUER_IDENTITY", + }, ] `); }); @@ -357,6 +387,36 @@ describe.concurrent('W3C verify', () => { "status": "VALID", "type": "ISSUER_IDENTITY", }, + { + "name": "W3CVpSignatureIntegrity", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document is not a Verifiable Presentation.", + }, + "status": "SKIPPED", + "type": "DOCUMENT_INTEGRITY", + }, + { + "name": "W3CVpCredentialStatus", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document is not a Verifiable Presentation.", + }, + "status": "SKIPPED", + "type": "DOCUMENT_STATUS", + }, + { + "name": "W3CVpIssuerIdentity", + "reason": { + "code": 0, + "codeString": "SKIPPED", + "message": "Document is not a Verifiable Presentation.", + }, + "status": "SKIPPED", + "type": "ISSUER_IDENTITY", + }, ] `); }, diff --git a/src/__tests__/w3c/vpFragments.test.ts b/src/__tests__/w3c/vpFragments.test.ts index 27a557f..c662057 100644 --- a/src/__tests__/w3c/vpFragments.test.ts +++ b/src/__tests__/w3c/vpFragments.test.ts @@ -17,6 +17,12 @@ import { import { w3cIssuerIdentity } from '../../verify/fragments/issuer-identity/w3cIssuerIdentity'; import { verifyDocument } from '../../core/verify'; +// Asserts a value is defined and returns it narrowed (avoids `!` assertions). +const assertDefined = (value: T | undefined, message: string): T => { + if (value === undefined) throw new Error(message); + return value; +}; + const PUB = 'zDnaemDNwi4G5eTzGfRooFFu5Kns3be6yfyVNtiaMhWkZbwtc'; const SEC = 'z42tmUXTVn3n9BihE6NhdMpvVBTnFTgmb6fw18o5Ud6puhRW'; const DID = `did:key:${PUB}`; @@ -42,9 +48,11 @@ describe('W3C VP verification fragments', () => { credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: DID }, }; const s = await signCredential(raw as never, holderKey as never, 'ecdsa-sd-2023'); - embeddedVc = ( - await deriveCredential(s.signed!, ['/credentialSubject/id', '/credentialSubject/blNumber']) - ).derived!; + const derived = await deriveCredential(assertDefined(s.signed, 'signed'), [ + '/credentialSubject/id', + '/credentialSubject/blNumber', + ]); + embeddedVc = assertDefined(derived.derived, 'derived'); }); it('test() detects a VP and the VC-only issuer verifier skips it', async () => { @@ -109,8 +117,15 @@ describe('W3C VP verification fragments', () => { credentialSubject: { ...W3C_RAW_CREDENTIAL_V2_0.credentialSubject, id: DID }, }; const s = await signCredential(raw as never, holderKey as never, 'ecdsa-sd-2023'); - const vc = (await deriveCredential(s.signed!, ['/credentialSubject/id', '/validUntil'])) - .derived!; + const vc = assertDefined( + ( + await deriveCredential(assertDefined(s.signed, 'signed'), [ + '/credentialSubject/id', + '/validUntil', + ]) + ).derived, + 'derived', + ); const vp = await createPresentation(vc as never, { holder: DID, now: new Date('2020-06-01T00:00:00Z'), @@ -147,6 +162,28 @@ describe('W3C VP verification fragments', () => { expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/revoked/i); }); + it('w3cVpCredentialStatus ERRORs on an unsupported credentialStatus type (not silently dropped)', async () => { + const vc = { + ...W3C_VERIFIABLE_DOCUMENT, + credentialStatus: { type: 'TransferableRecords' }, + }; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [vc] }; + const o = await opts(); + const status = await w3cVpCredentialStatus.verify(vp as never, o as never); + expect(status.status).toBe('ERROR'); + expect((status as { reason?: { message?: string } }).reason?.message).toMatch(/Unsupported/i); + }); + + it('w3cVpIssuerIdentity emits INVALID when an embedded credential has no issuer', async () => { + const noIssuer = { ...W3C_VERIFIABLE_DOCUMENT } as { issuer?: string }; + delete noIssuer.issuer; + const vp = { type: ['VerifiablePresentation'], verifiableCredential: [noIssuer] }; + const o = await opts(); + const issuer = await w3cVpIssuerIdentity.verify(vp as never, o as never); + expect(issuer.status).toBe('INVALID'); + expect((issuer as { reason?: { message?: string } }).reason?.message).toMatch(/no issuer/i); + }); + it('w3cVpIssuerIdentity resolves an embedded did:web issuer (→ VALID)', async () => { const vp = { type: ['VerifiablePresentation'], diff --git a/src/verify/fragments/index.ts b/src/verify/fragments/index.ts index 85041d0..bfc72f5 100644 --- a/src/verify/fragments/index.ts +++ b/src/verify/fragments/index.ts @@ -15,16 +15,14 @@ import { import { w3cCredentialStatus } from './document-status/w3cCredentialStatus'; import { w3cIssuerIdentity } from './issuer-identity/w3cIssuerIdentity'; import { w3cEmptyCredentialStatus } from './document-status/w3cEmptyCredentialStatus'; -import { + +export { w3cVpCredentialStatus, w3cVpIssuerIdentity, w3cVpSignatureIntegrity, } from './presentation/w3cVpVerifier'; export { - w3cVpCredentialStatus, - w3cVpIssuerIdentity, - w3cVpSignatureIntegrity, TRANSFERABLE_RECORDS_TYPE, credentialStatusTransferableRecordVerifier, openAttestationDidSignedDocumentStatus, diff --git a/src/verify/fragments/presentation/w3cVpVerifier.ts b/src/verify/fragments/presentation/w3cVpVerifier.ts index 4f98790..882ba93 100644 --- a/src/verify/fragments/presentation/w3cVpVerifier.ts +++ b/src/verify/fragments/presentation/w3cVpVerifier.ts @@ -13,6 +13,9 @@ import { verifyPresentation, } from '@trustvc/w3c-vc'; +// StatusList credentialStatus types this pipeline can evaluate for revocation. +const SUPPORTED_STATUS_TYPES = new Set(['BitstringStatusListEntry', 'StatusList2021Entry']); + // A document is a Verifiable Presentation when its `type` includes // `VerifiablePresentation` and it carries a `verifiableCredential` field. const isVpDocument = (document: unknown): boolean => { @@ -39,9 +42,15 @@ const readId = (value: unknown): string | undefined => { const getDidFromId = (id: string | undefined): string | undefined => id ? id.split('#')[0] : undefined; -// Returns the first credentialSubject (credentialSubject may be an object or an array). -const getFirstSubject = (cred: SignedVerifiableCredential): unknown => - Array.isArray(cred?.credentialSubject) ? cred.credentialSubject[0] : cred?.credentialSubject; +// Normalises an object-or-array value into an array (empty when absent). +const toArray = (value: T | T[] | undefined | null): T[] => { + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +}; + +// Returns ALL credentialSubjects (credentialSubject may be an object or an array). +const getSubjects = (cred: SignedVerifiableCredential): unknown[] => + toArray(cred?.credentialSubject as unknown); // Holder binding: the signer's DID (from the proof's verificationMethod) must equal the // holder and every credentialSubject.id. Returns an error message, or undefined when bound. @@ -55,12 +64,20 @@ const checkVpHolderBinding = (doc: VerifiablePresentation): string | undefined = const owner = holder ?? signerDid; const credentials = getCredentials(doc); for (let i = 0; i < credentials.length; i++) { - const subjectId = readId(getFirstSubject(credentials[i])); - if (!subjectId) { - return `credential at index ${i} has no "credentialSubject.id", so it cannot be bound to the holder.`; + const subjects = getSubjects(credentials[i]); + if (subjects.length === 0) { + return `credential at index ${i} has no credentialSubject, so it cannot be bound to the holder.`; } - if (subjectId !== owner) { - return `credentialSubject.id ("${subjectId}") of credential at index ${i} does not match the presentation holder/signer ("${owner}").`; + // EVERY subject must be the holder — a credential with a second subject bound to a + // different DID must not pass. + for (const subject of subjects) { + const subjectId = readId(subject); + if (!subjectId) { + return `credential at index ${i} has a subject with no "credentialSubject.id", so it cannot be bound to the holder.`; + } + if (subjectId !== owner) { + return `credentialSubject.id ("${subjectId}") of credential at index ${i} does not match the presentation holder/signer ("${owner}").`; + } } } return undefined; @@ -199,20 +216,35 @@ export const w3cVpCredentialStatus: Verifier = { // Embedded credentials' revocation status. const credentials = getCredentials(doc); + const allStatuses = credentials.flatMap((cred) => + toArray(cred.credentialStatus as CredentialStatus | CredentialStatus[] | undefined), + ); + + // A status entry whose type we cannot evaluate must NOT be silently dropped (that would + // report VALID while revocation is unenforced). Surface it as ERROR. + const unsupported = allStatuses.filter( + (cs) => cs?.type && !SUPPORTED_STATUS_TYPES.has(cs.type), + ); + if (unsupported.length > 0) { + const types = [...new Set(unsupported.map((cs) => cs.type))].join(', '); + return { + type: 'DOCUMENT_STATUS', + name: 'W3CVpCredentialStatus', + reason: { message: `Unsupported credentialStatus type(s) cannot be verified: ${types}.` }, + status: 'ERROR', + }; + } + const statusChecks = await Promise.all( - credentials.flatMap((cred) => { - const raw = cred.credentialStatus; - const statuses = (Array.isArray(raw) ? raw : raw ? [raw] : []) as CredentialStatus[]; - return statuses - .filter((cs) => ['BitstringStatusListEntry', 'StatusList2021Entry'].includes(cs?.type)) - .map((cs) => - verifyCredentialStatus( - cs as BitstringStatusListCredentialStatus, - cs.type as CredentialStatusType, - verifierOptions, - ), - ); - }), + allStatuses + .filter((cs) => SUPPORTED_STATUS_TYPES.has(cs?.type)) + .map((cs) => + verifyCredentialStatus( + cs as BitstringStatusListCredentialStatus, + cs.type as CredentialStatusType, + verifierOptions, + ), + ), ); const revoked = statusChecks.find((r) => r.status === true); @@ -265,16 +297,25 @@ export const w3cVpIssuerIdentity: Verifier = { verify: async (document: unknown, verifierOptions: VerifierOptions) => { const doc = document as VerifiablePresentation; const credentials = getCredentials(doc); - const issuers = credentials.map((c) => readId(c.issuer)).filter(Boolean) as string[]; + const issuerIds = credentials.map((c) => readId(c.issuer)); - if (issuers.length === 0) { + // Every embedded credential must declare an issuer — a missing issuer cannot be + // resolved, so it must fail rather than be silently dropped. + const missing = issuerIds.filter((id) => !id).length; + if (credentials.length === 0 || missing > 0) { return { type: 'ISSUER_IDENTITY', name: 'W3CVpIssuerIdentity', - reason: { message: 'No embedded credential has an issuer.' }, + reason: { + message: + credentials.length === 0 + ? 'Presentation contains no verifiable credentials.' + : `${missing} embedded credential(s) have no issuer.`, + }, status: 'INVALID', }; } + const issuers = issuerIds as string[]; const resolved = await Promise.all( issuers.map((did) => checkDidResolve(did, verifierOptions?.documentLoader)), From 7059bfde6bde30558f2d5e34477fcb1af5e4af9d Mon Sep 17 00:00:00 2001 From: rongquan1 <85145303+rongquan1@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:08:34 +0800 Subject: [PATCH 3/5] fix(w3c): skip verifiable presentations in empty-credential-status verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A VP has no top-level credentialStatus, so this VC-only verifier's test() matched it and then reported INVALID ("not a valid SignedVerifiableCredential") — marking every VP (even a valid one) as INVALID for DOCUMENT_STATUS. Exclude presentations in test() so it SKIPs them; VP status is handled by W3CVpCredentialStatus. Strengthen the full-pipeline test to assert a valid VP yields only VALID/SKIPPED fragments (and W3CEmptyCredentialStatus is SKIPPED). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/w3c/vpFragments.test.ts | 8 +++++--- .../w3cEmptyCredentialStatus/index.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/__tests__/w3c/vpFragments.test.ts b/src/__tests__/w3c/vpFragments.test.ts index c662057..a954096 100644 --- a/src/__tests__/w3c/vpFragments.test.ts +++ b/src/__tests__/w3c/vpFragments.test.ts @@ -207,8 +207,10 @@ describe('W3C VP verification fragments', () => { expect(byName('W3CVpSignatureIntegrity')?.status).toBe('VALID'); expect(byName('W3CVpCredentialStatus')?.status).toBe('VALID'); expect(byName('W3CVpIssuerIdentity')?.status).toBe('VALID'); - // No VP fragment errored. - const vpFragments = fragments.filter((f) => f.name?.startsWith('W3CVp')); - expect(vpFragments.every((f) => f.status !== 'ERROR')).toBe(true); + // VC-only verifiers must SKIP a VP — in particular W3CEmptyCredentialStatus must not + // report a valid VP as INVALID. + expect(byName('W3CEmptyCredentialStatus')?.status).toBe('SKIPPED'); + // A valid VP must produce NO INVALID/ERROR fragment across the whole pipeline. + expect(fragments.every((f) => f.status === 'VALID' || f.status === 'SKIPPED')).toBe(true); }); }); diff --git a/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts b/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts index 619869f..406e33a 100644 --- a/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts +++ b/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts @@ -19,7 +19,17 @@ export const w3cEmptyCredentialStatus: Verifier = { }, test: (document: unknown) => { - const doc = document as SignedVerifiableCredential; + const doc = document as SignedVerifiableCredential & { + type?: string | string[]; + verifiableCredential?: unknown; + }; + // Verifiable Presentations have no top-level credentialStatus but are NOT signed + // credentials — they are handled by the VP verifiers, so this VC-only check must skip + // them (otherwise it would report every VP as INVALID). + const types = Array.isArray(doc?.type) ? doc.type : [doc?.type]; + if (types.includes('VerifiablePresentation') || 'verifiableCredential' in (doc ?? {})) { + return false; + } return ( !!doc.credentialStatus === false || (Array.isArray(doc.credentialStatus) && doc.credentialStatus.length === 0) || From 47baf6f8b2a21fd84ae0a70942be8b3c2ddc21ef Mon Sep 17 00:00:00 2001 From: rongquan1 <85145303+rongquan1@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:24:11 +0800 Subject: [PATCH 4/5] chore(deps): bump @trustvc/w3c* to 2.4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit w3c, w3c-vc, w3c-context and w3c-credential-status to ^2.4.0. w3c-issuer stays at ^2.3.0 (no 2.4.0 was published — latest is 2.3.0). Type-check and VP tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 42 +++++++++++++++++++++--------------------- package.json | 8 ++++---- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index a5ea2b6..590c6c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,11 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.3.0", - "@trustvc/w3c-context": "^2.3.0", - "@trustvc/w3c-credential-status": "^2.3.0", + "@trustvc/w3c": "^2.4.0", + "@trustvc/w3c-context": "^2.4.0", + "@trustvc/w3c-credential-status": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", - "@trustvc/w3c-vc": "^2.3.0", + "@trustvc/w3c-vc": "^2.4.0", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", @@ -6885,24 +6885,24 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.3.0.tgz", - "integrity": "sha512-F/9YT9Dvb6cD5uWE3+eV/DCvLyjpPbqxKExPGo4O2kpYKXwovEVVqf4MDTJskh3BIYzJQnQ5d6Gf8z1951uERQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.4.0.tgz", + "integrity": "sha512-EUlaBqf/PzUskUzGcJy+UztBoTLN+aAzbSdIuCkrARwJzFS9l4lPbBXW2Vfs23dks2GTgoq8aghtBm1FrOttsg==", "license": "Apache-2.0", "dependencies": { - "@trustvc/w3c-context": "^2.3.0", - "@trustvc/w3c-credential-status": "^2.3.0", + "@trustvc/w3c-context": "^2.4.0", + "@trustvc/w3c-credential-status": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", - "@trustvc/w3c-vc": "^2.3.0" + "@trustvc/w3c-vc": "^2.4.0" }, "engines": { "node": ">=18.x" } }, "node_modules/@trustvc/w3c-context": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-context/-/w3c-context-2.3.0.tgz", - "integrity": "sha512-Zm9yVJaU6PYBqJsGdl6BT1ydow7xNmchFlct0oiI2BnMaY5+5+E+98OV/YQxipy9ir0EE/UVxnKadu2mIIJXcw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-context/-/w3c-context-2.4.0.tgz", + "integrity": "sha512-OCRfqZfTyEZ2Lpd5RPBl36DwvaKv3qV1PgZOEA9hrnEtHMWxwS5iSdv+xFYK8pZIoeheFZKymfgcB5LL7UBQRw==", "license": "Apache-2.0", "dependencies": { "did-resolver": "^4.1.0", @@ -6919,12 +6919,12 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c-credential-status": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-credential-status/-/w3c-credential-status-2.3.0.tgz", - "integrity": "sha512-mPJvrYeBP9ZVJvey5GnJ1DEBLOTOxjgh3hSvZyB3dfG7nJsg5chhrnIkuBuBV2b/Qg6k5GyOIliIDB0F+/EDQw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-credential-status/-/w3c-credential-status-2.4.0.tgz", + "integrity": "sha512-fiMGtfOmq1x9e+vd89ZKqguYtmW2w2k2hxINkno+mDNHdt5CV8tfTH5EbL1fY0oPiuXM5SK+dV6iSBTIWGPFog==", "license": "Apache-2.0", "dependencies": { - "@trustvc/w3c-context": "^2.3.0", + "@trustvc/w3c-context": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", "base64url-universal": "^2.0.0", "pako": "^2.1.0" @@ -6958,9 +6958,9 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c-vc": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.3.0.tgz", - "integrity": "sha512-Heb3YYpiXJBEyhB5mEagUULrOL5ZnKSXw35EfufySY8V+rTBn5kFtWagp06x8sFiyqPPxETZBgaR3yGbusZUMQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.4.0.tgz", + "integrity": "sha512-6B7jJT0ir3v+2yDMQqSSdr1izj6nEZRZMRxuSvRxINpRV/50J8OqVFtRuuf00JGcTvpqeCNoHV7WWVrp515Gcg==", "license": "Apache-2.0", "dependencies": { "@digitalbazaar/bbs-2023-cryptosuite": "^2.0.1", @@ -6970,7 +6970,7 @@ "@digitalbazaar/ecdsa-rdfc-2019-cryptosuite": "^1.3.0", "@digitalbazaar/ecdsa-sd-2023-cryptosuite": "^3.4.1", "@mattrglobal/jsonld-signatures-bbs": "^1.2.0", - "@trustvc/w3c-credential-status": "^2.3.0", + "@trustvc/w3c-credential-status": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", "base64url-universal": "^2.0.0", "cbor": "^9.0.2", diff --git a/package.json b/package.json index 59cbd09..c710c07 100644 --- a/package.json +++ b/package.json @@ -122,11 +122,11 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.3.0", - "@trustvc/w3c-context": "^2.3.0", - "@trustvc/w3c-credential-status": "^2.3.0", + "@trustvc/w3c": "^2.4.0", + "@trustvc/w3c-context": "^2.4.0", + "@trustvc/w3c-credential-status": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", - "@trustvc/w3c-vc": "^2.3.0", + "@trustvc/w3c-vc": "^2.4.0", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", From c995f0593d04cbb3a38d8fd7d6d28a45ec760457 Mon Sep 17 00:00:00 2001 From: rongquan1 <85145303+rongquan1@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:07:58 +0800 Subject: [PATCH 5/5] fix(w3c): bump to w3c 2.4.1 + address CodeRabbit + README VP clarifications - Bump @trustvc/w3c and @trustvc/w3c-vc to ^2.4.1, which decouples credentialStatus field-format validation from _checkCredential's verify path. A malformed tokenNetwork.chainId now surfaces as a DOCUMENT_STATUS problem instead of a signature-integrity failure, so the TransferableRecords verifier reports it and the pol "chainId is missing" test passes without any trustvc workaround. - Harden the empty-credential-status VP guard (CodeRabbit): guard non-object input before the `in` operator, and require the full VP shape (type includes VerifiablePresentation AND has verifiableCredential) to skip, matching isVpDocument. - README: clarify that challenge/domain are OPTIONAL when signing a VP (a plain assertionMethod proof needs only a lifetime); add a minimal no-challenge example. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 24 +++++++++++++------ package-lock.json | 18 +++++++------- package.json | 4 ++-- .../w3cEmptyCredentialStatus/index.ts | 11 ++++++--- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 234ef11..7a9b34e 100644 --- a/README.md +++ b/README.md @@ -354,21 +354,31 @@ const derivationResult = await deriveW3C(signedDocument, { > > Credentials carrying a `TransferableRecords` status cannot be presented (they are controlled on-chain). The holder proof uses the `ecdsa-rdfc-2019` cryptosuite and reuses the holder's ECDSA (P-256) Multikey. A `challenge` produces an `authentication` proof (anti-replay); omitting it produces an `assertionMethod` proof. +Only the **lifetime** is required. `challenge` and `domain` are **optional**: pass a `challenge` for anti-replay (authentication proof); omit it for a plain `assertionMethod` proof. `domain` may only be used together with a `challenge`. + ```ts import { signW3CPresentation, verifyW3CPresentation } from '@trustvc/trustvc'; // `derivedCredential` is a signed (and, for SD suites, derived) W3C VC whose // credentialSubject.id is the holder. `holderKeyPair` is the holder's ECDSA Multikey. -const { signed, error } = await signW3CPresentation(derivedCredential, holderKeyPair, { + +// Minimal — no challenge/domain → an assertionMethod proof (only lifetime is required): +const { signed } = await signW3CPresentation(derivedCredential, holderKeyPair, { holder: 'did:key:zDnae...', - challenge: 'nonce-issued-by-the-verifier', // authentication proof (anti-replay) - domain: 'verifier.example.com', // optional; requires a challenge - expiresInSeconds: 600, // REQUIRED (or `validUntil`) + expiresInSeconds: 600, // REQUIRED (or `validUntil`) }); +const result = await verifyW3CPresentation(signed); // no challenge needed to verify it -// Verify: holder proof + holder binding + VP expiry + every embedded credential -// (signature, expiry and revocation). Pass the same challenge the verifier issued. -const result = await verifyW3CPresentation(signed, { +// With anti-replay — pass a challenge (→ authentication proof); domain is optional: +const { signed: authVp } = await signW3CPresentation(derivedCredential, holderKeyPair, { + holder: 'did:key:zDnae...', + expiresInSeconds: 600, + challenge: 'nonce-issued-by-the-verifier', // optional → authentication proof (anti-replay) + domain: 'verifier.example.com', // optional; only valid with a challenge +}); +// Verify: holder proof + holder binding + VP expiry + every embedded credential. +// For an authentication proof, pass the SAME challenge the verifier issued. +const authResult = await verifyW3CPresentation(authVp, { challenge: 'nonce-issued-by-the-verifier', domain: 'verifier.example.com', }); diff --git a/package-lock.json b/package-lock.json index 590c6c6..9747f50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,11 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.4.0", + "@trustvc/w3c": "^2.4.1", "@trustvc/w3c-context": "^2.4.0", "@trustvc/w3c-credential-status": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", - "@trustvc/w3c-vc": "^2.4.0", + "@trustvc/w3c-vc": "^2.4.1", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", @@ -6885,15 +6885,15 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.4.0.tgz", - "integrity": "sha512-EUlaBqf/PzUskUzGcJy+UztBoTLN+aAzbSdIuCkrARwJzFS9l4lPbBXW2Vfs23dks2GTgoq8aghtBm1FrOttsg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.4.1.tgz", + "integrity": "sha512-yCyztSAbialRn9VYvXrHUE4v13q4g/BHUN4XUctzF5lrN3WsHKSgUpFUflocLdp1h5fxvJTbqyVoMSh5SDLwQw==", "license": "Apache-2.0", "dependencies": { "@trustvc/w3c-context": "^2.4.0", "@trustvc/w3c-credential-status": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", - "@trustvc/w3c-vc": "^2.4.0" + "@trustvc/w3c-vc": "^2.4.1" }, "engines": { "node": ">=18.x" @@ -6958,9 +6958,9 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c-vc": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.4.0.tgz", - "integrity": "sha512-6B7jJT0ir3v+2yDMQqSSdr1izj6nEZRZMRxuSvRxINpRV/50J8OqVFtRuuf00JGcTvpqeCNoHV7WWVrp515Gcg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.4.1.tgz", + "integrity": "sha512-f/pqBu70epEYVmtWvKvlhCd2F5QkV885rCcbfa9KZq9xYbOzeBueIF7P3gSdv3ILAiMCQTxvpL7hd8I8hj+M1A==", "license": "Apache-2.0", "dependencies": { "@digitalbazaar/bbs-2023-cryptosuite": "^2.0.1", diff --git a/package.json b/package.json index c710c07..7bc387f 100644 --- a/package.json +++ b/package.json @@ -122,11 +122,11 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.4.0", + "@trustvc/w3c": "^2.4.1", "@trustvc/w3c-context": "^2.4.0", "@trustvc/w3c-credential-status": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", - "@trustvc/w3c-vc": "^2.4.0", + "@trustvc/w3c-vc": "^2.4.1", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", diff --git a/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts b/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts index 406e33a..5f6f7f0 100644 --- a/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts +++ b/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts @@ -19,15 +19,20 @@ export const w3cEmptyCredentialStatus: Verifier = { }, test: (document: unknown) => { + // Guard non-object input before any property/`in` access. + if (!document || typeof document !== 'object') { + return false; + } const doc = document as SignedVerifiableCredential & { type?: string | string[]; verifiableCredential?: unknown; }; // Verifiable Presentations have no top-level credentialStatus but are NOT signed // credentials — they are handled by the VP verifiers, so this VC-only check must skip - // them (otherwise it would report every VP as INVALID). - const types = Array.isArray(doc?.type) ? doc.type : [doc?.type]; - if (types.includes('VerifiablePresentation') || 'verifiableCredential' in (doc ?? {})) { + // them (otherwise it would report every VP as INVALID). Match isVpDocument(): require + // BOTH a VerifiablePresentation type AND a verifiableCredential (not just either). + const types = Array.isArray(doc.type) ? doc.type : [doc.type]; + if (types.includes('VerifiablePresentation') && 'verifiableCredential' in doc) { return false; } return (