diff --git a/.gitignore b/.gitignore index b920894..aad6156 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,25 @@ -# Generated files from CLI commands -keypair.json -didKeyPairs.json -wellknown.json -credentialStatus.json -signed_vc.json -raw_vc.json +# Generated files from CLI commands, dropped in the working directory by their default +# output paths. Anchored to the repo root: unanchored, these names match at ANY depth and +# would also swallow identically-named files elsewhere in the tree. +/keypair.json +/didKeyPairs.json +/wellknown.json +/credentialStatus.json +/signed_vc.json +/signed_vp.json +/raw_vc.json + +# Verifiable Presentation manual-test fixtures — generated, deliberately not committed. +# They carry throwaway private keys, and every credential and presentation is bound to the +# one holder key pair, so the set is only coherent as a whole; there is no regenerating +# part of it. Only generate-fixtures.cjs and README.md are tracked. Recreate with: +# node tests/fixtures/vp/generate-fixtures.cjs +# npx prettier --write "tests/fixtures/vp/**/*.json" +tests/fixtures/vp/*.json +tests/fixtures/vp/credentials/ +tests/fixtures/vp/invalid-credentials/ +tests/fixtures/vp/invalid-keypairs/ +tests/fixtures/vp/presentations/ # Dependencies node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7e4ef5a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,170 @@ +# 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-cli` is the **interactive command-line front end** to +[`@trustvc/trustvc`](https://github.com/TrustVC/trustvc). It owns no cryptography and no +chain logic — every command is prompts + file I/O around a library call. When something is +wrong with a signature, a proof or a verification result, the bug is almost always upstream +in `trustvc` (or `@trustvc/w3c-vc` below it), not here. + +```text +src/main.ts yargs entry point; auto-registers every command under src/commands/ +src/commands/ one file per command — see "Adding a command" +src/utils/ file I/O, networks, wallets, formatting, prompts (barrel: utils/index.ts) +src/types.ts shared input types for command handlers +tests/fixtures/ documents to run commands against +``` + +Each command file exports `command`, `describe` and `handler`; `main.ts` picks them up with +`commandDir(..., { recurse: true })`. A nested folder becomes a **command group** only if it +has an `index.ts` declaring one (`wallet `, `document-store `); otherwise the +files register flat, which is why `src/commands/w3c/sign.ts` is `trustvc w3c-sign`. + +## Commands + +Node **≥ 22** — enforced at runtime in `main.ts`, and the install fails below it. +Use `nvm use 22`. + +```bash +npm run build # tsup -> dist/ (run before testing the real CLI) +node dist/main.js # run a command; `npm link` if you want a global `trustvc` +npm test # vitest --run +npm run lint # eslint, --max-warnings=0 +npm run format:check # prettier, same set CI checks + +npx vitest --run tests/commands/w3c/vp-sign.test.ts # one file +npx vitest --run -t "does not match the holder" # one test +``` + +**Before "done": `npm run lint` AND `npm run format:check`.** CI runs lint → format:check → +test → build, and `lint` is `--max-warnings=0`, so a single warning is a red build. + +**`tsc --noEmit` is NOT a gate and will never be clean** — `node_modules/@tradetrust-tt/ +token-registry-v4` ships `.ts` sources that don't compile against ethers v6, and several +`src/commands/**` files have pre-existing ethers v5/v6 signature mismatches. To check your +own work, filter: `npx tsc --noEmit -p tsconfig.json 2>&1 | grep `. + +**Some tests hit the network** (did:web resolution, StatusList fetches, RPC). They're real +integration checks — don't mock them away. + +## Testing an interactive command for real + +Every command is prompt-driven, so piping stdin does **not** work — inquirer needs a TTY and +exits with `User force closed the prompt`. Use `expect`: + +```tcl +set timeout 90 +spawn node dist/main.js vp-sign +expect "path(s) to individual JSON file" +send "tests/fixtures/vp/credentials\r" +expect "key-pair JSON file" +send "tests/fixtures/vp/didKeyPairs.json\r" +expect eof +``` + +Strip the ANSI redraw noise from the output: `| perl -pe 's/\e\[[0-9;?]*[a-zA-Z]//g; s/\r//g'`. +Keep answers short — long absolute paths make the prompt line-wrap, and an `expect` pattern +that straddles the wrap never matches, which looks like a hang. + +## Verifiable Presentations + +`vp-sign` creates and signs a presentation; `verify` verifies it, along with every other +document type. There is deliberately **no `vp-verify`** — one verify command for everything. + +- **The credentials prompt takes a directory**, a file, or comma-separated files. A directory + presents **every file in it**, unfiltered: anything that isn't a presentable credential is + reported by the signing step, which names the file (`nameFailingCredential` rewrites + trustvc's "credential at index 2" using the paths). Dot-files and sub-directories are + skipped — OS noise, never a credential. +- **The holder DID is not prompted.** trustvc enforces that the signing key's DID *is* the + holder, so any other answer could only fail. It's read from the key pair and printed. A key + pair with no `controller` (the bare `keypair.json` from `key-pair-generation`) is rejected + up front — presentations need the `didKeyPairs.json` that `did-web` writes. +- **No challenge support.** An anti-replay challenge can only be checked by the verifier that + issued it, and `verify` has no way to take one, so every presentation gets an + `assertionMethod` proof. +- A valid presentation prints one extra line — `N embedded credentials verified.` — because + the three fragment lines read identically over one credential or five. Failures keep the + plain three-line output. + +`verify` routes on shape via `isVerifiablePresentation()` (type includes +`VerifiablePresentation` **and** a `verifiableCredential` field). It deliberately ignores +`proof`, so an unsigned presentation is routed in and reported INVALID rather than skipped. + +## Gotchas (hard-won — add to this list) + +- **`.gitignore` entries for command output are anchored (`/didKeyPairs.json`) on purpose.** + Unanchored, those names match at *any* depth and silently swallow the identically-named + files under `tests/fixtures/`. Adding a bare `signed_vp.json` would quietly drop a fixture + from the next commit. If you add a command that writes a default filename, anchor it. +- **`tests/commands/verify.test.ts` walks `tests/fixtures/verify/` recursively** and verifies + every JSON it finds. Anything you drop in there becomes a test case. Never put a + presentation there — a VP always carries an expiry and would start failing on its own. +- **VP tests mint their own presentations at runtime** for the same reason. Nothing + automated reads `tests/fixtures/vp/`. +- **`tests/fixtures/vp/` is generated and gitignored** — only `generate-fixtures.cjs` and + `README.md` are tracked, so the folder is empty on a fresh clone. Run + `node tests/fixtures/vp/generate-fixtures.cjs` before testing a command by hand. The set is + all-or-nothing: every credential and presentation is bound to the one holder key pair it + mints, so you cannot regenerate part of it, and each run produces a new holder DID. +- **1–3 `bbs2023`/`ecdsa` fixtures in `verify.test.ts` time out under full-suite load.** + Pre-existing: BBS verification is slow and vitest's default timeout is 5s. It varies run to + run. `npx vitest --run tests/commands/verify.test.ts -t bbs2023` passes in isolation. +- **`main.ts` sets `process.noDeprecation = true`.** Transitive deps (`node-fetch@2` → + `whatwg-url` → `tr46`, and `jsonld@4` → `request` → `tough-cookie`) still require Node's + deprecated `punycode`, and the warning printed mid-prompt garbled the interactive display. +- **`@trustvc/trustvc` does not export the presentation types.** It exports + `signW3CPresentation`/`verifyW3CPresentation` but not `SignedVerifiablePresentation`, so + `src/types.ts` derives it from the function signature. Delete that alias and import + directly once the library exports it. +- **`w3c-sign` output changed in trustvc 2.15.1.** `@trustvc/w3c-vc` 2.4.2 made + `/credentialStatus`, `/validUntil` and `/expirationDate` mandatory pointers, so a holder can + no longer selectively disclose a revocation entry or an expiry away. Credentials signed by + older versions are still strippable and must be reissued. Regenerate the VP fixtures after + bumping trustvc — they're signed artifacts and keep whatever rules produced them. +- **`Cannot read properties of null (reading 'verificationMethod')`** always means a DID could + not be resolved — nearly always a did:web whose document isn't published yet. + +## Adding a command + +1. Create `src/commands//.ts` exporting `command`, `describe`, `handler`. +2. Keep the prompt flow in an exported `promptForInputs()` and the work in a second exported + function. Tests mock `@inquirer/prompts` and call the two separately — a handler that does + both inline can't be tested. +3. Add the input type to `src/types.ts`. +4. Wrap the handler body in try/catch and report via `signale.error`. +5. Document it in `README.md`: the Quick Start block, the command table, and a + `
` section in the Detailed Command Reference. + +## Conventions + +- Conventional commits (semantic-release drives versioning and the CHANGELOG). +- Prompts use `@inquirer/prompts`; user-facing output goes through `signale`, never + `console.log` (except deliberate blank spacer lines). +- Read and write files through `src/utils/file-io.ts` — it handles path validation and + parent-directory creation. +- Don't add a prompt whose only correct answer is the default. See the holder-DID note 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 command's prompts, name, or output shape** — including anything the README documents. +- **A gotcha you just spent time on** — new gotchas are the highest-value additions. +- **The dependency on `@trustvc/trustvc`**, when its behaviour changes what commands produce. +- **Tooling, CI gates, or the Node requirement** — keep the Commands section runnable. + +Small-and-true beats big-and-stale; delete guidance that no longer holds. Keep it +repo-specific: anything true of every Node CLI doesn't belong here. + +**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 c846ef5..5e27064 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A comprehensive command-line interface for managing W3C Verifiable Credentials, - ✅ **Key Pair Generation**: Generate cryptographic key pairs with Multikey format - ✅ **DID Management**: Create and manage did:web identifiers - ✅ **W3C Verifiable Credentials**: Sign, verify and manage W3C verifiable credentials +- ✅ **W3C Verifiable Presentations**: Present credentials as a holder and verify presentations - ✅ **OpenAttestation**: Sign, verify, wrap/unwrap, and encrypt/decrypt OpenAttestation v2/v3 documents - ✅ **Token Registry**: Mint tokens to blockchain-based token registries - ✅ **Document Store**: Deploy and manage document store contracts @@ -76,7 +77,10 @@ trustvc did-web # Sign a W3C verifiable credential trustvc w3c-sign -# Verify a W3C document +# Present credential(s) you hold as a Verifiable Presentation +trustvc vp-sign + +# Verify a W3C credential or presentation trustvc verify # Create a credential status list @@ -202,6 +206,15 @@ trustvc title-escrow reject-transfer-owner-holder - **Credential Status**: Provides commands to create and update W3C credential status lists for managing credential revocation and suspension. +- **Verifiable Presentations**: Uses `signW3CPresentation` to let a holder bundle and present their own credentials; presentations are verified by the same `verify` command as every other document. TrustVC enforces the presentation policies, so the CLI cannot disable them: + - **Holder binding** — the signing key's DID must equal the presentation `holder` and every `credentialSubject.id`. The issuer is independent: a credential issued by another party is fine. + - **Mandatory expiry** — every presentation carries a `validUntil`; `vp-sign` always asks for one. + - **Full disclosure** — a selective-disclosure credential that has not been derived is auto-derived. + - **v2 envelope** — the presentation envelope is always VC Data Model 2.0; embedded credentials keep their own version. + - Credentials with a `TransferableRecords` status cannot be presented — ownership of a transferable record lives on-chain. + + The holder proof is an `assertionMethod` proof: the CLI does not issue challenges, since an anti-replay nonce can only be checked by the verifier that issued it. + ### OpenAttestation - **Document Signing**: Uses `signOA` to cryptographically sign OpenAttestation v2 and v3 documents with private keys. @@ -233,7 +246,8 @@ trustvc title-escrow reject-transfer-owner-holder | **W3C Credentials** | [`key-pair-generation`](#key-pair-generation) | Generate cryptographic key pairs (ECDSA-SD-2023, BBS-2023) | | | [`did-web`](#did-web) | Create did:web identifiers from key pairs | | | [`w3c-sign`](#w3c-sign) | Sign W3C verifiable credentials | -| | [`verify`](#verify) | Verify W3C verifiable credentials | +| | [`verify`](#verify) | Verify W3C verifiable credentials and presentations | +| | [`vp-sign`](#vp-sign) | Create and sign a W3C verifiable presentation | | | [`credential-status-create`](#credential-status-create) | Create credential status lists | | | [`credential-status-update`](#credential-status-update) | Update credential status (revoke/suspend) | | **OpenAttestation** | [`oa-sign`](#oa-sign) | Sign OpenAttestation v2/v3 documents | @@ -442,9 +456,50 @@ Verifies the document integrity, status, and issuer identity. **Supported Formats:** - W3C Verifiable Credential +- W3C Verifiable Presentation - OpenAttestation v2 - OpenAttestation v3 +**Verifiable Presentations:** +For a presentation, the three results cover the presentation as a whole — `DOCUMENT_INTEGRITY` is the holder proof plus holder binding (an unsigned presentation is invalid), `DOCUMENT_STATUS` is the presentation expiry plus each embedded credential's revocation, and `ISSUER_IDENTITY` resolves each embedded issuer. Freshness of an `authentication` proof (challenge/domain) is not checked — only the verifier that issued the challenge can do that. + +A valid presentation adds one line stating how many credentials it covered, since the three results read identically over one credential or five: + +```text +✔ success DOCUMENT_INTEGRITY: VALID +✔ success DOCUMENT_STATUS: VALID +✔ success ISSUER_IDENTITY: VALID +ℹ info 2 embedded credentials verified. +``` + +
+ +
+

vp-sign

+ +Creates **and** signs a W3C Verifiable Presentation from one or more signed credentials, so a holder can present credentials they own. + +**Usage:** + +```sh +trustvc vp-sign +``` + +**Interactive Prompts:** + +- A directory of signed verifiable credentials, **or** the path(s) to individual JSON files (comma-separated). Given a directory, **every file in it is presented** — nothing is filtered by extension or content, so anything that is not a presentable credential is reported by the signing step, naming the file it came from. Sub-directories and dot-files (`.DS_Store` and the like) are skipped. +- Path to the holder did key-pair JSON file (defaults to `./didKeyPairs.json`). The holder DID is taken from this file and is **not** asked for — trustvc requires the signing key's DID to *be* the holder, so there is nothing to choose. A key pair with no DID (the bare `keypair.json` from [`key-pair-generation`](#key-pair-generation)) is rejected up front; use the `didKeyPairs.json` that [`did-web`](#did-web) writes. +- Presentation expiry — either a lifetime in seconds or an explicit `validUntil` timestamp +- Output directory + +**Output:** +Creates `signed_vp.json`, holding an `assertionMethod` holder proof. Verify it with [`verify`](#verify). + +**Requirements:** + +- The holder key pair must be an ECDSA (P-256) Multikey bound to a DID — the `didKeyPairs.json` produced by [`did-web`](#did-web) is one. +- Every credential must be about the holder: each `credentialSubject.id` must equal the holder DID. Credentials with a `TransferableRecords` status cannot be presented. +
@@ -1438,6 +1493,7 @@ src/commands/ ├── did.ts # Generate DID ├── key-pair.ts # Generate key pairs ├── sign.ts # Sign W3C credentials + ├── vp-sign.ts # Create and sign a verifiable presentation └── credentialStatus/ ├── create.ts # Create credential status list └── update.ts # Update credential status list diff --git a/package-lock.json b/package-lock.json index 737cc65..6f96609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@trustvc/trustvc-cli", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trustvc/trustvc-cli", - "version": "1.0.1", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "^2.14.1", + "@trustvc/trustvc": "^2.15.1", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "dotenv": "^16.0.0", @@ -899,13 +899,13 @@ } }, "node_modules/@digitalbazaar/di-sd-primitives/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==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-4.4.0.tgz", + "integrity": "sha512-ODhCGmElUPmR3IR+KZmBNkRAFyjJ01rxvk2E+/qQ2h2EGPJH5k6bz3N24ympGc5+i4YCGk/ipIpmkwc0+iSmRg==", "license": "BSD-3-Clause", "dependencies": { - "ky": "^1.14.2", - "undici": "^6.23.0" + "ky": "^1.14.3", + "undici": "^6.28.0" }, "engines": { "node": ">=18.0" @@ -972,6 +972,81 @@ "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.4.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-4.4.0.tgz", + "integrity": "sha512-ODhCGmElUPmR3IR+KZmBNkRAFyjJ01rxvk2E+/qQ2h2EGPJH5k6bz3N24ympGc5+i4YCGk/ipIpmkwc0+iSmRg==", + "license": "BSD-3-Clause", + "dependencies": { + "ky": "^1.14.3", + "undici": "^6.28.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-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", @@ -2937,12 +3012,12 @@ } }, "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": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.3.0.tgz", + "integrity": "sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==", "license": "MIT", "dependencies": { - "@noble/hashes": "2.2.0" + "@noble/hashes": "2.3.0" }, "engines": { "node": ">= 20.19.0" @@ -2952,9 +3027,9 @@ } }, "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==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -5360,9 +5435,9 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/trustvc": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.14.1.tgz", - "integrity": "sha512-vGR6/s8nn69sl3Ntr/rE8+kg64RuqsRN2AeB0yeNOAUwTWEmQRk16cvB4g1xW7+CsLc5yI5sRuAqeAmbhHdV0g==", + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/@trustvc/trustvc/-/trustvc-2.15.1.tgz", + "integrity": "sha512-mCdI2sXYQcgarHQ0VtrXnVyqNBVjxBqNvKtD3PEL+cF85114wB2XOPfwu+jf87eNUchzlvvNhL0YrrfXCra+jw==", "license": "Apache-2.0", "dependencies": { "@tradetrust-tt/dnsprove": "^2.18.0", @@ -5372,11 +5447,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.2", + "@trustvc/w3c-context": "^2.4.0", + "@trustvc/w3c-credential-status": "^2.4.0", + "@trustvc/w3c-issuer": "^2.3.0", + "@trustvc/w3c-vc": "^2.4.2", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", @@ -5478,24 +5553,24 @@ } }, "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.2", + "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.4.2.tgz", + "integrity": "sha512-ldrlp3Mg3Jr+JOME4R5mhqubmLg5VtuQPbDh1oh8KGAX93zX/ZCGgLgCE4KI6CrZsEv5HYCA2tTSBxMjKzU6eg==", "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.2" }, "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", @@ -5512,13 +5587,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" }, @@ -5527,9 +5602,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", @@ -5561,19 +5636,20 @@ } }, "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.2", + "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.4.2.tgz", + "integrity": "sha512-XLyiMjIoaviPfp0CT3rodUPS8zD2KCTIR6lPFfAPz7/JK0KlgdUW8O12HzgN1ZGw4hqIKfZiV8Wy1AwG75bgvQ==", "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", @@ -6620,9 +6696,9 @@ } }, "node_modules/cborg": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cborg/-/cborg-5.1.1.tgz", - "integrity": "sha512-BDbSRIp6XrQXkTc7g+DN0RB9RrDPTUfals2ecWUlt3juPLjbAvy/V72mJcXY0Ehu0Dq/3WpNCOCT68HUTbW+lw==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-5.1.11.tgz", + "integrity": "sha512-oc6Pzg/gkTobxHZNgMmny+G99dOeBMbAmnGHcZWMKtolxZBIVwfi0Pj0khxEtNU8HMFdbT5sK0HmtgecDUPP0A==", "license": "Apache-2.0", "bin": { "cborg": "lib/bin.js" @@ -10697,13 +10773,13 @@ "license": "ISC" }, "node_modules/jsonld-signatures/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==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@digitalbazaar/http-client/-/http-client-4.4.0.tgz", + "integrity": "sha512-ODhCGmElUPmR3IR+KZmBNkRAFyjJ01rxvk2E+/qQ2h2EGPJH5k6bz3N24ympGc5+i4YCGk/ipIpmkwc0+iSmRg==", "license": "BSD-3-Clause", "dependencies": { - "ky": "^1.14.2", - "undici": "^6.23.0" + "ky": "^1.14.3", + "undici": "^6.28.0" }, "engines": { "node": ">=18.0" @@ -16837,6 +16913,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": { @@ -16865,9 +16942,9 @@ } }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "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" diff --git a/package.json b/package.json index 34257d5..7a713ef 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@inquirer/prompts": "^5.3.8", - "@trustvc/trustvc": "^2.14.1", + "@trustvc/trustvc": "^2.15.1", "@types/yargs": "^17.0.32", "chalk": "^4.1.2", "dotenv": "^16.0.0", diff --git a/src/commands/verify.ts b/src/commands/verify.ts index 0220349..65c53e3 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -6,6 +6,7 @@ import { CaptureConsoleWarnAsync, CaptureConsoleWarn, promptNetworkSelection, + isVerifiablePresentation, } from '../utils'; import { getChainId, @@ -57,22 +58,52 @@ export const promptQuestions = async (): Promise => export const verify = async (signedVC: SignedVerifiableCredential) => { const isOpenAttestation = isWrappedV2Document(signedVC) || isWrappedV3Document(signedVC); + const isPresentation = isVerifiablePresentation(signedVC); - const { result, warnings } = isOpenAttestation - ? { result: await verifyOpenAttestationDocument(signedVC), warnings: null } - : await verifyW3CDocument(signedVC); + let result: VerificationFragment[]; + let warnings: unknown[][] | null = null; + + if (isPresentation) { + result = await verifyPresentationDocument(signedVC); + } else if (isOpenAttestation) { + result = await verifyOpenAttestationDocument(signedVC); + } else { + ({ result, warnings } = await verifyW3CDocument(signedVC)); + } if (warnings) { handleExpiredCredentialWarning(warnings); } - logResultStatus(getResultFromFragment(FragmentType.DOCUMENT_INTEGRITY, result)); - logResultStatus(getResultFromFragment(FragmentType.DOCUMENT_STATUS, result)); - logResultStatus(getResultFromFragment(FragmentType.ISSUER_IDENTITY, result)); + const fragments = [ + getResultFromFragment(FragmentType.DOCUMENT_INTEGRITY, result), + getResultFromFragment(FragmentType.DOCUMENT_STATUS, result), + getResultFromFragment(FragmentType.ISSUER_IDENTITY, result), + ]; + fragments.forEach(logResultStatus); + + if (isPresentation && fragments.every((fragment) => fragment.status === 'VALID')) { + logPresentationCredentialCount(fragments[0]); + } }; // ==== Helper Functions ==== +/** + * Verifies a Verifiable Presentation through the unified fragment pipeline: the holder + * proof and holder binding (DOCUMENT_INTEGRITY), the presentation expiry and every + * embedded credential's revocation (DOCUMENT_STATUS), and every embedded issuer + * (ISSUER_IDENTITY). Freshness of an authentication proof (challenge / domain) is out of + * scope — only the verifier that issued the challenge can check it. + */ +const verifyPresentationDocument = async ( + presentation: SignedVerifiableCredential, +): Promise => { + signale.info('Verifying W3C Verifiable Presentation...'); + + return await verifyDocument(presentation); +}; + const verifyW3CDocument = async ( signedVC: SignedVerifiableCredential, ): Promise<{ result: VerificationFragment[]; warnings: unknown[][] }> => { @@ -142,6 +173,22 @@ const checkExpiration = (signedVC: WrappedOrSignedOpenAttestationDocument) => { } }; +/** + * How many credentials a valid presentation actually covered. The three aggregate lines read + * identically whether one credential was checked or five, so state the count — it is the one + * thing they cannot convey. Taken from the integrity fragment, which already verified each + * embedded credential. + */ +export const logPresentationCredentialCount = ( + integrityFragment: VerificationFragmentWithData, +): void => { + const credentialResults = (integrityFragment?.data as { credentialResults?: unknown[] }) + ?.credentialResults; + if (!credentialResults?.length) return; + const count = credentialResults.length; + signale.info(`${count} embedded credential${count === 1 ? '' : 's'} verified.`); +}; + export const getResultFromFragment = ( fragmentType: FragmentType, resultFragments: VerificationFragment[], diff --git a/src/commands/w3c/vp-sign.ts b/src/commands/w3c/vp-sign.ts new file mode 100644 index 0000000..58c0d38 --- /dev/null +++ b/src/commands/w3c/vp-sign.ts @@ -0,0 +1,228 @@ +import { input, number, select } from '@inquirer/prompts'; +import { PrivateKeyPair, SignedVerifiableCredential, signW3CPresentation } from '@trustvc/trustvc'; +import fs from 'fs'; +import path from 'path'; +import signale from 'signale'; +import { VpLifetime, VpSignInput } from '../../types'; +import { + isDir, + isDirectoryValid, + isFile, + readJsonFile, + validateInputFileExists, + writeFile, +} from '../../utils'; + +export const command = 'vp-sign'; +export const describe = + 'Create and sign a W3C Verifiable Presentation from signed Verifiable Credential(s)'; + +export const handler = async () => { + try { + const answers = await promptForInputs(); + if (!answers) return; + + await signPresentation(answers); + } catch (err: unknown) { + signale.error(`${err instanceof Error ? err.message : String(err)}`); + } +}; + +/** + * The holder DID a key pair belongs to: its `controller`, or the DID part of its + * verification-method `id` (`did:key:z...#z...` -> `did:key:z...`). + */ +export const getHolderDidFromKeyPair = (keyPair: PrivateKeyPair): string | undefined => + keyPair?.controller || keyPair?.id?.split('#')[0]; + +const splitPaths = (value: string): string[] => + value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry !== ''); + +/** + * The credentials to present: a directory (every file in it is taken as a credential), a + * single file, or several files comma-separated. + * + * A directory is NOT filtered by extension or content — whatever is in it is presented, and + * anything that is not a valid credential is reported by the signing step rather than being + * silently dropped. Sub-directories and dot-files (`.DS_Store` and friends) are skipped: + * they are OS noise, never something a user put there to present. + */ +export const resolveCredentialPaths = (value: string): string[] => { + const entries = splitPaths(value); + if (entries.length !== 1 || !isDir(entries[0])) return entries; + + const directory = entries[0]; + const files = fs + .readdirSync(directory) + .filter((name) => !name.startsWith('.')) + .map((name) => path.join(directory, name)) + .filter((entry) => isFile(entry)) + .sort(); + + if (files.length === 0) throw new Error(`No files found in directory: ${directory}`); + return files; +}; + +export const promptForInputs = async (): Promise => { + const credentialPathInput = await input({ + message: + 'Please enter a directory of signed Verifiable Credentials, or the path(s) to individual JSON file(s) (comma-separated):', + required: true, + validate: (value: string) => { + const entries = splitPaths(value); + if (entries.length === 0) return 'A directory or at least one credential file is required'; + // A single directory is accepted as-is; every file inside it will be presented. + if (entries.length === 1 && isDir(entries[0])) return true; + for (const entry of entries) { + const result = validateInputFileExists(entry); + if (result !== true) return result; + } + return true; + }, + }); + + const credentialPaths = resolveCredentialPaths(credentialPathInput); + if (credentialPaths.length > 1) { + signale.info(`Presenting ${credentialPaths.length} credentials:`); + credentialPaths.forEach((entry, index) => signale.info(` [${index}] ${entry}`)); + } + + const credentials: SignedVerifiableCredential[] = credentialPaths.map((entry) => + readJsonFile(entry, 'Verifiable Credential JSON'), + ); + + const pathToKeypairFile = await input({ + message: 'Please enter the path to the holder did key-pair JSON file:', + required: true, + default: './didKeyPairs.json', + validate: (value: string) => validateInputFileExists(value), + }); + + const keyPairData: PrivateKeyPair = readJsonFile(pathToKeypairFile, 'key pair'); + + // The holder is NOT asked for: trustvc enforces that the signing key's DID *is* the holder, + // so any other answer could only ever fail. A key pair with no DID cannot sign a + // presentation at all, so say that here rather than let the library report a missing + // "controller" further down. + const holder = getHolderDidFromKeyPair(keyPairData); + if (!holder) { + throw new Error( + `The key pair at ${pathToKeypairFile} is not bound to a DID (no "controller"). ` + + 'Create one with "trustvc did-web" and use the didKeyPairs.json it writes.', + ); + } + signale.info(`Holder: ${holder}`); + + const lifetime = await promptForLifetime(); + + const outputPath = await input({ + message: 'Enter a directory to save the signed Verifiable Presentation (optional):', + required: false, + default: '.', + }); + + if (!isDirectoryValid(outputPath)) throw new Error('Output path is not valid'); + + return { + credentials, + credentialPaths, + keyPairData, + holder, + lifetime, + outputPath, + }; +}; + +/** A VP lifetime is mandatory at the trustvc layer — it cannot be left to a default. */ +export const promptForLifetime = async (): Promise => { + const mode = await select({ + message: 'How should the presentation expiry be set?', + choices: [ + { + name: 'Expires in a number of seconds from now', + value: 'expiresInSeconds', + description: 'Stamp validUntil relative to the current time', + }, + { + name: 'Explicit validUntil timestamp', + value: 'validUntil', + description: 'Provide an absolute ISO 8601 expiry', + }, + ], + default: 'expiresInSeconds', + }); + + if (mode === 'validUntil') { + const validUntil = await input({ + message: 'Enter the validUntil timestamp (ISO 8601, e.g. 2026-01-01T00:00:00Z):', + required: true, + validate: (value: string) => { + const parsed = new Date(value.trim()); + if (Number.isNaN(parsed.getTime())) return 'Enter a valid ISO 8601 timestamp'; + if (parsed.getTime() <= Date.now()) return 'validUntil must be in the future'; + return true; + }, + }); + return { validUntil: validUntil.trim() }; + } + + const expiresInSeconds = await number({ + message: 'Enter the presentation lifetime in seconds:', + default: 600, + required: true, + min: 1, + }); + + return { expiresInSeconds: expiresInSeconds as number }; +}; + +/** + * trustvc reports a rejected credential by its position ("credential at index 2 is not + * valid: ..."). With a whole directory presented at once that position means nothing on its + * own, so name the file it came from. + */ +export const nameFailingCredential = (error: string, credentialPaths?: string[]): string => { + if (!credentialPaths?.length) return error; + return error.replace(/credential at index (\d+)/g, (match, index) => { + const source = credentialPaths[Number(index)]; + return source ? `${match} (${source})` : match; + }); +}; + +export const signPresentation = async ({ + credentials, + credentialPaths, + keyPairData, + holder, + lifetime, + outputPath, +}: VpSignInput): Promise => { + // A single credential is presented as-is; multiple are presented as an array. + const verifiableCredential = credentials.length === 1 ? credentials[0] : credentials; + + // No `challenge` is passed, so the holder proof is always an assertionMethod proof: + // an authentication (anti-replay) proof can only be verified against the challenge the + // verifier issued, which `trustvc verify` has no way to take. + const { signed, error } = await signW3CPresentation(verifiableCredential, keyPairData, { + holder, + ...lifetime, + }); + + if (!signed) { + signale.error( + error + ? nameFailingCredential(error, credentialPaths) + : 'Failed to sign the Verifiable Presentation', + ); + return; + } + + signale.success('Verifiable Presentation signed successfully'); + + const signedVpPath = path.join(outputPath, 'signed_vp.json'); + writeFile(signedVpPath, signed, true); + signale.success(`Signed verifiable presentation saved to: ${signedVpPath}`); +}; diff --git a/src/main.ts b/src/main.ts index 5c1b61a..791520f 100755 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,12 @@ if (major < 22) { process.exit(1); } +// Transitive dependencies (node-fetch@2 -> whatwg-url -> tr46, and jsonld@4 -> request -> +// tough-cookie) still require Node's deprecated `punycode` module. Node prints that warning +// to stderr on first load, which lands in the middle of an interactive prompt and garbles it. +// Only DeprecationWarnings are silenced; every other warning and error still surfaces. +process.noDeprecation = true; + import path from 'path'; import yargs from 'yargs'; import signale from 'signale'; diff --git a/src/types.ts b/src/types.ts index 9b58676..e614cc5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,11 @@ -import { credentialStatus, issuer, RawVerifiableCredential } from '@trustvc/trustvc'; +import { + credentialStatus, + issuer, + PrivateKeyPair, + RawVerifiableCredential, + signW3CPresentation, + SignedVerifiableCredential, +} from '@trustvc/trustvc'; import { PrivateKeyOption, GasPriceScale, @@ -15,6 +22,28 @@ export type SignInput = { pathToSignedVC: string; }; +// @trustvc/trustvc does not (yet) re-export the presentation types, so they are derived +// from the signature of the function the CLI calls. +export type SignedVerifiablePresentation = NonNullable< + Awaited>['signed'] +>; + +/** + * The VP lifetime is mandatory at the trustvc layer: exactly one of these is passed + * through to signW3CPresentation. + */ +export type VpLifetime = { expiresInSeconds: number } | { validUntil: string }; + +export type VpSignInput = { + credentials: SignedVerifiableCredential[]; + /** Where each credential was read from, in the same order — used to name a rejected one. */ + credentialPaths?: string[]; + keyPairData: PrivateKeyPair; + holder: string; + lifetime: VpLifetime; + outputPath: string; +}; + export enum WrapMode { Individual = 'individual', Batch = 'batch', diff --git a/src/utils/document-verification.ts b/src/utils/document-verification.ts index c7f8de3..fafed11 100644 --- a/src/utils/document-verification.ts +++ b/src/utils/document-verification.ts @@ -9,6 +9,25 @@ import { vc, } from '@trustvc/trustvc'; +/** + * Detects a W3C Verifiable Presentation by shape only: `type` includes + * `VerifiablePresentation` and the document carries a `verifiableCredential` field. + * Deliberately does NOT look at `proof` — an unsigned presentation is still a + * presentation, and must be routed to the VP path so it can be reported as invalid. + * + * @param document - The document to inspect + * @returns true when the document is a Verifiable Presentation + */ +export const isVerifiablePresentation = (document: unknown): boolean => { + if (!document || typeof document !== 'object') return false; + const { type, verifiableCredential } = document as { + type?: string | string[]; + verifiableCredential?: unknown; + }; + const types = Array.isArray(type) ? type : [type]; + return types.includes('VerifiablePresentation') && verifiableCredential !== undefined; +}; + /** * Verifies the signature of a document (W3C or OpenAttestation). * Throws an error if the document signature is invalid. diff --git a/tests/commands/w3c/vp-sign.test.ts b/tests/commands/w3c/vp-sign.test.ts new file mode 100644 index 0000000..770ea50 --- /dev/null +++ b/tests/commands/w3c/vp-sign.test.ts @@ -0,0 +1,437 @@ +import * as prompts from '@inquirer/prompts'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { + getHolderDidFromKeyPair, + nameFailingCredential, + promptForInputs, + resolveCredentialPaths, + signPresentation, +} from '../../../src/commands/w3c/vp-sign'; + +vi.mock('@inquirer/prompts'); + +vi.mock('signale', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, + Signale: vi.fn().mockImplementation(() => ({ + await: vi.fn(), + success: vi.fn(), + })), +})); + +vi.mock('../../../src/utils', async () => { + const actual = await vi.importActual('../../../src/utils'); + return { + ...actual, + readJsonFile: vi.fn(), + isDirectoryValid: vi.fn(), + validateInputFileExists: vi.fn(), + writeFile: vi.fn(), + }; +}); + +vi.mock('@trustvc/trustvc', async () => { + const actual = await vi.importActual('@trustvc/trustvc'); + return { + ...actual, + signW3CPresentation: vi.fn(), + }; +}); + +const HOLDER_DID = 'did:key:zDnaemDNwi4G5eTzGfRooFFu5Kns3be6yfyVNtiaMhWkZbwtc'; +const KEY_PAIR = { + id: `${HOLDER_DID}#zDnaemDNwi4G5eTzGfRooFFu5Kns3be6yfyVNtiaMhWkZbwtc`, + controller: HOLDER_DID, + type: 'Multikey', +}; + +describe('vp-sign', () => { + const tempDirs: string[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetAllMocks(); + }); + + afterAll(() => { + tempDirs.forEach((dir) => fs.rmSync(dir, { recursive: true, force: true })); + }); + + describe('getHolderDidFromKeyPair', () => { + it('should use the controller when present', () => { + expect(getHolderDidFromKeyPair(KEY_PAIR as never)).toBe(HOLDER_DID); + }); + + it('should fall back to the DID part of the verification method id', () => { + expect(getHolderDidFromKeyPair({ id: `${HOLDER_DID}#multikey-1` } as never)).toBe(HOLDER_DID); + }); + + it('should return undefined when the key pair carries no DID', () => { + expect(getHolderDidFromKeyPair({} as never)).toBeUndefined(); + }); + }); + + describe('resolveCredentialPaths', () => { + let dir: string; + + const makeDir = () => { + const created = fs.mkdtempSync(path.join(os.tmpdir(), 'trustvc-vp-dir-')); + tempDirs.push(created); + return created; + }; + + it('passes a single file path through unchanged', () => { + expect(resolveCredentialPaths('./signed_vc.json')).toStrictEqual(['./signed_vc.json']); + }); + + it('splits and trims comma-separated file paths', () => { + expect(resolveCredentialPaths(' ./a.json , ./b.json ')).toStrictEqual([ + './a.json', + './b.json', + ]); + }); + + it('expands a directory to every file inside it, in a stable order', () => { + dir = makeDir(); + fs.writeFileSync(path.join(dir, 'b.json'), '{}'); + fs.writeFileSync(path.join(dir, 'a.json'), '{}'); + // No extension filtering — whatever is in the directory is presented, and an invalid + // credential is reported by the signing step rather than silently dropped. + fs.writeFileSync(path.join(dir, 'c.txt'), 'not json'); + + expect(resolveCredentialPaths(dir)).toStrictEqual([ + path.join(dir, 'a.json'), + path.join(dir, 'b.json'), + path.join(dir, 'c.txt'), + ]); + }); + + it('skips dot-files and sub-directories (OS noise, not credentials)', () => { + dir = makeDir(); + fs.writeFileSync(path.join(dir, 'vc.json'), '{}'); + fs.writeFileSync(path.join(dir, '.DS_Store'), 'junk'); + fs.mkdirSync(path.join(dir, 'nested')); + + expect(resolveCredentialPaths(dir)).toStrictEqual([path.join(dir, 'vc.json')]); + }); + + it('throws when the directory is empty', () => { + dir = makeDir(); + expect(() => resolveCredentialPaths(dir)).toThrow(`No files found in directory: ${dir}`); + }); + }); + + describe('nameFailingCredential', () => { + it('names the file a rejected credential came from', () => { + const error = 'credential at index 1 is about "did:key:zOther", which does not match'; + expect(nameFailingCredential(error, ['./a.json', './b.json'])).toBe( + 'credential at index 1 (./b.json) is about "did:key:zOther", which does not match', + ); + }); + + it('leaves the message alone when the paths are unknown or out of range', () => { + const error = 'credential at index 5 is not valid'; + expect(nameFailingCredential(error, undefined)).toBe(error); + expect(nameFailingCredential(error, ['./a.json'])).toBe(error); + }); + }); + + describe('promptForInputs', () => { + const mockUtils = async () => { + const utils = await import('../../../src/utils'); + (utils.isDirectoryValid as MockedFunction).mockReturnValue(true); + (utils.validateInputFileExists as MockedFunction).mockReturnValue(true); + return utils; + }; + + it('should return parsed inputs for a single credential', async () => { + (prompts.input as any) + .mockResolvedValueOnce('./signed_vc.json') // credential path(s) + .mockResolvedValueOnce('./didKeyPairs.json') // key pair path + .mockResolvedValueOnce('.'); // output directory + (prompts.select as any).mockResolvedValueOnce('expiresInSeconds'); + (prompts.number as any).mockResolvedValueOnce(600); + + const utils = await mockUtils(); + (utils.readJsonFile as MockedFunction) + .mockReturnValueOnce({ id: 'urn:uuid:123' }) + .mockReturnValueOnce(KEY_PAIR); + + const result = await promptForInputs(); + + expect(result).toStrictEqual({ + credentials: [{ id: 'urn:uuid:123' }], + credentialPaths: ['./signed_vc.json'], + keyPairData: KEY_PAIR, + holder: HOLDER_DID, + lifetime: { expiresInSeconds: 600 }, + outputPath: '.', + }); + // Credentials, key pair, output directory — no challenge, no holder question. + expect((prompts.input as any).mock.calls.length).toBe(3); + }); + + it('should read every comma-separated credential path', async () => { + (prompts.input as any) + .mockResolvedValueOnce(' ./vc-1.json , ./vc-2.json ') + .mockResolvedValueOnce('./didKeyPairs.json') + .mockResolvedValueOnce('.'); + (prompts.select as any).mockResolvedValueOnce('expiresInSeconds'); + (prompts.number as any).mockResolvedValueOnce(600); + + const utils = await mockUtils(); + (utils.readJsonFile as MockedFunction) + .mockReturnValueOnce({ id: 'urn:uuid:1' }) + .mockReturnValueOnce({ id: 'urn:uuid:2' }) + .mockReturnValueOnce(KEY_PAIR); + + const result = await promptForInputs(); + + expect(result.credentials).toStrictEqual([{ id: 'urn:uuid:1' }, { id: 'urn:uuid:2' }]); + expect(utils.readJsonFile).toHaveBeenNthCalledWith( + 1, + './vc-1.json', + 'Verifiable Credential JSON', + ); + expect(utils.readJsonFile).toHaveBeenNthCalledWith( + 2, + './vc-2.json', + 'Verifiable Credential JSON', + ); + }); + + it('should read every file when a directory is given', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trustvc-vp-prompt-')); + tempDirs.push(dir); + fs.writeFileSync(path.join(dir, 'vc-1.json'), '{}'); + fs.writeFileSync(path.join(dir, 'vc-2.json'), '{}'); + + (prompts.input as any) + .mockResolvedValueOnce(dir) // a directory, not a file + .mockResolvedValueOnce('./didKeyPairs.json') + .mockResolvedValueOnce('.'); + (prompts.select as any).mockResolvedValueOnce('expiresInSeconds'); + (prompts.number as any).mockResolvedValueOnce(600); + + const utils = await mockUtils(); + (utils.readJsonFile as MockedFunction) + .mockReturnValueOnce({ id: 'urn:uuid:1' }) + .mockReturnValueOnce({ id: 'urn:uuid:2' }) + .mockReturnValueOnce(KEY_PAIR); + + const result = await promptForInputs(); + + expect(result.credentials).toStrictEqual([{ id: 'urn:uuid:1' }, { id: 'urn:uuid:2' }]); + expect(result.credentialPaths).toStrictEqual([ + path.join(dir, 'vc-1.json'), + path.join(dir, 'vc-2.json'), + ]); + // A directory is accepted by the prompt without per-file validation. + expect((prompts.input as any).mock.calls[0][0].validate(dir)).toBe(true); + }); + + it('should accept an explicit validUntil as the lifetime', async () => { + (prompts.input as any) + .mockResolvedValueOnce('./signed_vc.json') + .mockResolvedValueOnce('./didKeyPairs.json') + .mockResolvedValueOnce('2999-01-01T00:00:00Z') // validUntil + .mockResolvedValueOnce('.'); + (prompts.select as any).mockResolvedValueOnce('validUntil'); + + const utils = await mockUtils(); + (utils.readJsonFile as MockedFunction) + .mockReturnValueOnce({ id: 'urn:uuid:123' }) + .mockReturnValueOnce(KEY_PAIR); + + const result = await promptForInputs(); + + expect(result.lifetime).toStrictEqual({ validUntil: '2999-01-01T00:00:00Z' }); + expect(prompts.number).not.toHaveBeenCalled(); + }); + + it('should take the holder from the key pair without asking', async () => { + (prompts.input as any) + .mockResolvedValueOnce('./signed_vc.json') + .mockResolvedValueOnce('./didKeyPairs.json') + .mockResolvedValueOnce('.'); + (prompts.select as any).mockResolvedValueOnce('expiresInSeconds'); + (prompts.number as any).mockResolvedValueOnce(600); + + const utils = await mockUtils(); + (utils.readJsonFile as MockedFunction) + .mockReturnValueOnce({ id: 'urn:uuid:123' }) + .mockReturnValueOnce(KEY_PAIR); + + const result = await promptForInputs(); + + expect(result.holder).toBe(HOLDER_DID); + // Credentials, key pair, output directory — no holder question. + expect((prompts.input as any).mock.calls.length).toBe(3); + const asked = (prompts.input as any).mock.calls.map((call: any[]) => call[0].message); + expect(asked.some((message: string) => /Enter the holder DID/i.test(message))).toBe(false); + }); + + it('should reject a key pair that is not bound to a DID', async () => { + (prompts.input as any) + .mockResolvedValueOnce('./signed_vc.json') + .mockResolvedValueOnce('./keypair.json') // the bare file `key-pair-generation` writes + .mockResolvedValueOnce('.'); + + const utils = await mockUtils(); + (utils.readJsonFile as MockedFunction) + .mockReturnValueOnce({ id: 'urn:uuid:123' }) + .mockReturnValueOnce({ + type: 'Multikey', + secretKeyMultibase: 'z42', + publicKeyMultibase: 'zDn', + }); + + await expect(promptForInputs()).rejects.toThrow( + 'The key pair at ./keypair.json is not bound to a DID (no "controller").', + ); + }); + + it('should abide by the validation rules of each prompt', async () => { + (prompts.input as any) + .mockResolvedValueOnce('./signed_vc.json') + .mockResolvedValueOnce('./didKeyPairs.json') + .mockResolvedValueOnce('2999-01-01T00:00:00Z') // validUntil + .mockResolvedValueOnce('.'); + (prompts.select as any).mockResolvedValueOnce('validUntil'); + + const utils = await mockUtils(); + (utils.readJsonFile as MockedFunction) + .mockReturnValueOnce({ id: 'urn:uuid:123' }) + .mockReturnValueOnce(KEY_PAIR); + (utils.validateInputFileExists as MockedFunction).mockImplementation((...args: any[]) => + args[0] === './missing.json' ? 'File not found: ./missing.json' : true, + ); + + await promptForInputs(); + + const credentialPrompt = (prompts.input as any).mock.calls[0][0]; + expect(credentialPrompt.validate('')).toBe( + 'A directory or at least one credential file is required', + ); + expect(credentialPrompt.validate('./vc-1.json,./missing.json')).toBe( + 'File not found: ./missing.json', + ); + expect(credentialPrompt.validate('./vc-1.json, ./vc-2.json')).toBe(true); + + const validUntilPrompt = (prompts.input as any).mock.calls[2][0]; + expect(validUntilPrompt.validate('not-a-date')).toBe('Enter a valid ISO 8601 timestamp'); + expect(validUntilPrompt.validate('2020-01-01T00:00:00Z')).toBe( + 'validUntil must be in the future', + ); + expect(validUntilPrompt.validate('2999-01-01T00:00:00Z')).toBe(true); + }); + + it('should throw when the output directory is invalid', async () => { + (prompts.input as any) + .mockResolvedValueOnce('./signed_vc.json') + .mockResolvedValueOnce('./didKeyPairs.json') + .mockResolvedValueOnce('/does/not/exist'); + (prompts.select as any).mockResolvedValueOnce('expiresInSeconds'); + (prompts.number as any).mockResolvedValueOnce(600); + + const utils = await import('../../../src/utils'); + (utils.readJsonFile as MockedFunction) + .mockReturnValueOnce({ id: 'urn:uuid:123' }) + .mockReturnValueOnce(KEY_PAIR); + (utils.isDirectoryValid as MockedFunction).mockReturnValue(false); + + await expect(promptForInputs()).rejects.toThrow('Output path is not valid'); + }); + }); + + describe('signPresentation', () => { + const baseInput = { + credentials: [{ id: 'urn:uuid:123' }], + keyPairData: KEY_PAIR, + holder: HOLDER_DID, + lifetime: { expiresInSeconds: 600 }, + outputPath: '.', + } as never as Parameters[0]; + + it('should pass a single credential through unwrapped and save the signed VP', async () => { + const trustvc = await import('@trustvc/trustvc'); + (trustvc.signW3CPresentation as MockedFunction).mockResolvedValue({ + signed: { type: ['VerifiablePresentation'] }, + }); + const utils = await import('../../../src/utils'); + + await signPresentation(baseInput); + + // No challenge/domain: the CLI always produces an assertionMethod proof. + expect(trustvc.signW3CPresentation).toHaveBeenCalledWith({ id: 'urn:uuid:123' }, KEY_PAIR, { + holder: HOLDER_DID, + expiresInSeconds: 600, + }); + // path.join normalises the '.' away. + expect(utils.writeFile).toHaveBeenCalledWith( + 'signed_vp.json', + { type: ['VerifiablePresentation'] }, + true, + ); + }); + + it('should pass multiple credentials as an array', async () => { + const trustvc = await import('@trustvc/trustvc'); + (trustvc.signW3CPresentation as MockedFunction).mockResolvedValue({ + signed: { type: ['VerifiablePresentation'] }, + }); + + await signPresentation({ + ...baseInput, + credentials: [{ id: 'urn:uuid:1' }, { id: 'urn:uuid:2' }], + } as never); + + expect(trustvc.signW3CPresentation).toHaveBeenCalledWith( + [{ id: 'urn:uuid:1' }, { id: 'urn:uuid:2' }], + KEY_PAIR, + { holder: HOLDER_DID, expiresInSeconds: 600 }, + ); + }); + + it('should name the file a rejected credential came from', async () => { + const trustvc = await import('@trustvc/trustvc'); + (trustvc.signW3CPresentation as MockedFunction).mockResolvedValue({ + error: 'credential at index 1 is not valid: bad', + }); + const signale = await import('signale'); + + await signPresentation({ + ...baseInput, + credentials: [{ id: 'urn:uuid:1' }, { id: 'urn:uuid:2' }], + credentialPaths: ['./vc-dir/a.json', './vc-dir/b.json'], + } as never); + + expect((signale.default as any).error).toHaveBeenCalledWith( + 'credential at index 1 (./vc-dir/b.json) is not valid: bad', + ); + }); + + it('should report the error and write nothing when signing fails', async () => { + const trustvc = await import('@trustvc/trustvc'); + (trustvc.signW3CPresentation as MockedFunction).mockResolvedValue({ + error: 'credentialSubject.id does not match the holder', + }); + const utils = await import('../../../src/utils'); + const signale = await import('signale'); + + await signPresentation(baseInput); + + expect((signale.default as any).error).toHaveBeenCalledWith( + 'credentialSubject.id does not match the holder', + ); + expect(utils.writeFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/commands/w3c/vp.integration.test.ts b/tests/commands/w3c/vp.integration.test.ts new file mode 100644 index 0000000..24fabd3 --- /dev/null +++ b/tests/commands/w3c/vp.integration.test.ts @@ -0,0 +1,462 @@ +import { deriveW3C, issuer, signW3C, signW3CPresentation } from '@trustvc/trustvc'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'; +import { verify } from '../../../src/commands/verify'; +import { signPresentation } from '../../../src/commands/w3c/vp-sign'; +import { SignedVerifiablePresentation } from '../../../src/types'; + +vi.mock('signale', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + note: vi.fn(), + }, + Signale: vi.fn().mockImplementation(() => ({ + await: vi.fn(), + success: vi.fn(), + })), +})); + +// Asserts a value is defined and returns it narrowed (avoids `!` assertions). +const assertDefined = (value: T | undefined | null, message: string): T => { + if (value === undefined || value === null) throw new Error(message); + return value; +}; + +/** + * End-to-end VP tests using REAL crypto: a did:key holder self-issues a credential, + * presents it with `vp-sign`, and the presentation is verified through the unified + * `verify` command. Presentations are minted per-run rather than stored as fixtures + * because a VP always carries an expiry. + */ +describe('verifiable presentation (integration)', () => { + let outputPath: string; + let holderDid: string; + // The holder's did:key private key pair (id + controller bound to the did:key). + let keyPairData: Record; + let derivedCredential: Record; + + let signaleSuccessMock: MockedFunction; + let signaleWarnMock: MockedFunction; + let signaleErrorMock: MockedFunction; + let signaleInfoMock: MockedFunction; + + const messages = () => + [ + ...signaleSuccessMock.mock.calls, + ...signaleWarnMock.mock.calls, + ...signaleErrorMock.mock.calls, + ...signaleInfoMock.mock.calls, + ] + .map((call: any[]) => String(call[0])) + .join('\n'); + + beforeAll(async () => { + outputPath = fs.mkdtempSync(path.join(os.tmpdir(), 'trustvc-vp-')); + + const { did, didKeyPairs } = await issuer.generateDidKeyPair('ecdsa-sd-2023'); + holderDid = did; + keyPairData = didKeyPairs as Record; + + // A credential ABOUT the holder — holder binding requires credentialSubject.id === holder. + const raw = { + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://trustvc.io/context/bill-of-lading.json', + ], + type: ['VerifiableCredential'], + issuer: did, + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { id: did, type: ['BillOfLading'], blNumber: 'BL-123' }, + }; + const signed = await signW3C(raw as never, didKeyPairs as never, 'ecdsa-sd-2023'); + if (signed.error) throw new Error(`could not sign the test credential: ${signed.error}`); + const derived = await deriveW3C(assertDefined(signed.signed, 'signed credential'), [ + '/credentialSubject/id', + '/credentialSubject/blNumber', + ]); + if (derived.error) throw new Error(`could not derive the test credential: ${derived.error}`); + derivedCredential = assertDefined(derived.derived, 'derived credential') as Record< + string, + unknown + >; + }, 60000); + + afterAll(() => { + fs.rmSync(outputPath, { recursive: true, force: true }); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + const signale = await import('signale'); + signaleSuccessMock = (signale.default as any).success; + signaleWarnMock = (signale.default as any).warn; + signaleErrorMock = (signale.default as any).error; + signaleInfoMock = (signale.default as any).info; + }); + + const sign = async ( + overrides: Partial[0]> = {}, + ): Promise => { + await signPresentation({ + credentials: [derivedCredential as never], + keyPairData: keyPairData as never, + holder: holderDid, + lifetime: { expiresInSeconds: 600 }, + outputPath, + ...overrides, + }); + const signedVpPath = path.join(outputPath, 'signed_vp.json'); + if (!fs.existsSync(signedVpPath)) return undefined; + return JSON.parse(fs.readFileSync(signedVpPath, 'utf8')); + }; + + it('signs a presentation the holder can prove ownership of', async () => { + const vp = assertDefined(await sign(), 'signed VP'); + + expect(vp.type).toContain('VerifiablePresentation'); + expect(vp.holder).toBe(holderDid); + expect(vp.validFrom).toBeDefined(); + expect(vp.validUntil).toBeDefined(); + expect(vp.proof.cryptosuite).toBe('ecdsa-rdfc-2019'); + // The CLI never issues a challenge, so the proof is always an assertionMethod proof. + expect(vp.proof.proofPurpose).toBe('assertionMethod'); + expect(vp.proof.challenge).toBeUndefined(); + expect(messages()).toContain('Verifiable Presentation signed successfully'); + }, 60000); + + it('refuses to sign when the holder does not match the credential subject', async () => { + // Remove any presentation an earlier test left behind, so "no file" is a real signal + // rather than something inherited — otherwise this passes even if signing succeeded. + fs.rmSync(path.join(outputPath, 'signed_vp.json'), { force: true }); + + const vp = await sign({ holder: 'did:example:someone-else' }); + + expect(vp).toBeUndefined(); // nothing was written + expect(signaleErrorMock).toHaveBeenCalled(); + expect(String(signaleErrorMock.mock.calls[0][0])).toMatch(/does not match the holder/); + }, 60000); + + it('verifies a presentation through the unified verify pipeline', async () => { + const vp = assertDefined(await sign(), 'signed VP'); + + vi.clearAllMocks(); + await verify(vp as never); + expect(signaleSuccessMock).toHaveBeenCalledWith('DOCUMENT_INTEGRITY: VALID'); + expect(signaleSuccessMock).toHaveBeenCalledWith('DOCUMENT_STATUS: VALID'); + expect(signaleSuccessMock).toHaveBeenCalledWith('ISSUER_IDENTITY: VALID'); + // The three lines read the same for one credential or five, so the count is stated. + expect(signaleInfoMock).toHaveBeenCalledWith('1 embedded credential verified.'); + }, 60000); + + it('reports an unsigned presentation as invalid (ownership cannot be proven)', async () => { + const vp = assertDefined(await sign(), 'signed VP'); + const unsigned = JSON.parse(JSON.stringify(vp)); + delete unsigned.proof; + + vi.clearAllMocks(); + await verify(unsigned as never); + expect(messages()).toContain('DOCUMENT_INTEGRITY: INVALID'); + }, 60000); + + it('reports a tampered embedded credential as invalid', async () => { + const vp = assertDefined(await sign(), 'signed VP'); + const tampered = JSON.parse(JSON.stringify(vp)); + const credential = Array.isArray(tampered.verifiableCredential) + ? tampered.verifiableCredential[0] + : tampered.verifiableCredential; + credential.credentialSubject.blNumber = 'TAMPERED'; + + vi.clearAllMocks(); + await verify(tampered as never); + expect(messages()).toContain('DOCUMENT_INTEGRITY: INVALID'); + }, 60000); + + it('reports a presentation whose holder binding is broken as invalid', async () => { + const vp = assertDefined(await sign(), 'signed VP'); + // The signer no longer matches the declared holder. + const rebound = { ...JSON.parse(JSON.stringify(vp)), holder: 'did:example:someone-else' }; + + vi.clearAllMocks(); + await verify(rebound as never); + expect(messages()).toContain('DOCUMENT_INTEGRITY: INVALID'); + }, 60000); + + it('presents several credentials in one presentation', async () => { + const vp = assertDefined( + await sign({ credentials: [derivedCredential as never, derivedCredential as never] }), + 'signed VP', + ); + expect(vp.verifiableCredential).toHaveLength(2); + + vi.clearAllMocks(); + await verify(vp as never); + expect(signaleSuccessMock).toHaveBeenCalledWith('DOCUMENT_INTEGRITY: VALID'); + expect(signaleSuccessMock).toHaveBeenCalledWith('DOCUMENT_STATUS: VALID'); + expect(signaleSuccessMock).toHaveBeenCalledWith('ISSUER_IDENTITY: VALID'); + expect(signaleInfoMock).toHaveBeenCalledWith('2 embedded credentials verified.'); + }, 60000); + + it('does not state a credential count when the presentation is invalid', async () => { + const vp = assertDefined(await sign(), 'signed VP'); + const unsigned = JSON.parse(JSON.stringify(vp)); + delete unsigned.proof; + + vi.clearAllMocks(); + await verify(unsigned as never); + expect(messages()).toContain('DOCUMENT_INTEGRITY: INVALID'); + expect(messages()).not.toContain('embedded credential'); + }, 60000); + + it('reports an expired presentation as invalid', async () => { + // `vp-sign` refuses a past `validUntil` at the prompt, so an expired presentation cannot + // come from the CLI — but one handed to `verify` must still be caught. Built directly. + const { signed, error } = await signW3CPresentation( + derivedCredential as never, + keyPairData as never, + { + holder: holderDid, + validFrom: '2020-01-01T00:00:00Z', + validUntil: '2020-01-02T00:00:00Z', + } as never, + ); + expect(error).toBeUndefined(); + + vi.clearAllMocks(); + await verify(assertDefined(signed, 'signed VP') as never); + // The signature is still sound — only the validity window has closed. + expect(signaleSuccessMock).toHaveBeenCalledWith('DOCUMENT_INTEGRITY: VALID'); + expect(messages()).toContain('DOCUMENT_STATUS: INVALID'); + expect(messages()).toContain('has expired'); + }, 60000); +}); + +/** + * The same flows with a PUBLISHED did:web, in both roles. These resolve + * `did:web:trustvc.github.io:did:1` over the network, as the OA/W3C fixtures in + * `tests/commands/verify.test.ts` already do. + */ +describe('verifiable presentation with a published did:web (integration, network)', () => { + let outputPath: string; + let signaleSuccessMock: MockedFunction; + + // The did:web hosted at trustvc.github.io. `#multikey-1` is its ECDSA (P-256) Multikey. + // This is PUBLISHED test material for a PUBLISHED test DID — the same key pair committed + // in @trustvc/trustvc's own sign/presentation/vpFragments fixtures. It secures nothing and + // controls no funds; it exists so a did:web can be exercised without hosting one. + const HOSTED_DID = 'did:web:trustvc.github.io:did:1'; + const hostedKey = { + id: `${HOSTED_DID}#multikey-1`, + controller: HOSTED_DID, + type: 'Multikey', + publicKeyMultibase: 'zDnaemDNwi4G5eTzGfRooFFu5Kns3be6yfyVNtiaMhWkZbwtc', + secretKeyMultibase: 'z42tmUXTVn3n9BihE6NhdMpvVBTnFTgmb6fw18o5Ud6puhRW', // gitleaks:allow + }; + + const issueTo = async (issuerDid: string, issuerKey: unknown, subjectDid: string) => { + const signed = await signW3C( + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://trustvc.io/context/bill-of-lading.json', + ], + type: ['VerifiableCredential'], + issuer: issuerDid, + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { id: subjectDid, type: ['BillOfLading'], blNumber: 'BL-HOSTED' }, + } as never, + issuerKey as never, + 'ecdsa-sd-2023', + ); + if (signed.error) throw new Error(`could not sign the test credential: ${signed.error}`); + return assertDefined(signed.signed, 'signed credential'); + }; + + beforeAll(() => { + outputPath = fs.mkdtempSync(path.join(os.tmpdir(), 'trustvc-vp-hosted-')); + }); + + afterAll(() => { + fs.rmSync(outputPath, { recursive: true, force: true }); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + const signale = await import('signale'); + signaleSuccessMock = (signale.default as any).success; + }); + + const presentAndVerify = async (credential: unknown, keyPair: unknown, holder: string) => { + await signPresentation({ + credentials: [credential as never], + keyPairData: keyPair as never, + holder, + lifetime: { expiresInSeconds: 600 }, + outputPath, + }); + const vp = JSON.parse(fs.readFileSync(path.join(outputPath, 'signed_vp.json'), 'utf8')); + vi.clearAllMocks(); + await verify(vp as never); + return vp; + }; + + it('a did:web holder can present a credential issued by a did:key', async () => { + const { did: issuerDid, didKeyPairs: issuerKey } = + await issuer.generateDidKeyPair('ecdsa-sd-2023'); + const credential = await issueTo(issuerDid, issuerKey, HOSTED_DID); + + const vp = await presentAndVerify(credential, hostedKey, HOSTED_DID); + + expect(vp.holder).toBe(HOSTED_DID); + expect(vp.proof.verificationMethod).toBe(`${HOSTED_DID}#multikey-1`); + expect(signaleSuccessMock).toHaveBeenCalledWith('DOCUMENT_INTEGRITY: VALID'); + expect(signaleSuccessMock).toHaveBeenCalledWith('DOCUMENT_STATUS: VALID'); + expect(signaleSuccessMock).toHaveBeenCalledWith('ISSUER_IDENTITY: VALID'); + }, 60000); + + it('a did:key holder can present a credential issued by a did:web', async () => { + const { did: holderDid, didKeyPairs: holderKey } = + await issuer.generateDidKeyPair('ecdsa-sd-2023'); + // Not derived — `vp-sign` must auto full-disclose it. + const credential = await issueTo(HOSTED_DID, hostedKey, holderDid); + + const vp = await presentAndVerify(credential, holderKey, holderDid); + + const embedded = Array.isArray(vp.verifiableCredential) + ? vp.verifiableCredential[0] + : vp.verifiableCredential; + expect(embedded.issuer).toBe(HOSTED_DID); + expect(vp.holder).toBe(holderDid); + expect(signaleSuccessMock).toHaveBeenCalledWith('DOCUMENT_INTEGRITY: VALID'); + expect(signaleSuccessMock).toHaveBeenCalledWith('DOCUMENT_STATUS: VALID'); + expect(signaleSuccessMock).toHaveBeenCalledWith('ISSUER_IDENTITY: VALID'); + }, 60000); +}); + +/** + * did:web identities are only usable once their DID document is actually published: the + * issuer's must resolve when the credential is PRESENTED (trustvc verifies every credential + * before putting it in a presentation), and the holder's must resolve when the presentation + * is VERIFIED. Both halves are covered here — these are the traps a user hits after running + * `did-web` and not yet hosting `wellknown.json`. + */ +describe('verifiable presentation with unpublished did:web identities (integration)', () => { + let outputPath: string; + let signaleErrorMock: MockedFunction; + let signaleWarnMock: MockedFunction; + let signaleSuccessMock: MockedFunction; + + // A did:web key pair for a domain that does not exist — the same two calls the + // `key-pair-generation` and `did-web` commands make. + const makeUnpublishedDidWeb = async (domain: string) => { + const keyPair = await issuer.generateKeyPair({ type: 'ecdsa-sd-2023' }); + const { didKeyPairs } = await issuer.issueDID({ ...keyPair, domain } as never); + return didKeyPairs as Record & { controller: string }; + }; + + beforeAll(() => { + outputPath = fs.mkdtempSync(path.join(os.tmpdir(), 'trustvc-vp-web-')); + }); + + afterAll(() => { + fs.rmSync(outputPath, { recursive: true, force: true }); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + const signale = await import('signale'); + signaleErrorMock = (signale.default as any).error; + signaleWarnMock = (signale.default as any).warn; + signaleSuccessMock = (signale.default as any).success; + }); + + it('refuses to present a credential whose issuer did:web is not published', async () => { + const issuerKey = await makeUnpublishedDidWeb('https://issuer.invalid/.well-known/did.json'); + const holderKey = await makeUnpublishedDidWeb('https://holder.invalid/.well-known/did.json'); + + // Signing the credential works offline — only the private key is needed. + const signed = await signW3C( + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://trustvc.io/context/bill-of-lading.json', + ], + type: ['VerifiableCredential'], + issuer: issuerKey.controller, + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { + id: holderKey.controller, + type: ['BillOfLading'], + blNumber: 'BL-WEB', + }, + } as never, + issuerKey as never, + 'ecdsa-sd-2023', + ); + expect(signed.error).toBeUndefined(); + + // Presenting it does not: the issuer's key cannot be fetched, so the credential cannot + // be verified, so it must not be presented. + await signPresentation({ + credentials: [assertDefined(signed.signed, 'signed credential') as never], + keyPairData: holderKey as never, + holder: holderKey.controller, + lifetime: { expiresInSeconds: 600 }, + outputPath, + }); + + expect(signaleErrorMock).toHaveBeenCalled(); + expect(String(signaleErrorMock.mock.calls[0][0])).toMatch(/credential at index 0 is not valid/); + expect(fs.existsSync(path.join(outputPath, 'signed_vp.json'))).toBe(false); + }, 60000); + + it('signs for an unpublished did:web holder, but verification cannot resolve its key', async () => { + // Issuer is a did:key (resolves in-memory), so signing the presentation gets that far. + const { did: issuerDid, didKeyPairs: issuerKey } = + await issuer.generateDidKeyPair('ecdsa-sd-2023'); + const holderKey = await makeUnpublishedDidWeb('https://holder.invalid/.well-known/did.json'); + + const signed = await signW3C( + { + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://trustvc.io/context/bill-of-lading.json', + ], + type: ['VerifiableCredential'], + issuer: issuerDid, + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { + id: holderKey.controller, + type: ['BillOfLading'], + blNumber: 'BL-WEB-2', + }, + } as never, + issuerKey as never, + 'ecdsa-sd-2023', + ); + expect(signed.error).toBeUndefined(); + + await signPresentation({ + credentials: [assertDefined(signed.signed, 'signed credential') as never], + keyPairData: holderKey as never, + holder: holderKey.controller, + lifetime: { expiresInSeconds: 600 }, + outputPath, + }); + + // Signing succeeds — it only needs the holder's private key. + const signedVpPath = path.join(outputPath, 'signed_vp.json'); + expect(signaleSuccessMock).toHaveBeenCalledWith('Verifiable Presentation signed successfully'); + const vp = JSON.parse(fs.readFileSync(signedVpPath, 'utf8')); + + // Verifying does not — the holder's public key cannot be fetched from the domain. + vi.clearAllMocks(); + await verify(vp as never); + const warnings = signaleWarnMock.mock.calls.map((call: any[]) => String(call[0])).join('\n'); + expect(warnings).toContain('DOCUMENT_INTEGRITY: INVALID'); + }, 60000); +}); diff --git a/tests/fixtures/vp/README.md b/tests/fixtures/vp/README.md new file mode 100644 index 0000000..d94b214 --- /dev/null +++ b/tests/fixtures/vp/README.md @@ -0,0 +1,159 @@ +# Verifiable Presentation fixtures + +Files for trying `trustvc vp-sign` and `trustvc verify` by hand. + +## Generate them first + +**Only this README and `generate-fixtures.cjs` are committed.** The fixtures themselves are +generated and gitignored: they carry throwaway private keys, and every credential and +presentation is bound to the one holder key pair, so the set is only coherent as a whole — +there is no regenerating part of it. + +```sh +npm run build +node tests/fixtures/vp/generate-fixtures.cjs +npx prettier --write "tests/fixtures/vp/**/*.json" +``` + +That writes: + +```text +credentials/ → hand this WHOLE FOLDER to vp-sign +invalid-credentials/ → one file per reason vp-sign refuses a credential (pass ONE at a time) +invalid-keypairs/ → one file per reason vp-sign cannot sign with a key +presentations/ → inputs for verify +didKeyPairs.json → the holder's key pair +``` + +Re-run it after bumping `@trustvc/trustvc`: these are signed artifacts and keep whatever +issuance rules produced them. Each run mints fresh keys, so the holder DID changes — never +hard-code it anywhere. + +The holder is a **did:key**, so its DID is derived from its own public key and nothing has to +be hosted for you to sign and verify. Each credential is self-issued (issuer = holder) purely +so the set is self-contained — in real use the issuer is a different party, which is fine: +holder binding only checks holder = signer = `credentialSubject.id`. + +**Two fixtures do need network access**, because what they demonstrate is a lookup: +`invalid-credentials/revoked.json` fetches its status list from `trustvc.github.io`, and +`invalid-credentials/unresolvable_issuer.json` only fails the way it should once DID +resolution has been attempted and failed. Everything else works offline. + +Run the commands from the repository root, against a built CLI: + +```sh +npm run build +node dist/main.js vp-sign # or `trustvc vp-sign` if you have npm link'ed +node dist/main.js verify +``` + +## Signing — `vp-sign` + +Give the first prompt the **folder**, and both credentials in it are presented: + +``` +tests/fixtures/vp/credentials +``` + +| Prompt | Answer | +| --- | --- | +| directory of signed Verifiable Credentials, or path(s) to individual JSON file(s) | `tests/fixtures/vp/credentials` | +| holder did key-pair JSON file | `tests/fixtures/vp/didKeyPairs.json` | +| presentation expiry | **Enter** (seconds from now) | +| lifetime in seconds | **Enter** (600) | +| directory to save | anywhere you like | + +For a single credential, pass `tests/fixtures/vp/credentials/signed_vc.json` (`BL-0001`) +instead; `signed_vc_2.json` is `BL-0002`. + +The holder DID is read from the key pair — the command prints it rather than asking, since +the signing key's DID *is* the holder and nothing else could work. It is also what every +`credentialSubject.id` in `credentials/` is set to; the generator mints the key pair and the +credentials together so they always agree. + +## Credentials `vp-sign` must refuse — `invalid-credentials/` + +One file per reason, so give the **file**, not the folder — a directory stops at the first +credential it rejects, and you would only ever see one message. Every error names the file it +came from, and nothing is written when signing fails. + +| File | Why it is refused | +| --- | --- | +| `other_holder.json` | `is about "did:key:zDnaerUv…", which does not match the holder "did:key:zDnaer6t…"` | +| `no_subject_id.json` | `has no "credentialSubject.id", so it cannot be bound to the holder.` | +| `expired.json` | `has expired (2021-01-01T00:00:00Z).` | +| `revoked.json` | `has been revocation (credentialStatus).` | +| `transferable_record.json` | `has a "TransferableRecords" credentialStatus and cannot be included in a Verifiable Presentation.` Ownership lives on-chain. | +| `unresolvable_issuer.json` | `is not valid: Cannot read properties of null (reading 'verificationMethod')` — the issuer's did:web is not published, so its key cannot be fetched. | +| `tampered.json` | `is not valid: Invalid signature.` A field was edited after signing. | +| `unsigned.json` | `each credential must be a signed credential object (with a "proof").` | +| `for_bbs_holder.json` | Valid in itself — it exists to pair with `invalid-keypairs/wrong_suite_bbs.json`. Its subject is the BBS did:key, so holder binding passes and the suite check is reached. | + +Two of these are worth knowing about beyond the message: + +- **`no_subject_id.json`** had to be issued *without* a subject id. Selective disclosure keeps + a `credentialSubject.id` that was present at signing, so you cannot produce this by deriving + one away. +- **`revoked.json`** is left **underived** on purpose. `vp-sign` full-discloses an underived + credential, so the `credentialStatus` is visible and the revocation is caught. Derive it + narrowly and that entry is stripped — the credential then presents and verifies cleanly, + because there is no longer anything to check. Issuers who care should pass + `mandatoryPointers: ['/credentialStatus']` when signing. + +Note the presentation you produce expires 600 seconds later, unless you pick an explicit +`validUntil`. + +## Key pairs `vp-sign` must reject — `invalid-keypairs/` + +Give a valid credential (`credentials/signed_vc.json`) and one of these at the key-pair +prompt. Nothing is written in any of these cases. + +| File | Result | +| --- | --- | +| `no_controller.json` | `The key pair at … is not bound to a DID (no "controller"). Create one with "trustvc did-web"…` — this is what `key-pair-generation` writes, and the CLI catches it before signing. | +| `missing_secret_key.json` | `"secretKeyMultibase" property in keyPair is required.` | +| `garbage_key_material.json` | `An ECDSA (P-256) Multikey is required to sign a presentation with "ecdsa-rdfc-2019".` | +| `mismatched_secret_key.json` | Same message. The file claims the holder's DID and public key but carries somebody else's private key, so the pair cannot be loaded — it fails cleanly rather than producing a signature that would not verify. | +| `different_holder.json` | `credential at index 0 … is about "did:key:zDnaeb…", which does not match the holder "did:key:zDnaeS…"` — a perfectly good key belonging to the wrong person. | +| `wrong_suite_bbs.json` | Use it **with `invalid-credentials/for_bbs_holder.json`**, not with `credentials/signed_vc.json`. A BBS did:key is a different DID, so any other credential trips holder binding first and the suite is never checked. Paired correctly: `An ECDSA (P-256) Multikey is required … (BBS keys cannot produce a plain presentation proof).` | + +## Verifying — `verify` + +| File in `presentations/` | Expected result | +| --- | --- | +| `signed_vp.json` | All three VALID, then `1 embedded credential verified.` Valid until the year 2999, so it will not rot. | +| `signed_vp_multi.json` | All three VALID, then `2 embedded credentials verified.` | +| `expired_vp.json` | `DOCUMENT_STATUS: INVALID - Presentation has expired (validUntil 2020-01-02T00:00:00Z).` Integrity stays VALID: the signature is sound, the window has simply closed. | +| `unsigned_vp.json` | `DOCUMENT_INTEGRITY: INVALID - Presentation is not signed (no holder "proof"), so ownership cannot be proven.` | +| `tampered_vp.json` | `DOCUMENT_INTEGRITY: INVALID - Invalid signature.` An embedded credential was edited (`blNumber` → `TAMPERED`) after signing. | +| `tampered_holder_vp.json` | `DOCUMENT_INTEGRITY: INVALID - Invalid signature.` The `holder` field was swapped to another DID after signing. `holder` is part of the signed payload, so this breaks the proof itself rather than reporting a holder-binding message. | +| `unresolvable_issuer_vp.json` | `ISSUER_IDENTITY: INVALID - Could not resolve issuer(s): did:web:nope.invalid.` — plus `DOCUMENT_INTEGRITY: INVALID`, see below. | + +### Getting `ISSUER_IDENTITY: INVALID` + +The fragment fails in three ways, and `unresolvable_issuer_vp.json` covers the realistic one +— a credential whose issuer DID no longer resolves. To produce the other two, edit a copy of +`signed_vp.json`: + +| Edit to the embedded credential | Message | +| --- | --- | +| `issuer` set to an unpublished DID | `Could not resolve issuer(s): did:web:nope.invalid.` | +| `issuer` deleted | `1 embedded credential(s) have no issuer.` | +| `verifiableCredential` set to `[]` | `Presentation contains no verifiable credentials.` | + +**`DOCUMENT_INTEGRITY` always goes INVALID too**, and that is not a flaw in the fixture. The +embedded credentials are inside the signed payload, so editing one breaks the holder proof; +and verifying that credential's own signature needs the issuer's public key, which is exactly +what could not be fetched. A correctly signed presentation cannot carry an unresolvable +issuer in the first place — `vp-sign` verifies every credential before presenting it, so it +refuses to build one. + +## Editing them + +Don't — the presentations carry real signatures over real timestamps, so any hand edit +invalidates the proof. Change `generate-fixtures.cjs` and re-run it instead +(see [Generate them first](#generate-them-first)). + +Nothing automated reads these files: `tests/commands/verify.test.ts` only walks +`tests/fixtures/verify/`, and the VP tests mint their own presentations at runtime because a +stored one would eventually expire. They exist purely for manual testing. diff --git a/tests/fixtures/vp/generate-fixtures.cjs b/tests/fixtures/vp/generate-fixtures.cjs new file mode 100644 index 0000000..2c724e6 --- /dev/null +++ b/tests/fixtures/vp/generate-fixtures.cjs @@ -0,0 +1,299 @@ +/** + * Regenerates the Verifiable Presentation fixtures in this directory. + * + * node tests/fixtures/vp/generate-fixtures.cjs + * npx prettier --write "tests/fixtures/vp/*.json" + * + * Everything is did:key based, so no DID document has to be hosted anywhere and the + * fixtures verify offline. See README.md in this directory for what each file is for. + */ +const fs = require('fs'); +const path = require('path'); +const { + deriveW3C, + issuer, + signW3C, + signW3CPresentation, +} = require('@trustvc/trustvc'); + +const OUT = __dirname; +// `credentials/` holds ONLY presentable credentials, so the whole folder can be handed to +// `vp-sign` as-is. `presentations/` holds the ready-made inputs for `verify`. +// `invalid-credentials/` holds one file per reason `vp-sign` can refuse a credential — +// pass them ONE AT A TIME, never the folder, or you only see the first refusal. +const CREDENTIALS = path.join(OUT, 'credentials'); +const PRESENTATIONS = path.join(OUT, 'presentations'); +const INVALID = path.join(OUT, 'invalid-credentials'); +// `invalid-keypairs/` holds key pairs `vp-sign` cannot sign with, one per reason. +const INVALID_KEYS = path.join(OUT, 'invalid-keypairs'); + +// Far enough out that the ready-made presentations do not expire during normal use. +const FAR_FUTURE = '2999-01-01T00:00:00Z'; + +const write = (dir, name, data) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, name), `${JSON.stringify(data, null, 2)}\n`); + console.log(`wrote ${path.relative(OUT, path.join(dir, name))}`); +}; + +/** Signs a bill of lading credential for `subjectDid` and reveals it in full. */ +const makeCredential = async (issuerDid, keyPair, subjectDid, blNumber) => { + const raw = { + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://trustvc.io/context/bill-of-lading.json', + ], + type: ['VerifiableCredential'], + issuer: issuerDid, + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { id: subjectDid, type: ['BillOfLading'], blNumber }, + }; + const signed = await signW3C(raw, keyPair, 'ecdsa-sd-2023'); + if (signed.error) throw new Error(`sign failed: ${signed.error}`); + const derived = await deriveW3C(signed.signed, [ + '/credentialSubject/id', + '/credentialSubject/blNumber', + ]); + if (derived.error) throw new Error(`derive failed: ${derived.error}`); + return derived.derived; +}; + +/** Signs a raw credential and reveals `pointers` (defaults to the subject id + blNumber). */ +const signAndDerive = async (raw, keyPair, pointers, signOptions) => { + const signed = await signW3C(raw, keyPair, 'ecdsa-sd-2023', signOptions); + if (signed.error) throw new Error(`sign failed: ${signed.error}`); + if (!pointers) return signed.signed; // base (underived) credential + const derived = await deriveW3C(signed.signed, pointers); + if (derived.error) throw new Error(`derive failed: ${derived.error}`); + return derived.derived; +}; + +const sign = async (credentials, keyPair, holder, options) => { + const { signed, error } = await signW3CPresentation(credentials, keyPair, { + holder, + ...options, + }); + if (error) throw new Error(`sign presentation failed: ${error}`); + return signed; +}; + +(async () => { + // The holder: a did:key whose key pair signs both the credentials and the presentation. + const { did: holderDid, didKeyPairs: holderKey } = + await issuer.generateDidKeyPair('ecdsa-sd-2023'); + // A second party, used for the credential that is about somebody else. + const { did: otherDid } = await issuer.generateDidKeyPair('ecdsa-sd-2023'); + + write(OUT, 'didKeyPairs.json', holderKey); + + // Both are about the holder, so `vp-sign` can be pointed at `credentials/` directly. + const vc1 = await makeCredential(holderDid, holderKey, holderDid, 'BL-0001'); + const vc2 = await makeCredential(holderDid, holderKey, holderDid, 'BL-0002'); + write(CREDENTIALS, 'signed_vc.json', vc1); + write(CREDENTIALS, 'signed_vc_2.json', vc2); + + const validVp = await sign(vc1, holderKey, holderDid, { validUntil: FAR_FUTURE }); + write(PRESENTATIONS, 'signed_vp.json', validVp); + + write( + PRESENTATIONS, + 'signed_vp_multi.json', + await sign([vc1, vc2], holderKey, holderDid, { validUntil: FAR_FUTURE }), + ); + + // Expired: a presentation whose window closed in 2020 — the crypto is still sound. + write( + PRESENTATIONS, + 'expired_vp.json', + await sign(vc1, holderKey, holderDid, { + validFrom: '2020-01-01T00:00:00Z', + validUntil: '2020-01-02T00:00:00Z', + }), + ); + + // Unsigned: no holder proof, so ownership cannot be proven. + const unsigned = JSON.parse(JSON.stringify(validVp)); + delete unsigned.proof; + write(PRESENTATIONS, 'unsigned_vp.json', unsigned); + + // Tampered: an embedded credential was edited after the presentation was signed. + const tampered = JSON.parse(JSON.stringify(validVp)); + const embedded = Array.isArray(tampered.verifiableCredential) + ? tampered.verifiableCredential[0] + : tampered.verifiableCredential; + embedded.credentialSubject.blNumber = 'TAMPERED'; + write(PRESENTATIONS, 'tampered_vp.json', tampered); + + // The declared holder was swapped after signing. `holder` is part of the signed payload, + // so this breaks the proof itself rather than surfacing as a holder-binding message. + write(PRESENTATIONS, 'tampered_holder_vp.json', { + ...JSON.parse(JSON.stringify(validVp)), + holder: otherDid, + }); + + // An embedded credential naming an issuer that cannot be resolved — the shape of a + // credential whose issuer has since taken its DID document down. This is the only way to + // reach ISSUER_IDENTITY: INVALID, since `vp-sign` refuses to present a credential whose + // issuer it cannot resolve. Editing the credential also breaks the holder proof, so + // DOCUMENT_INTEGRITY goes INVALID alongside it. + const unresolvableIssuer = JSON.parse(JSON.stringify(validVp)); + const credential = Array.isArray(unresolvableIssuer.verifiableCredential) + ? unresolvableIssuer.verifiableCredential[0] + : unresolvableIssuer.verifiableCredential; + credential.issuer = 'did:web:nope.invalid'; + write(PRESENTATIONS, 'unresolvable_issuer_vp.json', unresolvableIssuer); + + // --------------------------------------------------------------------------------- + // invalid-credentials/ — one file per reason `vp-sign` refuses a credential. + // --------------------------------------------------------------------------------- + const BOL_CONTEXT = [ + 'https://www.w3.org/ns/credentials/v2', + 'https://trustvc.io/context/bill-of-lading.json', + ]; + const bol = (extra) => ({ + '@context': BOL_CONTEXT, + type: ['VerifiableCredential'], + issuer: holderDid, + validFrom: '2024-04-01T12:19:52Z', + credentialSubject: { id: holderDid, type: ['BillOfLading'], blNumber: 'BL-BAD' }, + ...extra, + }); + + // Subject is a different DID, so holder binding cannot hold. + write(INVALID, 'other_holder.json', await makeCredential(holderDid, holderKey, otherDid, 'BL-0003')); + + // No credentialSubject.id at all. It must be ABSENT AT ISSUANCE: selective disclosure + // keeps a subject id that was there when the credential was signed. + write( + INVALID, + 'no_subject_id.json', + await signAndDerive( + { ...bol(), credentialSubject: { type: ['BillOfLading'], blNumber: 'BL-BAD' } }, + holderKey, + ['/credentialSubject/blNumber'], + ), + ); + + // Validity window closed in 2021. + write( + INVALID, + 'expired.json', + await signAndDerive( + bol({ validFrom: '2020-01-01T00:00:00Z', validUntil: '2021-01-01T00:00:00Z' }), + holderKey, + ['/credentialSubject/id', '/credentialSubject/blNumber', '/validUntil'], + ), + ); + + // Revoked on the hosted status list (index 5). Left UNDERIVED so `vp-sign` full-discloses + // it and can see the credentialStatus — a narrow derivation would strip that entry and the + // revocation would go unnoticed. + write( + INVALID, + 'revoked.json', + await signAndDerive( + { + ...bol(), + '@context': [...BOL_CONTEXT, 'https://w3id.org/vc/status-list/2021/v1'], + credentialStatus: { + id: 'https://trustvc.github.io/did/credentials/statuslist/1#5', + type: 'StatusList2021Entry', + statusPurpose: 'revocation', + statusListIndex: '5', + statusListCredential: 'https://trustvc.github.io/did/credentials/statuslist/1', + }, + }, + holderKey, + ), + ); + + // A transferable record — ownership lives on-chain, so it cannot be presented. + write( + INVALID, + 'transferable_record.json', + await signAndDerive( + { + ...bol(), + '@context': [ + ...BOL_CONTEXT, + 'https://trustvc.io/context/transferable-records-context.json', + ], + // `tokenId` is derived by trustvc at signing time and must not be supplied here. + credentialStatus: { + type: 'TransferableRecords', + tokenNetwork: { chain: 'MATIC', chainId: 80002 }, + tokenRegistry: '0x6c2a002A5833a100f38458c50F11E71Aa1A342c6', + }, + }, + holderKey, + ), + ); + + // Issued by a did:web that is not published, so its key cannot be fetched to verify it. + const ghostKp = await issuer.generateKeyPair({ type: 'ecdsa-sd-2023' }); + const { didKeyPairs: ghostKey } = await issuer.issueDID({ + ...ghostKp, + domain: 'https://nope.invalid/.well-known/did.json', + }); + write( + INVALID, + 'unresolvable_issuer.json', + await signAndDerive({ ...bol(), issuer: ghostKey.controller }, ghostKey, [ + '/credentialSubject/id', + '/credentialSubject/blNumber', + ]), + ); + + // Edited after signing. + const tamperedVc = JSON.parse(JSON.stringify(vc1)); + tamperedVc.credentialSubject.blNumber = 'TAMPERED'; + write(INVALID, 'tampered.json', tamperedVc); + + // Never signed — a raw credential has no proof to check. + write(INVALID, 'unsigned.json', bol()); + + // --------------------------------------------------------------------------------- + // invalid-keypairs/ — key pairs `vp-sign` cannot sign the presentation with. + // --------------------------------------------------------------------------------- + + // What `key-pair-generation` writes: key material with no DID, so there is no holder. + write(INVALID_KEYS, 'no_controller.json', await issuer.generateKeyPair({ type: 'ecdsa-sd-2023' })); + + // A DID-bound key pair with the private half removed. + const noSecret = { ...holderKey }; + delete noSecret.secretKeyMultibase; + write(INVALID_KEYS, 'missing_secret_key.json', noSecret); + + // Well-formed shape, key material that is not a key. + write(INVALID_KEYS, 'garbage_key_material.json', { + ...holderKey, + publicKeyMultibase: 'zGARBAGEKEYNOTVALID', + secretKeyMultibase: 'zGARBAGEKEYNOTVALID', + }); + + // A BBS key pair. Presentation proofs use ecdsa-rdfc-2019, which needs ECDSA (P-256). + // Pair it with `invalid-credentials/for_bbs_holder.json`: a BBS did:key is a DIFFERENT DID, + // so with any other credential holder binding fails first and the suite is never reached. + const bbs = await issuer.generateDidKeyPair('bbs-2023'); + write(INVALID_KEYS, 'wrong_suite_bbs.json', bbs.didKeyPairs); + write( + INVALID, + 'for_bbs_holder.json', + await makeCredential(holderDid, holderKey, bbs.did, 'BL-BBS'), + ); + + // A perfectly good key pair — belonging to somebody who is not the credential subject. + const stranger = await issuer.generateDidKeyPair('ecdsa-sd-2023'); + write(INVALID_KEYS, 'different_holder.json', stranger.didKeyPairs); + + // Claims the holder's DID and public key, but carries a stranger's private key. + write(INVALID_KEYS, 'mismatched_secret_key.json', { + ...holderKey, + secretKeyMultibase: stranger.didKeyPairs.secretKeyMultibase, + }); + + console.log(`\nholder did: ${holderDid}\nother did: ${otherDid}`); +})().catch((err) => { + console.error(err); + process.exit(1); +});