diff --git a/integration-tests/verification-flow/range-proofs.test.ts b/integration-tests/verification-flow/range-proofs.test.ts index 9e56ed95..1d6814cf 100644 --- a/integration-tests/verification-flow/range-proofs.test.ts +++ b/integration-tests/verification-flow/range-proofs.test.ts @@ -35,10 +35,6 @@ describe('Range proofs verification', () => { template: proofRequest.qr, }); - // We can keep the attributes to reveal empty - // Range proof attributes will be handled automatically during presentation creation - // and will not be revealed in the presentation - // even if we include them in the attributesToReveal, they will be ignored const attributesToReveal = []; controller.selectedCredentials.set(credential.id, { @@ -149,7 +145,7 @@ describe('Range proofs verification', () => { const presentation = await controller.createPresentation(); // Presentation issuanceDate should not be equal to the credential issuanceDate - // The credential SDK will genreate a presentation timestamp instead + // The credential SDK will generate a presentation timestamp instead expect(presentation.verifiableCredential[0].issuanceDate).not.toBe( credential.issuanceDate, ); diff --git a/packages/core/src/verification-controller.ts b/packages/core/src/verification-controller.ts index 3ec9033d..0aa94aaa 100644 --- a/packages/core/src/verification-controller.ts +++ b/packages/core/src/verification-controller.ts @@ -21,10 +21,6 @@ export enum VerificationStatus { SelectingCredentials = 'SelectingCredentials', } -function isRangeProofTemplate(templateJSON) { - return templateJSON.proving_key; -} - type CredentialId = string; type CredentialSelection = { credential: any; @@ -52,7 +48,6 @@ export function createVerificationController({ let filteredCredentials = []; let selectedCredentials: CredentialSelectionMap = new Map(); let selectedDID = null; - let provingKey = null; if (!credentialProvider) { credentialProvider = createCredentialProvider({wallet}); @@ -62,23 +57,6 @@ export function createVerificationController({ didProvider = createDIDProvider({wallet}); } - async function fetchProvingKey(templateJSON: any) { - if (templateJSON.proving_key) { - setState(VerificationStatus.FetchingProvingKey); - try { - provingKey = await axios - .get(templateJSON.proving_key) - .then(res => res.data); - } catch (err) { - setState(VerificationStatus.Error, { - message: 'failed_to_fetch_proving_key', - }); - - throw err; - } - } - } - async function start({template}: {template: string | any}) { setState(VerificationStatus.LoadingTemplate); @@ -96,7 +74,6 @@ export function createVerificationController({ selectedDID = dids[0].didDocument.id; templateJSON = await getJSON(template); - await fetchProvingKey(templateJSON); await loadCredentials(); setState(VerificationStatus.SelectingCredentials); @@ -154,71 +131,110 @@ export function createVerificationController({ return credentialServiceRPC.isKvacCredential({credential}); } + async function deriveNonBbsCredentials(sdJwtSelections, regularSelections) { + const credentials = []; + + for (const sel of sdJwtSelections) { + const derived = await credentialServiceRPC.createSDJWTPresentation({ + attributesToReveal: sel.attributesToReveal, + credential: sel.credential._sd_jwt.encoded, + }); + credentials.push(derived); + } + + for (const sel of regularSelections) { + credentials.push(sel.credential); + } + + return credentials; + } + + function getKeyId(keyDoc) { + return keyDoc.controller.startsWith('did:key:') + ? keyDoc.id + : `${keyDoc.controller}#keys-1`; + } + + async function assembleSignedPresentation(credentials, keyDoc) { + return credentialServiceRPC.createPresentation({ + credentials, + challenge: templateJSON.nonce, + keyDoc, + id: getKeyId(keyDoc), + domain: 'dock.io', + }); + } + async function createPresentation() { assert(!!selectedDID, 'No DID selected'); assert(!!selectedCredentials.size, 'No credentials selected'); - if (isRangeProofTemplate(templateJSON)) { - // TODO: Implement proving key usage for range-proofs - assert(!!provingKey, 'No proving key found'); - } + const didKeyPairList = await didProvider.getDIDKeyPairs(); + const keyDoc = didKeyPairList.find(doc => doc.controller === selectedDID); + assert(keyDoc, `No key pair found for the selected DID ${selectedDID}`); - const credentials = []; + const sdJwtSelections = []; + const bbsKvacSelections = []; + const regularSelections = []; for (const credentialSelection of selectedCredentials.values()) { - const isBBS = await isBBSPlusCredential(credentialSelection.credential); - const isKVAC = await isKvacCredential(credentialSelection.credential); - if (credentialSelection.credential._sd_jwt) { - const derivedCredential = - await credentialServiceRPC.createSDJWTPresentation({ - attributesToReveal: credentialSelection.attributesToReveal, - credential: credentialSelection.credential._sd_jwt.encoded, - }); - - credentials.push(derivedCredential); - } else if (isBBS || isKVAC) { - // derive credential - const derivedCredentials = - await credentialServiceRPC.deriveVCFromPresentation({ - proofRequest: templateJSON, - - credentials: [ - { - credential: credentialSelection.credential, - witness: await credentialProvider.getMembershipWitness(credentialSelection.credential.id), - attributesToReveal: [ - ...(credentialSelection.attributesToReveal || []), - 'id', - ], - }, - ], - }); - - console.log('Credential derived'); - - credentials.push(derivedCredentials[0]); + sdJwtSelections.push(credentialSelection); } else { - credentials.push(credentialSelection.credential); + const isBBS = await isBBSPlusCredential(credentialSelection.credential); + const isKVAC = await isKvacCredential(credentialSelection.credential); + if (isBBS || isKVAC) { + bbsKvacSelections.push(credentialSelection); + } else { + regularSelections.push(credentialSelection); + } } } - const didKeyPairList = await didProvider.getDIDKeyPairs(); - const keyDoc = didKeyPairList.find(doc => doc.controller === selectedDID); + if (bbsKvacSelections.length > 0) { + const credentialsWithWitness = await Promise.all( + bbsKvacSelections.map(async sel => ({ + credential: sel.credential, + witness: await credentialProvider.getMembershipWitness(sel.credential.id), + attributesToReveal: sel.attributesToReveal || [], + })), + ); - assert(keyDoc, `No key pair found for the selected DID ${selectedDID}`); + // Pure BBS+/KVAC: use generatePresentationFromPex end-to-end + if (sdJwtSelections.length === 0 && regularSelections.length === 0) { + return credentialServiceRPC.generatePresentationFromPex({ + credentials: credentialsWithWitness, + pexRequest: templateJSON.request, + holderKeyDoc: keyDoc, + holderDid: selectedDID, + challenge: templateJSON.nonce, + domain: 'dock.io', + boundCheckSnarkKey: templateJSON.boundCheckSnarkKey, + skipSigning: true, + }); + } - const presentation = await credentialServiceRPC.createPresentation({ - credentials, - challenge: templateJSON.nonce, - keyDoc, - id: keyDoc.controller.startsWith('did:key:') - ? keyDoc.id - : `${keyDoc.controller}#keys-1`, - domain: 'dock.io', - }); + // Mixed: derive BBS+/KVAC, then combine with SD-JWT and regular + const derivedCredentials = + await credentialServiceRPC.deriveVCFromPresentation({ + proofRequest: templateJSON, + credentials: credentialsWithWitness.map(c => ({ + credential: c.credential, + witness: c.witness, + attributesToReveal: [...(c.attributesToReveal || []), 'id'], + })), + }); + + const nonBbsCredentials = await deriveNonBbsCredentials(sdJwtSelections, regularSelections); + return assembleSignedPresentation( + [...derivedCredentials, ...nonBbsCredentials], + keyDoc, + ); + } - return presentation; + // No BBS+/KVAC: handle SD-JWT and regular only + const credentials = await deriveNonBbsCredentials(sdJwtSelections, regularSelections); + return assembleSignedPresentation(credentials, keyDoc); } /** diff --git a/packages/wasm/src/services/credential/config.ts b/packages/wasm/src/services/credential/config.ts index 1b0185ab..cbbd5583 100644 --- a/packages/wasm/src/services/credential/config.ts +++ b/packages/wasm/src/services/credential/config.ts @@ -40,6 +40,13 @@ export const validation = { 'publicKeyBase58 is not present', ); }, + generatePresentationFromPex: params => { + const {credentials, pexRequest, challenge} = params; + assert(Array.isArray(credentials), 'invalid credentials'); + assert(credentials.length > 0, 'no credential found'); + assert(pexRequest, 'pexRequest is required'); + assert(challenge, 'challenge is required'); + }, createPresentation: params => { const {credentials, keyDoc, challenge, id} = params; assert(typeof id === 'string', 'invalid id'); diff --git a/packages/wasm/src/services/credential/index.test.js b/packages/wasm/src/services/credential/index.test.js index 547817bd..e0037cca 100644 --- a/packages/wasm/src/services/credential/index.test.js +++ b/packages/wasm/src/services/credential/index.test.js @@ -2,6 +2,7 @@ import {assertRpcService, getPromiseError} from '../test-utils'; import {credentialService as service} from './service'; import {validation} from './config'; import * as credentialUtils from '@docknetwork/credential-sdk/vc'; +import * as pexUtils from '@docknetwork/credential-sdk/pex'; import {CredentialServiceRPC} from './service-rpc'; import {OpenID4VCIClientV1_0_13} from '@sphereon/oid4vci-client'; import {didService} from '../dids/service'; @@ -566,4 +567,243 @@ describe('Credential Service', () => { }); }); }); + + describe('generatePresentationFromPex', () => { + let generateSpy; + + const basePexRequest = { + id: 'test-request', + input_descriptors: [ + { + id: 'Credential 1', + constraints: {fields: [{path: ['$.type[*]']}]}, + }, + ], + }; + + const baseCredential = { + id: 'https://example.com/credential/1', + type: ['VerifiableCredential'], + credentialSubject: {id: 'did:example:123', name: 'Test'}, + proof: {type: 'Bls12381BBS+SignatureDock2022'}, + }; + + beforeEach(() => { + generateSpy = jest.spyOn(pexUtils, 'generatePresentationFromPexRequest'); + }); + + afterEach(() => { + generateSpy.mockRestore(); + }); + + it('should reject empty credentials array', async () => { + const error = await getPromiseError(() => + service.generatePresentationFromPex({ + credentials: [], + pexRequest: basePexRequest, + challenge: 'test-challenge', + }), + ); + expect(error.message).toBe('no credential found'); + }); + + it('should reject non-array credentials', async () => { + const error = await getPromiseError(() => + service.generatePresentationFromPex({ + credentials: 'invalid', + pexRequest: basePexRequest, + challenge: 'test-challenge', + }), + ); + expect(error.message).toBe('invalid credentials'); + }); + + it('should reject missing pexRequest', async () => { + const error = await getPromiseError(() => + service.generatePresentationFromPex({ + credentials: [{credential: baseCredential}], + challenge: 'test-challenge', + }), + ); + expect(error.message).toBe('pexRequest is required'); + }); + + it('should reject missing challenge', async () => { + const error = await getPromiseError(() => + service.generatePresentationFromPex({ + credentials: [{credential: baseCredential}], + pexRequest: basePexRequest, + }), + ); + expect(error.message).toBe('challenge is required'); + }); + + it('should call generatePresentationFromPexRequest and return presentation', async () => { + const mockPresentation = { + type: ['VerifiablePresentation'], + verifiableCredential: [baseCredential], + }; + + generateSpy.mockResolvedValueOnce({ + status: pexUtils.GeneratePresentationStatus.SUCCESS, + presentation: mockPresentation, + }); + + const result = await service.generatePresentationFromPex({ + credentials: [ + { + credential: baseCredential, + attributesToReveal: ['credentialSubject.name'], + }, + ], + pexRequest: basePexRequest, + challenge: 'test-challenge', + domain: 'dock.io', + skipSigning: true, + }); + + expect(result).toEqual(mockPresentation); + expect(pexUtils.generatePresentationFromPexRequest).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: [baseCredential], + pexRequest: basePexRequest, + challenge: 'test-challenge', + domain: 'dock.io', + skipSigning: true, + selectiveDisclosure: { + credentials: [ + { + attributes: ['credentialSubject.name', 'id'], + witness: undefined, + }, + ], + }, + }), + ); + }); + + it('should throw when generation status is not SUCCESS', async () => { + generateSpy.mockResolvedValueOnce({ + status: pexUtils.GeneratePresentationStatus.REQUIREMENTS_NOT_MET, + error: new Error( + 'Credentials do not satisfy the presentation definition', + ), + }); + + const error = await getPromiseError(() => + service.generatePresentationFromPex({ + credentials: [{credential: baseCredential}], + pexRequest: basePexRequest, + challenge: 'test-challenge', + skipSigning: true, + }), + ); + + expect(error.message).toBe( + 'Credentials do not satisfy the presentation definition', + ); + }); + + it('should throw generic error when generation fails without error object', async () => { + generateSpy.mockResolvedValueOnce({ + status: pexUtils.GeneratePresentationStatus.REQUIREMENTS_NOT_MET, + }); + + const error = await getPromiseError(() => + service.generatePresentationFromPex({ + credentials: [{credential: baseCredential}], + pexRequest: basePexRequest, + challenge: 'test-challenge', + skipSigning: true, + }), + ); + + expect(error.message).toBe( + 'Presentation generation failed: requirements_not_met', + ); + }); + + it('should pass credentials without witness as undefined', async () => { + generateSpy.mockResolvedValueOnce({ + status: pexUtils.GeneratePresentationStatus.SUCCESS, + presentation: {type: ['VerifiablePresentation']}, + }); + + await service.generatePresentationFromPex({ + credentials: [{credential: baseCredential, attributesToReveal: []}], + pexRequest: basePexRequest, + challenge: 'test-challenge', + skipSigning: true, + }); + + const callArgs = generateSpy.mock.calls[0][0]; + expect( + callArgs.selectiveDisclosure.credentials[0].witness, + ).toBeUndefined(); + }); + + it('should not include loadProvingKey when no boundCheckSnarkKey', async () => { + generateSpy.mockResolvedValueOnce({ + status: pexUtils.GeneratePresentationStatus.SUCCESS, + presentation: {type: ['VerifiablePresentation']}, + }); + + await service.generatePresentationFromPex({ + credentials: [{credential: baseCredential}], + pexRequest: basePexRequest, + challenge: 'test-challenge', + skipSigning: true, + }); + + const callArgs = generateSpy.mock.calls[0][0]; + expect(callArgs.loadProvingKey).toBeUndefined(); + }); + + it('should include loadProvingKey when boundCheckSnarkKey is provided', async () => { + generateSpy.mockResolvedValueOnce({ + status: pexUtils.GeneratePresentationStatus.SUCCESS, + presentation: {type: ['VerifiablePresentation']}, + }); + + await service.generatePresentationFromPex({ + credentials: [{credential: baseCredential}], + pexRequest: basePexRequest, + challenge: 'test-challenge', + boundCheckSnarkKey: 'SGVsbG8gV29ybGQ=', + skipSigning: true, + }); + + const callArgs = generateSpy.mock.calls[0][0]; + expect(typeof callArgs.loadProvingKey).toBe('function'); + }); + + it('should always append id to attributesToReveal', async () => { + generateSpy.mockResolvedValueOnce({ + status: pexUtils.GeneratePresentationStatus.SUCCESS, + presentation: {type: ['VerifiablePresentation']}, + }); + + await service.generatePresentationFromPex({ + credentials: [ + { + credential: baseCredential, + attributesToReveal: ['credentialSubject.name'], + }, + {credential: baseCredential}, + ], + pexRequest: basePexRequest, + challenge: 'test-challenge', + skipSigning: true, + }); + + const callArgs = generateSpy.mock.calls[0][0]; + expect(callArgs.selectiveDisclosure.credentials[0].attributes).toEqual([ + 'credentialSubject.name', + 'id', + ]); + expect(callArgs.selectiveDisclosure.credentials[1].attributes).toEqual([ + 'id', + ]); + }); + }); }); diff --git a/packages/wasm/src/services/credential/service-rpc.js b/packages/wasm/src/services/credential/service-rpc.js index 6b17b005..4dd1bf48 100644 --- a/packages/wasm/src/services/credential/service-rpc.js +++ b/packages/wasm/src/services/credential/service-rpc.js @@ -44,4 +44,7 @@ export class CredentialServiceRPC extends RpcService { async acquireOIDCredential(params) { return this.call('acquireOIDCredential', params); } + async generatePresentationFromPex(params) { + return this.call('generatePresentationFromPex', params); + } } diff --git a/packages/wasm/src/services/credential/service.ts b/packages/wasm/src/services/credential/service.ts index 92ba0e4b..b9363238 100644 --- a/packages/wasm/src/services/credential/service.ts +++ b/packages/wasm/src/services/credential/service.ts @@ -33,7 +33,15 @@ import { applyEnforceBounds, hasProvingKey, fetchProvingKey, + isBase64OrDataUrl, + blobFromBase64, + fetchBlobFromUrl, } from './bound-check'; +import { + generatePresentationFromPexRequest, + GeneratePresentationStatus, +} from '@docknetwork/credential-sdk/pex'; +import {LegoProvingKey} from '@docknetwork/crypto-wasm-ts/lib/legosnark'; import assert from 'assert'; import axios from 'axios'; import {getIsRevoked, getWitnessDetails} from './bbs-revocation'; @@ -47,6 +55,71 @@ import {isSDJWTCredential as checkIsSDJWT, credentialToW3C as convertCredentialT */ const pex: PEX = new PEX(); +/** + * Resolves the accumulator module class for a credential based on its status ID. + */ +function getAccumulatorModuleClass(credential) { + const statusId = credential?.credentialStatus?.id; + if (!statusId) { + throw new Error('Credential is missing credentialStatus.id required for witness resolution'); + } + const chainModule = + statusId.indexOf('dock:accumulator') === 0 + ? blockchainService.modules.accumulator.modules[0] + : blockchainService.modules.accumulator.modules[ + blockchainService.modules.accumulator.modules.length - 1 + ]; + return chainModule.constructor; +} + +/** + * Resolves the witness for a single credential into the format expected by credential-sdk. + * Returns undefined if the credential has no witness. + */ +async function resolveWitnessForCredential(credential, witness) { + if (!witness) { + return undefined; + } + + try { + const details = await getWitnessDetails(credential, witness); + const accumulatorModuleClass = getAccumulatorModuleClass(credential); + return { + membershipWitness: details.membershipWitness, + accumulated: accumulatorModuleClass.accumulatedFromHex( + details.accumulator.accumulated, + AccumulatorType.VBPos, + ), + pk: details.pk, + params: details.params, + }; + } catch (err) { + throw new Error( + `Failed to resolve witness for credential ${credential?.id || 'unknown'}: ${err.message}`, + ); + } +} + +/** + * Creates a loadProvingKey callback for bound check proofs. + * Returns undefined if no boundCheckSnarkKey is provided. + */ +function createProvingKeyLoader(boundCheckSnarkKey) { + if (!boundCheckSnarkKey) { + return undefined; + } + + return async () => { + const blob = (await isBase64OrDataUrl(boundCheckSnarkKey)) + ? blobFromBase64(boundCheckSnarkKey) + : await fetchBlobFromUrl(boundCheckSnarkKey); + return { + provingKey: new LegoProvingKey(blob), + provingKeyId: 'key0', + }; + }; +} + /** * Checks if a credential uses BBS+ signature * @param {Object} credential - The credential to check @@ -122,6 +195,7 @@ class CredentialService { CredentialService.prototype.credentialToW3C, CredentialService.prototype.createSDJWTPresentation, CredentialService.prototype.acquireOIDCredential, + CredentialService.prototype.generatePresentationFromPex, ]; @@ -739,6 +813,48 @@ class CredentialService { return credentialsFromPresentation; } + async generatePresentationFromPex(params) { + validation.generatePresentationFromPex(params); + const { + credentials, + pexRequest, + holderKeyDoc, + holderDid, + challenge, + domain, + boundCheckSnarkKey, + skipSigning, + } = params; + + const resolvedWitnesses = await Promise.all( + credentials.map(c => resolveWitnessForCredential(c.credential, c.witness)), + ); + + const result = await generatePresentationFromPexRequest({ + credentials: credentials.map(c => c.credential), + pexRequest, + holderKeyDoc, + holderDid, + challenge, + domain, + resolver: blockchainService.resolver, + skipSigning: skipSigning || false, + loadProvingKey: createProvingKeyLoader(boundCheckSnarkKey), + selectiveDisclosure: { + credentials: credentials.map((c, i) => ({ + attributes: [...(c.attributesToReveal || []), 'id'], + witness: resolvedWitnesses[i], + })), + }, + }); + + if (result.status !== GeneratePresentationStatus.SUCCESS) { + throw result.error || new Error(`Presentation generation failed: ${result.status}`); + } + + return result.presentation; + } + /** * Test method for range proofs * @private