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/README.md b/README.md index fc2b368..7a9b34e 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,53 @@ 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. + +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. + +// Minimal — no challenge/domain → an assertionMethod proof (only lifetime is required): +const { signed } = await signW3CPresentation(derivedCredential, holderKeyPair, { + holder: 'did:key:zDnae...', + expiresInSeconds: 600, // REQUIRED (or `validUntil`) +}); +const result = await verifyW3CPresentation(signed); // no challenge needed to verify it + +// 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', +}); +// 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 +434,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 +511,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 +594,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 +784,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 +982,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 +1200,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/package-lock.json b/package-lock.json index 9d0b104..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.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.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.1", "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.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.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.4.0", + "@trustvc/w3c-credential-status": "^2.4.0", + "@trustvc/w3c-issuer": "^2.3.0", + "@trustvc/w3c-vc": "^2.4.1" }, "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.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", @@ -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.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.2.0", - "@trustvc/w3c-issuer": "^2.2.0", + "@trustvc/w3c-context": "^2.4.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.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", "@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.4.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..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.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.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.1", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", 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.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__/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/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..a954096 --- /dev/null +++ b/src/__tests__/w3c/vpFragments.test.ts @@ -0,0 +1,216 @@ +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'; + +// 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}`; +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'); + 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 () => { + 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 = 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'), + 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('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'], + 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'); + // 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..5f6f7f0 100644 --- a/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts +++ b/src/verify/fragments/document-status/w3cEmptyCredentialStatus/index.ts @@ -19,7 +19,22 @@ export const w3cEmptyCredentialStatus: Verifier = { }, test: (document: unknown) => { - const doc = document as SignedVerifiableCredential; + // 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). 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 ( !!doc.credentialStatus === false || (Array.isArray(doc.credentialStatus) && doc.credentialStatus.length === 0) || diff --git a/src/verify/fragments/index.ts b/src/verify/fragments/index.ts index f82678d..bfc72f5 100644 --- a/src/verify/fragments/index.ts +++ b/src/verify/fragments/index.ts @@ -16,6 +16,12 @@ import { w3cCredentialStatus } from './document-status/w3cCredentialStatus'; import { w3cIssuerIdentity } from './issuer-identity/w3cIssuerIdentity'; import { w3cEmptyCredentialStatus } from './document-status/w3cEmptyCredentialStatus'; +export { + w3cVpCredentialStatus, + w3cVpIssuerIdentity, + w3cVpSignatureIntegrity, +} from './presentation/w3cVpVerifier'; + export { TRANSFERABLE_RECORDS_TYPE, credentialStatusTransferableRecordVerifier, diff --git a/src/verify/fragments/presentation/w3cVpVerifier.ts b/src/verify/fragments/presentation/w3cVpVerifier.ts new file mode 100644 index 0000000..882ba93 --- /dev/null +++ b/src/verify/fragments/presentation/w3cVpVerifier.ts @@ -0,0 +1,341 @@ +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'; + +// 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 => { + 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; + +// 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. +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 subjects = getSubjects(credentials[i]); + if (subjects.length === 0) { + return `credential at index ${i} has no credentialSubject, so it cannot be bound to the holder.`; + } + // 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; +}; + +// 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 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( + 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); + 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 issuerIds = credentials.map((c) => readId(c.issuer)); + + // 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: + 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)), + ); + 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 }); +};