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 = [