From a46ae8eedd61322ba4fe2086ea6c1ced83b26041 Mon Sep 17 00:00:00 2001 From: rongquan1 <85145303+rongquan1@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:59:52 +0800 Subject: [PATCH] fix: bump w3c-vc to 2.4.2 so revoked and expired credentials cannot be presented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selective disclosure lets a holder omit any statement the issuer did not mark mandatory, and the credential still verifies. `credentialStatus` and the end of the validity window were not mandatory, so a holder could derive them away and present a revoked or expired credential that passed every check: issued with credentialStatus index 5 (revoked) holder derives -> credentialStatus present? false presenting the stripped credential -> SIGNED DOCUMENT_INTEGRITY: VALID / DOCUMENT_STATUS: VALID / ISSUER_IDENTITY: VALID The removal is undetectable — the derived proof records nothing about what was withheld, and dropping the then-unused status-list `@context` entry leaves the canonical RDF, and so the signature, intact. Verification cannot catch it, so the fix is at issuance: @trustvc/w3c-vc 2.4.2 adds `/credentialStatus`, `/validUntil` and `/expirationDate` to its core mandatory pointers. Both attacks are now refused through the trustvc API: status survived the strip attempt: true -> REFUSED: credential at index 0 has been revocation (credentialStatus). expiry survived the strip attempt: true -> REFUSED: credential at index 0 has expired (2021-01-01T00:00:00Z). Add two presentation tests for this. They are not re-testing w3c-vc: they pin a trustvc-level guarantee that now depends on another package's issuance defaults. Without them, a downgrade or a change to that default leaves every trustvc test passing while the hole silently reopens. Each asserts the field SURVIVED the strip attempt before asserting the refusal, so a failure says which link broke. Note this changes derivation output for consumers: a credential with a status or an expiry now discloses those fields whether or not the holder selected them. It is also not retroactive — anything already signed stays strippable and must be reissued. Co-Authored-By: Claude Opus 5 --- package-lock.json | 18 +++--- package.json | 4 +- src/__tests__/w3c/presentation.test.ts | 78 ++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8931c23..698a9e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,11 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.4.1", + "@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.1", + "@trustvc/w3c-vc": "^2.4.2", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", @@ -6885,15 +6885,15 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@trustvc/w3c/-/w3c-2.4.1.tgz", - "integrity": "sha512-yCyztSAbialRn9VYvXrHUE4v13q4g/BHUN4XUctzF5lrN3WsHKSgUpFUflocLdp1h5fxvJTbqyVoMSh5SDLwQw==", + "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.4.0", "@trustvc/w3c-credential-status": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", - "@trustvc/w3c-vc": "^2.4.1" + "@trustvc/w3c-vc": "^2.4.2" }, "engines": { "node": ">=18.x" @@ -6958,9 +6958,9 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/w3c-vc": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@trustvc/w3c-vc/-/w3c-vc-2.4.1.tgz", - "integrity": "sha512-f/pqBu70epEYVmtWvKvlhCd2F5QkV885rCcbfa9KZq9xYbOzeBueIF7P3gSdv3ILAiMCQTxvpL7hd8I8hj+M1A==", + "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", diff --git a/package.json b/package.json index 71797aa..9e8b6e5 100644 --- a/package.json +++ b/package.json @@ -122,11 +122,11 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/w3c": "^2.4.1", + "@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.1", + "@trustvc/w3c-vc": "^2.4.2", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", diff --git a/src/__tests__/w3c/presentation.test.ts b/src/__tests__/w3c/presentation.test.ts index f638110..938d940 100644 --- a/src/__tests__/w3c/presentation.test.ts +++ b/src/__tests__/w3c/presentation.test.ts @@ -244,6 +244,84 @@ describe('W3C Verifiable Presentation (via @trustvc/trustvc)', () => { }); describe('credential policy', () => { + // A holder can omit any statement the issuer did not mark mandatory and the credential + // still verifies. These two prove the trustvc-level guarantee end-to-end: a revoked or + // expired credential cannot be presented even by a holder who deliberately derives the + // offending field away. It holds because @trustvc/w3c-vc makes `/credentialStatus` and + // the expiry mandatory at issuance — so this ALSO guards the dependency: downgrade or + // weaken that and these fail, rather than the hole silently reopening. + describe('fields a holder cannot strip to dodge a check', () => { + const bol = (extra: Record) => ({ + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://trustvc.io/context/bill-of-lading.json', + ], + type: ['VerifiableCredential'], + issuer: HOLDER_DID, + credentialSubject: { id: HOLDER_DID, type: ['BillOfLading'], blNumber: 'BL-STRIP' }, + ...extra, + }); + + /** + * Signs, then derives revealing ONLY the subject — asking for nothing else. + * @param {object} raw - The raw credential to sign. + * @returns {Promise} The derived credential, minus anything not mandatory. + */ + const signAndStrip = async (raw: object) => { + const signed = await signW3C(raw as never, holderKey as never, 'ecdsa-sd-2023'); + if (signed.error) throw new Error(`sign failed: ${signed.error}`); + const derived = await deriveW3C(assertDefined(signed.signed, 'signed'), [ + '/credentialSubject/id', + '/credentialSubject/blNumber', + ]); + if (derived.error) throw new Error(`derive failed: ${derived.error}`); + return assertDefined(derived.derived, 'derived'); + }; + + it('cannot present a REVOKED credential by stripping credentialStatus', async () => { + // Index 5 on the hosted status list is revoked. + const vc = await signAndStrip( + bol({ + '@context': [ + 'https://www.w3.org/ns/credentials/v2', + 'https://trustvc.io/context/bill-of-lading.json', + 'https://w3id.org/vc/status-list/2021/v1', + ], + validFrom: '2024-04-01T12:19:52Z', + 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', + }, + }), + ); + expect(vc.credentialStatus).toBeDefined(); // survived the strip attempt + + const result = await signW3CPresentation(vc, holderKey as never, { + holder: HOLDER_DID, + expiresInSeconds: 600, + }); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/revocation/); + }); + + it('cannot present an EXPIRED credential by stripping validUntil', async () => { + const vc = await signAndStrip( + bol({ validFrom: '2020-01-01T00:00:00Z', validUntil: '2021-01-01T00:00:00Z' }), + ); + expect(vc.validUntil).toBeDefined(); // survived the strip attempt + + const result = await signW3CPresentation(vc, holderKey as never, { + holder: HOLDER_DID, + expiresInSeconds: 600, + }); + expect(result.signed).toBeUndefined(); + expect(result.error).toMatch(/has expired/); + }); + }); + it('blocks a credential with a TransferableRecords status', async () => { const result = await signW3CPresentation( W3C_TRANSFERABLE_RECORD as never,