From 4f6c7942c2d524e5cd7dddac4ce1c248428bb5f4 Mon Sep 17 00:00:00 2001 From: Abhigyan Singh Date: Thu, 21 May 2026 01:25:15 +0530 Subject: [PATCH] feat(pollux): wire SD-JWT requiredClaimKeys from presentation request to verify Extract required claim keys from the presentation request's input_descriptors.constraints.fields and forward them to SDJWT.verify() during presentation verification. Previously hardcoded to [], which meant @sd-jwt/core never enforced that required claims were actually disclosed by the holder. Fixes #366 Signed-off-by: Abhigyan Singh --- .../internal/dif/PresentationVerify.ts | 44 ++- .../plugins/dif/PresentationVerify.test.ts | 250 +++++++++++++++++- 2 files changed, 285 insertions(+), 9 deletions(-) diff --git a/packages/lib/sdk/src/plugins/internal/dif/PresentationVerify.ts b/packages/lib/sdk/src/plugins/internal/dif/PresentationVerify.ts index b9de38371..3f8153341 100644 --- a/packages/lib/sdk/src/plugins/internal/dif/PresentationVerify.ts +++ b/packages/lib/sdk/src/plugins/internal/dif/PresentationVerify.ts @@ -60,7 +60,8 @@ export class PresentationVerify extends Plugins.Task { private async getCredential( ctx: Context, descriptorItem: DIF.Presentation.Submission.DescriptorItem, - value: string + value: string, + inputDescriptor?: DIF.Presentation.Definition.InputDescriptor ) { if (descriptorItem.format === "jwt_vc" || descriptorItem.format === "jwt_vp") { // for jwt_vc and jwt_vp both are JWT objects and fromJWS will load the right object @@ -84,12 +85,13 @@ export class PresentationVerify extends Plugins.Task { } if (descriptorItem.format === "sd_jwt") { const credential = SDJWTCredential.fromJWS(value); + const requiredClaimKeys = inputDescriptor + ? this.extractRequiredClaimKeys(inputDescriptor) + : []; const valid = await ctx.SDJWT.verify({ issuerDID: credential.issuer, jws: credential.id, - // We leave them empty, we won't be checking here - // but instead disclosing all the values and then validating the claims against input_descriptors - requiredClaimKeys: [] + requiredClaimKeys }); if (!valid) { return null; @@ -153,7 +155,7 @@ export class PresentationVerify extends Plugins.Task { ): Promise { const isPresentation = descriptorItem.format === "jwt_vp" ? true : false; if (descriptorItem.path_nested) { - const credential = await this.getCredential(ctx, descriptorItem, value); + const credential = await this.getCredential(ctx, descriptorItem, value, inputDescriptor); if (!credential) { throw new Domain.PolluxError.InvalidVerifyCredentialError( value, @@ -175,7 +177,7 @@ export class PresentationVerify extends Plugins.Task { ); } - const credential = await this.getCredential(ctx, descriptorItem, value); + const credential = await this.getCredential(ctx, descriptorItem, value, inputDescriptor); if (!credential) { //TODO: Improve this error, can be presentation or credential throw new Domain.PolluxError.InvalidVerifyCredentialError( @@ -219,6 +221,36 @@ export class PresentationVerify extends Plugins.Task { return true; } + /** + * Extract required claim keys from the input descriptor's constraint fields. + * Non-optional fields have their JSONPath converted to the dot-path format + * used by @sd-jwt/core's `listKeys()` (e.g. "$.vc.credentialSubject.firstname" + * → "vc.credentialSubject.firstname"). + * These keys are passed to @sd-jwt/core so it can verify that the required + * selective disclosures are present in the SD-JWT presentation. + */ + private extractRequiredClaimKeys( + inputDescriptor: DIF.Presentation.Definition.InputDescriptor + ): string[] { + const fields = inputDescriptor.constraints.fields.filter(f => !f.optional); + const keys = new Set(); + + for (const field of fields) { + for (const path of field.path) { + // Strip the leading "$." from the JSONPath to produce the dot-path + // format that @sd-jwt/core's listKeys() generates. + // e.g. "$.vc.credentialSubject.firstname" → "vc.credentialSubject.firstname" + const dotPath = path.startsWith("$.") ? path.slice(2) : path; + if (dotPath) { + keys.add(dotPath); + break; // one key per field is sufficient + } + } + } + + return [...keys]; + } + private validateField( vc: string, mapper: DescriptorPath, diff --git a/packages/lib/sdk/tests/plugins/dif/PresentationVerify.test.ts b/packages/lib/sdk/tests/plugins/dif/PresentationVerify.test.ts index a19c27c82..242ba9180 100644 --- a/packages/lib/sdk/tests/plugins/dif/PresentationVerify.test.ts +++ b/packages/lib/sdk/tests/plugins/dif/PresentationVerify.test.ts @@ -1342,7 +1342,9 @@ describe("Plugins - DIF", () => { const sut = new PresentationVerify({ presentation, presentationRequest: failRequest }); const result = ctx.run(sut); - await expect(result).rejects.toThrow("Verification failed for credential (eyJ0eXAiOi...): reason -> Invalid Claim: Expected one of the paths $.vc.credentialSubject.not_a_course, $.credentialSubject.not_a_course, $.not_a_course to exist."); + // With requiredClaimKeys enforcement (issue #366), SDJWT.verify() now rejects + // credentials that do not disclose the required claim before reaching validateField(). + await expect(result).rejects.toThrow(/Verification failed for credential/); }); test("Should Verify false when the Credential subject does not match given pattern", async () => { @@ -1364,7 +1366,9 @@ describe("Plugins - DIF", () => { const sut = new PresentationVerify({ presentation, presentationRequest: failRequest }); const result = ctx.run(sut); - await expect(result).rejects.toThrow("Verification failed for credential (eyJ0eXAiOi...): reason -> Invalid Claim: Expected one of the paths $.vc.credentialSubject.course, $.credentialSubject.course, $.course to exist."); + // With requiredClaimKeys enforcement (issue #366), SDJWT.verify() now rejects + // credentials that do not disclose the required claim before reaching validateField(). + await expect(result).rejects.toThrow(/Verification failed for credential/); }); test("Should Verify false when at least one of the input_descriptors does not match the presentation", async () => { @@ -1554,10 +1558,250 @@ describe("Plugins - DIF", () => { presentationRequest: presentationDefinition })); - await expect(sut).rejects.toThrow('Verification failed for credential (eyJ0eXAiOi...): reason -> Invalid Claim: Expected one of the paths $.vc.credentialSubject.email, $.credentialSubject.email, $.email to exist.'); + // With requiredClaimKeys enforcement (issue #366), SDJWT.verify() now rejects + // credentials that do not disclose the required claim before reaching validateField(). + await expect(sut).rejects.toThrow(/Verification failed for credential/); }); + describe("requiredClaimKeys", () => { + // Helper to build a full SD-JWT sign → present → verify pipeline + async function buildSDJWTPipeline(opts: { + claims: Record; + presentationFrame: PresentationFrame; + optionalFieldNames?: string[]; + }) { + const issuerSeed = ctx.Apollo.createRandomSeed().seed; + const holderSeed = ctx.Apollo.createRandomSeed().seed; + + const issuerMasterSK = await ctx.Apollo.createPrivateKey({ + type: KeyTypes.EC, + curve: Curve.SECP256K1, + seed: issuerSeed.value, + }); + const issuerAuthenticationSK = await ctx.Apollo.createPrivateKey({ + type: KeyTypes.EC, + curve: Curve.ED25519, + seed: issuerSeed.value, + }); + + const holderMasterSK = await ctx.Apollo.createPrivateKey({ + type: KeyTypes.EC, + curve: Curve.SECP256K1, + seed: holderSeed.value, + }); + + const issuerDID = await ctx.Castor.createDID( + 'prism', + { + keys: { + MASTER_KEY: issuerMasterSK, + ISSUING_KEY: [issuerAuthenticationSK] + } + } + ); + + const holderDID = await ctx.Castor.createDID( + 'prism', + { + keys: { + MASTER_KEY: holderMasterSK, + } + } + ); + + const currentDate = new Date(); + const nextMonthDate = new Date(currentDate); + nextMonthDate.setMonth(currentDate.getMonth() + 1); + const issuanceDate = Math.floor(currentDate.getTime() / 1000); + const expirationDate = Math.floor(nextMonthDate.getTime() / 1000); + + const payload = { + iss: issuerDID.toString(), + nbf: issuanceDate, + exp: expirationDate, + sub: holderDID.toString(), + vc: { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + issuer: issuerDID.toString(), + issuanceDate: new Date(issuanceDate).toISOString(), + credentialSubject: { + firstname: "hola", + email: "secret@email.com", + }, + }, + vct: issuerDID.toString() + }; + + const disclosureFrame: DisclosureFrame = { + _sd: ["vc"], + vc: { + _sd: [ + "@context", + "credentialSubject", + "issuanceDate", + "issuer", + "type" + ], + credentialSubject: { + _sd: ['email', 'firstname'] + } + } + }; + + const jwt = await ctx.SDJWT.sign({ + issuerDID, + payload, + disclosureFrame, + privateKey: issuerAuthenticationSK + }); + + // Build presentation definition with requested claims + const constraintFields: DIF.Presentation.Definition.Field[] = Object.keys(opts.claims) + .map((key) => ({ + path: [ + `$.vc.credentialSubject.${key}`, + `$.credentialSubject.${key}`, + `$.${key}` + ], + id: `field-${key}`, + optional: opts.optionalFieldNames?.includes(key) ?? false, + filter: opts.claims[key], + name: key + })); + + const presentationDefinition: DIF.Presentation.Request = { + presentation_definition: { + id: "test-def-id", + input_descriptors: [{ + id: "test-descriptor", + name: "Presentation", + purpose: "Verifying Credentials", + constraints: { + fields: constraintFields, + limit_disclosure: "required", + }, + format: { + sdjwt: { alg: ["EdDSA"] }, + }, + }], + format: { + sdjwt: { alg: ["EdDSA"] }, + }, + }, + options: {}, + }; + + const presentationSubmissionJWS = await ctx.SDJWT.createPresentationFor({ + jws: jwt, + privateKey: issuerAuthenticationSK, + presentationFrame: opts.presentationFrame, + }); + + const [descriptor] = presentationDefinition.presentation_definition.input_descriptors; + const presentation: DIF.EmbedTarget = { + presentation_submission: { + id: "test-submission-id", + definition_id: "test-def-id", + descriptor_map: [{ + id: descriptor.id, + format: "sd_jwt", + path: "$.verifiablePresentation[0]", + }], + }, + verifiablePresentation: [presentationSubmissionJWS], + }; + + return { presentation, presentationDefinition }; + } + + test("Should fail verification when required claims are not disclosed", async () => { + // Request both firstname AND email, but only disclose firstname + const { presentation, presentationDefinition } = await buildSDJWTPipeline({ + claims: { + firstname: { type: 'string', pattern: 'hola' }, + email: { type: 'string', pattern: '*' }, + }, + presentationFrame: { + vc: { + credentialSubject: { + firstname: true, + email: false, // NOT disclosed + } + } + }, + }); + + const sut = ctx.run(new PresentationVerify({ + presentation, + presentationRequest: presentationDefinition, + })); + + // Should fail at the SD-JWT required claims layer + await expect(sut).rejects.toThrow(); + }); + + test("Should pass verification when all required claims are disclosed", async () => { + // Request both firstname AND email, disclose both + const { presentation, presentationDefinition } = await buildSDJWTPipeline({ + claims: { + firstname: { type: 'string', pattern: 'hola' }, + email: { type: 'string', pattern: 'secret@email.com' }, + }, + presentationFrame: { + vc: { + credentialSubject: { + firstname: true, + email: true, // Both disclosed + } + } + }, + }); + + const sut = new PresentationVerify({ + presentation, + presentationRequest: presentationDefinition, + }); + const result = await ctx.run(sut); + + expect(result.pid).toBe("valid"); + expect(result.data).toBe(true); + }); + + test("Should not include optional fields in required claim keys", async () => { + // Request firstname (required) and email (optional), only disclose firstname + const { presentation, presentationDefinition } = await buildSDJWTPipeline({ + claims: { + firstname: { type: 'string', pattern: 'hola' }, + email: { type: 'string', pattern: '*' }, + }, + presentationFrame: { + vc: { + credentialSubject: { + firstname: true, + email: false, // NOT disclosed, but field is optional + } + } + }, + optionalFieldNames: ['email'], + }); + + // Should pass because email is optional — not included in requiredClaimKeys + // It will still fail at the DescriptorPath validation layer if the claim + // is needed, but it should not fail at the SD-JWT required claims layer. + // Since email is optional in the input descriptor, verification should pass. + const sut = new PresentationVerify({ + presentation, + presentationRequest: presentationDefinition, + }); + const result = await ctx.run(sut); + + expect(result.pid).toBe("valid"); + expect(result.data).toBe(true); + }); + }); + test("Should throw 'key not found' when DID has no ISSUING_KEY and default purpose is used", async () => { const issuerSeed = ctx.Apollo.createRandomSeed().seed;