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,