From 573ec44c602aa556ed1dbd1af6d10f9b7330f6ba Mon Sep 17 00:00:00 2001 From: rongquan1 <85145303+rongquan1@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:22:23 +0800 Subject: [PATCH] fix: make credentialStatus and expiry non-strippable at issuance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selective disclosure lets the 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 verifies completely clean: 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 (a stripped credential and one that never had a status decode to the same shape), and dropping the then-unused status-list `@context` entry leaves the canonical RDF — and so the signature — intact. Verification cannot catch this; issuance is the only defence. `coreMandatoryPointers` already forced `/issuer` and `/validFrom`, covering who issued a credential and when it started, but not when it stops being usable. Add, each only when the credential carries the field: - `/validUntil` (v2.0) - `/expirationDate` (v1.1) - `/credentialStatus` This also closes a second bypass found while fixing the tests: the TransferableRecords prohibition was itself strippable. The presentation tests signed fixtures carrying a TransferableRecords status and derived it away, so the guard never fired — a transferable record could be presented as an ordinary credential, sidestepping on-chain ownership entirely. The presentable-credential helper now drops that status before signing; the dedicated rejection block keeps it. Note this changes derivation output: credentials with a status or an expiry now disclose those fields whether or not the holder selected them. That is the point, but it is visible to existing consumers. It is also not retroactive — anything already signed stays strippable and must be reissued. Co-Authored-By: Claude Opus 5 --- .../w3c-vc/src/lib/presentation/index.test.ts | 31 +++++++- packages/w3c-vc/src/lib/w3c-vc.test.ts | 79 +++++++++++++++++++ packages/w3c-vc/src/lib/w3c-vc.ts | 37 ++++++++- 3 files changed, 142 insertions(+), 5 deletions(-) diff --git a/packages/w3c-vc/src/lib/presentation/index.test.ts b/packages/w3c-vc/src/lib/presentation/index.test.ts index dfae1fd..e198173 100644 --- a/packages/w3c-vc/src/lib/presentation/index.test.ts +++ b/packages/w3c-vc/src/lib/presentation/index.test.ts @@ -27,15 +27,39 @@ const assertDefined = (value: T | undefined, message: string): T => { return value; }; +/** + * The modern fixtures carry a TransferableRecords credentialStatus, which cannot be + * presented. `credentialStatus` is a mandatory pointer so it survives derivation — tests + * about anything other than transferable records need it gone before signing. + */ +const presentableV2 = () => { + const { credentialStatus: _transferableRecord, ...rest } = modernCredentialV2_0 as Record< + string, + unknown + >; + return rest; +}; + /** * Signs a credential and derives it (selective disclosure) so it is a * verifiable, holder-presentable credential. + * + * The shared modern fixtures carry a TransferableRecords credentialStatus, which is NOT + * presentable — ownership of a transferable record lives on-chain. It is dropped before + * signing rather than relied on being derived away: `credentialStatus` is a mandatory + * pointer, so it survives derivation and the presentation would be rejected. The dedicated + * `rejects TransferableRecords credentials` block below keeps it, via makeSignedCredential. */ const makeDerivedCredential = async ( credential: object, keyPair: object, cryptosuite: 'ecdsa-sd-2023' | 'bbs-2023', ): Promise => { + const { credentialStatus: _transferableRecord, ...presentable } = credential as Record< + string, + unknown + >; + credential = presentable; const signed = await signCredential(credential as never, keyPair as never, cryptosuite); if (signed.error) throw new Error(`sign failed: ${signed.error}`); const derived = await deriveCredential( @@ -416,7 +440,8 @@ describe('Verifiable Presentation', () => { beforeAll(async () => { // The modern credential fixtures carry a TransferableRecords credentialStatus; - // a base (non-derived) signature retains it. + // a base (non-derived) signature retains it. This block NEEDS it, so it uses the + // fixture as-is rather than presentableV2(). transferableRecordVc = await makeSignedCredential( { ...modernCredentialV2_0, @@ -556,7 +581,7 @@ describe('Verifiable Presentation', () => { // modernCredentialV2_0 has validUntil 2029; reveal it, then treat "now" as 2030. const signed = await signCredential( { - ...modernCredentialV2_0, + ...presentableV2(), issuer: ECDSA_DID_KEY_ISSUER, validFrom: '2024-04-01T12:19:52Z', }, @@ -582,7 +607,7 @@ describe('Verifiable Presentation', () => { // Build a VP whose embedded credential expires in 2021, created in 2020 (so creation passes). const signed = await signCredential( { - ...modernCredentialV2_0, + ...presentableV2(), issuer: ECDSA_DID_KEY_ISSUER, validFrom: '2020-01-01T00:00:00Z', validUntil: '2021-01-01T00:00:00Z', diff --git a/packages/w3c-vc/src/lib/w3c-vc.test.ts b/packages/w3c-vc/src/lib/w3c-vc.test.ts index c465d97..3ae3973 100644 --- a/packages/w3c-vc/src/lib/w3c-vc.test.ts +++ b/packages/w3c-vc/src/lib/w3c-vc.test.ts @@ -261,6 +261,85 @@ describe('W3C Verifiable Credentials', () => { expect(verificationResult.error).toBeUndefined(); }); + describe('fields a holder must not be able to withhold', () => { + // Selective disclosure lets the holder drop any non-mandatory statement while the + // credential still verifies, so anything that limits whether a credential may + // still be used has to be mandatory at issuance. These derivations deliberately + // ask for none of those fields; they must come back anyway. + const expiryField = dateField === 'validFrom' ? 'validUntil' : 'expirationDate'; + const revealOnlyOneSubjectField = ['/credentialSubject/billOfLadingName']; + + it(`keeps ${dateField === 'validFrom' ? 'validUntil' : 'expirationDate'} through an unrelated derivation`, async () => { + const testCredential = { + ...credential, + [dateField]: dateValue, + [expiryField]: '2029-12-03T12:19:52Z', + }; + + const signedCredential = await signCredential(testCredential, keyPair, cryptosuite); + expect(signedCredential.error).toBeUndefined(); + + const derivedCredential = await deriveCredential( + signedCredential.signed, + revealOnlyOneSubjectField, + ); + expect(derivedCredential.error).toBeUndefined(); + // Without this, an expired credential could be presented with no expiry at all. + expect(derivedCredential.derived?.[expiryField]).toBe('2029-12-03T12:19:52Z'); + + const verificationResult = await verifyCredential(derivedCredential.derived); + expect(verificationResult.verified).toBe(true); + }); + + it('keeps credentialStatus through an unrelated derivation', async () => { + const testCredential = { + ...credential, + '@context': [...credential['@context'], 'https://w3id.org/vc/status-list/2021/v1'], + [dateField]: dateValue, + credentialStatus: { + id: 'https://trustvc.github.io/did/credentials/statuslist/1#10', + type: 'StatusList2021Entry', + statusPurpose: 'revocation', + statusListIndex: '10', + statusListCredential: 'https://trustvc.github.io/did/credentials/statuslist/1', + }, + }; + + const signedCredential = await signCredential(testCredential, keyPair, cryptosuite); + expect(signedCredential.error).toBeUndefined(); + + const derivedCredential = await deriveCredential( + signedCredential.signed, + revealOnlyOneSubjectField, + ); + expect(derivedCredential.error).toBeUndefined(); + // Without this, a revoked credential could be presented with the entry removed. + expect(derivedCredential.derived?.credentialStatus).toBeDefined(); + + const verificationResult = await verifyCredential(derivedCredential.derived); + expect(verificationResult.verified).toBe(true); + }); + + it('leaves a credential carrying neither field unaffected', async () => { + // The shared fixture already has an expiry, so strip both fields to test the + // case where there is nothing extra to force. + const testCredential = { ...credential, [dateField]: dateValue }; + delete (testCredential as Record)[expiryField]; + delete (testCredential as Record).credentialStatus; + + const signedCredential = await signCredential(testCredential, keyPair, cryptosuite); + expect(signedCredential.error).toBeUndefined(); + + const derivedCredential = await deriveCredential( + signedCredential.signed, + revealOnlyOneSubjectField, + ); + expect(derivedCredential.error).toBeUndefined(); + expect(derivedCredential.derived?.[expiryField]).toBeUndefined(); + expect(derivedCredential.derived?.credentialStatus).toBeUndefined(); + }); + }); + it('should automatically include entire credentialSubject when no properties selected', async () => { const testCredential = { ...credential, [dateField]: dateValue }; diff --git a/packages/w3c-vc/src/lib/w3c-vc.ts b/packages/w3c-vc/src/lib/w3c-vc.ts index c53dc03..b104c06 100644 --- a/packages/w3c-vc/src/lib/w3c-vc.ts +++ b/packages/w3c-vc/src/lib/w3c-vc.ts @@ -281,20 +281,53 @@ export const signCredential = async ( const firstContext = credential['@context'][0]; const isV2 = firstContext === CredentialContextVersion.v2; - // Core mandatory pointers for fields required for credential validity + // Core mandatory pointers for fields required for credential validity. + // + // These are forced rather than left to the caller because selective disclosure lets + // the HOLDER omit any statement the issuer did not mark mandatory, and the credential + // still verifies. Anything constraining whether a credential may still be used has to + // be here or a holder can derive it away: a revoked credential presented with no + // `credentialStatus`, or an expired one with no expiry, verifies clean. The removal is + // undetectable — the derived proof records nothing about what was withheld, and + // dropping the then-unused `@context` entry leaves the canonical RDF, and so the + // signature, intact. Verification cannot catch it; issuance is the only defence. const coreMandatoryPointers = ['/issuer']; - // Add date field pointer based on credential version + // Add date field pointers based on credential version. Both ends of the validity + // window belong here — forcing only the start would leave the expiry strippable. if (isV2) { // For v2.0, validFrom is optional but if present should be mandatory for consistency if (credential.validFrom) { coreMandatoryPointers.push('/validFrom'); } + if (credential.validUntil) { + coreMandatoryPointers.push('/validUntil'); + } } else { // For v1.1, issuanceDate is required coreMandatoryPointers.push('/issuanceDate'); + if (credential.expirationDate) { + coreMandatoryPointers.push('/expirationDate'); + } } + // A revocation entry a holder can withhold is not a revocation entry. + if (credential.credentialStatus) { + coreMandatoryPointers.push('/credentialStatus'); + } + + // BEFORE ENFORCING A NEW FIELD DURING VERIFICATION, ADD IT HERE FIRST. + // + // `credentialSchema` and `termsOfUse` are deliberately absent: nothing reads them + // today, so forcing them would only widen what a derivation discloses. But the moment + // a verifier starts acting on one, a holder can delete it and the check is silently + // skipped rather than failed — and that cannot be fixed retroactively, because + // mandatory pointers are fixed at issuance. Credentials signed before the pointer is + // added stay strippable forever and have to be reissued. + // + // `evidence`, `renderMethod` and `qrCode` are informational or display-only, and + // withholding them is a legitimate holder choice; they should stay optional. + // Combine core mandatory pointers with user-provided ones, ensuring core fields are always included const userMandatoryPointers = options?.mandatoryPointers || []; const mandatoryPointers = [