diff --git a/packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts b/packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts index 9629759b46..27d29efcf5 100644 --- a/packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts +++ b/packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts @@ -40,7 +40,11 @@ export type SdJwtVcIssuer = SdJwtVcIssuerDid export interface SdJwtVcSignOptions { payload: Payload - holder: SdJwtVcHolderBinding + + /** + * If holder is not provided, we don't bind the SD-JWT VC to a key (so bearer VC) + */ + holder?: SdJwtVcHolderBinding issuer: SdJwtVcIssuer disclosureFrame?: IDisclosureFrame @@ -63,8 +67,10 @@ export type SdJwtVcPresentOptions, { header } ) @@ -134,20 +138,27 @@ export class SdJwtVcService { }) const sdJwtVc = await sdjwt.decode(compactSdJwtVc) - const holder = await this.extractKeyFromHolderBinding(agentContext, this.parseHolderBindingFromCredential(sdJwtVc)) + const holderBinding = this.parseHolderBindingFromCredential(sdJwtVc) + if (!holderBinding && verifierMetadata) { + throw new SdJwtVcError("Verifier metadata provided, but credential has no 'cnf' claim to create a KB-JWT from") + } + + const holder = holderBinding ? await this.extractKeyFromHolderBinding(agentContext, holderBinding) : undefined sdjwt.config({ - kbSigner: this.signer(agentContext, holder.key), - kbSignAlg: holder.alg, + kbSigner: holder ? this.signer(agentContext, holder.key) : undefined, + kbSignAlg: holder?.alg, }) const compactDerivedSdJwtVc = await sdjwt.present(compactSdJwtVc, presentationFrame as PresentationFrame, { - kb: { - payload: { - iat: verifierMetadata.issuedAt, - nonce: verifierMetadata.nonce, - aud: verifierMetadata.audience, - }, - }, + kb: verifierMetadata + ? { + payload: { + iat: verifierMetadata.issuedAt, + nonce: verifierMetadata.nonce, + aud: verifierMetadata.audience, + }, + } + : undefined, }) return compactDerivedSdJwtVc @@ -166,11 +177,12 @@ export class SdJwtVcService { } const issuer = await this.extractKeyFromIssuer(agentContext, this.parseIssuerFromCredential(sdJwtVc)) - const holder = await this.extractKeyFromHolderBinding(agentContext, this.parseHolderBindingFromCredential(sdJwtVc)) + const holderBinding = this.parseHolderBindingFromCredential(sdJwtVc) + const holder = holderBinding ? await this.extractKeyFromHolderBinding(agentContext, holderBinding) : undefined sdjwt.config({ verifier: this.verifier(agentContext, issuer.key), - kbVerifier: this.verifier(agentContext, holder.key), + kbVerifier: holder ? this.verifier(agentContext, holder.key) : undefined, }) const verificationResult: VerificationResult = { @@ -178,7 +190,7 @@ export class SdJwtVcService { isSignatureValid: false, } - await sdjwt.verify(compactSdJwtVc, requiredClaimKeys, !!keyBinding) + await sdjwt.verify(compactSdJwtVc, requiredClaimKeys, keyBinding !== undefined) verificationResult.isValid = true verificationResult.isSignatureValid = true @@ -206,6 +218,7 @@ export class SdJwtVcService { } } catch (error) { verificationResult.isKeyBindingValid = false + verificationResult.containsExpectedKeyBinding = false verificationResult.isValid = false } @@ -369,13 +382,13 @@ export class SdJwtVcService { private parseHolderBindingFromCredential
( sdJwtVc: SDJwt - ): SdJwtVcHolderBinding { + ): SdJwtVcHolderBinding | null { if (!sdJwtVc.jwt?.payload) { throw new SdJwtVcError('Credential not exist') } if (!sdJwtVc.jwt?.payload['cnf']) { - throw new SdJwtVcError('Credential does not contain a holder binding') + return null } const cnf: CnfPayload = sdJwtVc.jwt.payload['cnf'] diff --git a/packages/core/src/modules/sd-jwt-vc/__tests__/SdJwtVcService.test.ts b/packages/core/src/modules/sd-jwt-vc/__tests__/SdJwtVcService.test.ts index a46ca47f60..c49f48e772 100644 --- a/packages/core/src/modules/sd-jwt-vc/__tests__/SdJwtVcService.test.ts +++ b/packages/core/src/modules/sd-jwt-vc/__tests__/SdJwtVcService.test.ts @@ -1,6 +1,8 @@ import type { SdJwtVcHeader } from '../SdJwtVcOptions' import type { Jwk, Key } from '@credo-ts/core' +import { randomUUID } from 'crypto' + import { getInMemoryAgentOptions } from '../../../../tests' import { SdJwtVcService } from '../SdJwtVcService' import { SdJwtVcRepository } from '../repository' @@ -12,6 +14,7 @@ import { sdJwtVcWithSingleDisclosurePresentation, simpleJwtVc, simpleJwtVcPresentation, + simpleJwtVcWithoutHolderBinding, } from './sdjwtvc.fixtures' import { @@ -105,7 +108,7 @@ describe('SdJwtVcService', () => { expect(compact).toStrictEqual(simpleJwtVc) - const sdJwtVc = await sdJwtVcService.fromCompact(compact) + const sdJwtVc = sdJwtVcService.fromCompact(compact) expect(sdJwtVc.header).toEqual({ alg: 'EdDSA', @@ -124,6 +127,36 @@ describe('SdJwtVcService', () => { }) }) + test('Sign sd-jwt-vc from a basic payload without holder binding', async () => { + const { compact } = await sdJwtVcService.sign(agent.context, { + payload: { + claim: 'some-claim', + vct: 'IdentityCredential', + }, + issuer: { + method: 'did', + didUrl: issuerDidUrl, + }, + }) + + expect(compact).toStrictEqual(simpleJwtVcWithoutHolderBinding) + + const sdJwtVc = sdJwtVcService.fromCompact(compact) + + expect(sdJwtVc.header).toEqual({ + alg: 'EdDSA', + typ: 'vc+sd-jwt', + kid: '#z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW', + }) + + expect(sdJwtVc.prettyClaims).toEqual({ + claim: 'some-claim', + vct: 'IdentityCredential', + iat: Math.floor(new Date().getTime() / 1000), + iss: parseDid(issuerDidUrl).did, + }) + }) + test('Sign sd-jwt-vc from a basic payload including false boolean values', async () => { const { compact } = await sdJwtVcService.sign(agent.context, { payload: { @@ -144,7 +177,7 @@ describe('SdJwtVcService', () => { }, }) - const sdJwtVc = await sdJwtVcService.fromCompact(compact) + const sdJwtVc = sdJwtVcService.fromCompact(compact) expect(sdJwtVc.header).toEqual({ alg: 'EdDSA', @@ -390,7 +423,7 @@ describe('SdJwtVcService', () => { describe('SdJwtVcService.receive', () => { test('Receive sd-jwt-vc from a basic payload without disclosures', async () => { - const sdJwtVc = await sdJwtVcService.fromCompact(simpleJwtVc) + const sdJwtVc = sdJwtVcService.fromCompact(simpleJwtVc) const sdJwtVcRecord = await sdJwtVcService.store(agent.context, sdJwtVc.compact) expect(sdJwtVcRecord.compactSdJwtVc).toEqual(simpleJwtVc) @@ -411,8 +444,27 @@ describe('SdJwtVcService', () => { }) }) + test('Receive sd-jwt-vc without holder binding', async () => { + const sdJwtVc = sdJwtVcService.fromCompact(simpleJwtVcWithoutHolderBinding) + const sdJwtVcRecord = await sdJwtVcService.store(agent.context, simpleJwtVcWithoutHolderBinding) + expect(sdJwtVcRecord.compactSdJwtVc).toEqual(simpleJwtVcWithoutHolderBinding) + + expect(sdJwtVc.header).toEqual({ + alg: 'EdDSA', + typ: 'vc+sd-jwt', + kid: '#z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW', + }) + + expect(sdJwtVc.payload).toEqual({ + claim: 'some-claim', + vct: 'IdentityCredential', + iat: Math.floor(new Date().getTime() / 1000), + iss: issuerDidUrl.split('#')[0], + }) + }) + test('Receive sd-jwt-vc from a basic payload with a disclosure', async () => { - const sdJwtVc = await sdJwtVcService.fromCompact(sdJwtVcWithSingleDisclosure) + const sdJwtVc = sdJwtVcService.fromCompact(sdJwtVcWithSingleDisclosure) expect(sdJwtVc.header).toEqual({ alg: 'EdDSA', @@ -437,7 +489,7 @@ describe('SdJwtVcService', () => { }) test('Receive sd-jwt-vc from a basic payload with multiple (nested) disclosure', async () => { - const sdJwtVc = await sdJwtVcService.fromCompact(complexSdJwtVc) + const sdJwtVc = sdJwtVcService.fromCompact(complexSdJwtVc) expect(sdJwtVc.header).toEqual({ alg: 'EdDSA', @@ -525,6 +577,30 @@ describe('SdJwtVcService', () => { expect(presentation).toStrictEqual(simpleJwtVcPresentation) }) + test('Present sd-jwt-vc without holder binding', async () => { + const presentation = await sdJwtVcService.present(agent.context, { + compactSdJwtVc: simpleJwtVcWithoutHolderBinding, + presentationFrame: {}, + }) + + // Input should be the same as output + expect(presentation).toStrictEqual(simpleJwtVcWithoutHolderBinding) + }) + + test('Errors when providing verifier metadata but SD-JWT VC has no cnf claim', async () => { + await expect( + sdJwtVcService.present(agent.context, { + compactSdJwtVc: simpleJwtVcWithoutHolderBinding, + presentationFrame: {}, + verifierMetadata: { + audience: 'verifier', + issuedAt: Date.now() / 1000, + nonce: randomUUID(), + }, + }) + ).rejects.toThrow("Verifier metadata provided, but credential has no 'cnf' claim to create a KB-JWT from") + }) + test('Present sd-jwt-vc from a basic payload with a disclosure', async () => { const presentation = await sdJwtVcService.present(agent.context, { compactSdJwtVc: sdJwtVcWithSingleDisclosure, @@ -598,6 +674,25 @@ describe('SdJwtVcService', () => { }) }) + test('Verify sd-jwt-vc without holder binding', async () => { + const presentation = await sdJwtVcService.present(agent.context, { + compactSdJwtVc: simpleJwtVcWithoutHolderBinding, + // no disclosures + presentationFrame: {}, + }) + + const { verification } = await sdJwtVcService.verify(agent.context, { + compactSdJwtVc: presentation, + requiredClaimKeys: ['claim'], + }) + + expect(verification).toEqual({ + isSignatureValid: true, + areRequiredClaimsIncluded: true, + isValid: true, + }) + }) + test('Verify sd-jwt-vc with a disclosure', async () => { const nonce = await agent.context.wallet.generateNonce() diff --git a/packages/core/src/modules/sd-jwt-vc/__tests__/sdjwtvc.fixtures.ts b/packages/core/src/modules/sd-jwt-vc/__tests__/sdjwtvc.fixtures.ts index e633c3bb3f..12bb9247b0 100644 --- a/packages/core/src/modules/sd-jwt-vc/__tests__/sdjwtvc.fixtures.ts +++ b/packages/core/src/modules/sd-jwt-vc/__tests__/sdjwtvc.fixtures.ts @@ -27,6 +27,28 @@ export const simpleJwtVc = 'eyJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFZERTQSIsImtpZCI6IiN6Nk1rdHF0WE5HOENEVVk5UHJydG9TdEZ6ZUNuaHBNbWd4WUwxZ2lrY1czQnp2TlcifQ.eyJjbGFpbSI6InNvbWUtY2xhaW0iLCJ2Y3QiOiJJZGVudGl0eUNyZWRlbnRpYWwiLCJjbmYiOnsiandrIjp7Imt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4Ijoib0VOVnN4T1VpSDU0WDh3SkxhVmtpY0NSazAwd0JJUTRzUmdiazU0TjhNbyJ9fSwiaXNzIjoiZGlkOmtleTp6Nk1rdHF0WE5HOENEVVk5UHJydG9TdEZ6ZUNuaHBNbWd4WUwxZ2lrY1czQnp2TlciLCJpYXQiOjE2OTgxNTE1MzJ9.vLkigrBr1IIVRJeYE5DQx0rKUVzO3KT9T0XBATWJE89pWCyvB3Rzs8VD7qfi0vDk_QVCPIiHq1U1PsmSe4ZqCg~' +/**simpleJwtVcWithoutHolderBinding + { + "jwt": { + "header": { + "typ": "vc+sd-jwt", + "alg": "EdDSA", + "kid": "#z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW" + }, + "payload": { + "claim": "some-claim", + "vct": "IdentityCredential", + "iss": "did:key:z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW", + "iat": 1698151532 + }, + "signature": "vLkigrBr1IIVRJeYE5DQx0rKUVzO3KT9T0XBATWJE89pWCyvB3Rzs8VD7qfi0vDk_QVCPIiHq1U1PsmSe4ZqCg" + }, + "disclosures": [] + } + */ +export const simpleJwtVcWithoutHolderBinding = + 'eyJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFZERTQSIsImtpZCI6IiN6Nk1rdHF0WE5HOENEVVk5UHJydG9TdEZ6ZUNuaHBNbWd4WUwxZ2lrY1czQnp2TlcifQ.eyJjbGFpbSI6InNvbWUtY2xhaW0iLCJ2Y3QiOiJJZGVudGl0eUNyZWRlbnRpYWwiLCJpc3MiOiJkaWQ6a2V5Ono2TWt0cXRYTkc4Q0RVWTlQcnJ0b1N0RnplQ25ocE1tZ3hZTDFnaWtjVzNCenZOVyIsImlhdCI6MTY5ODE1MTUzMn0.TsFJUFKwdw5kVL4eY5vHOPGHqXBCFJ-n9c9KwPHkXAVfZ1TZkGA8m0_sNuTDy5n_pCutS6uzKJDAM0dfeGPyDg~' + /**simpleJwtVcPresentation * { "jwt": {