Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sd-jwt issuance without holder binding #1871

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ export type SdJwtVcIssuer = SdJwtVcIssuerDid

export interface SdJwtVcSignOptions<Payload extends SdJwtVcPayload = SdJwtVcPayload> {
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

Expand All @@ -63,8 +67,10 @@ export type SdJwtVcPresentOptions<Payload extends SdJwtVcPayload = SdJwtVcPayloa
/**
* This information is received out-of-band from the verifier.
* The claims will be used to create a normal JWT, used for key binding.
*
* If not defined, a KB-JWT will not be created
*/
verifierMetadata: {
verifierMetadata?: {
audience: string
nonce: string
issuedAt: number
Expand Down
47 changes: 30 additions & 17 deletions packages/core/src/modules/sd-jwt-vc/SdJwtVcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ export class SdJwtVcService {
}

const issuer = await this.extractKeyFromIssuer(agentContext, options.issuer)
const holderBinding = await this.extractKeyFromHolderBinding(agentContext, options.holder)

// holer binding is optional
const holderBinding = options.holder
? await this.extractKeyFromHolderBinding(agentContext, options.holder)
: undefined

const header = {
alg: issuer.alg,
Expand All @@ -90,7 +94,7 @@ export class SdJwtVcService {
})

const compact = await sdjwt.issue(
{ ...payload, cnf: holderBinding.cnf, iss: issuer.iss, iat: Math.floor(new Date().getTime() / 1000) },
{ ...payload, cnf: holderBinding?.cnf, iss: issuer.iss, iat: Math.floor(new Date().getTime() / 1000) },
disclosureFrame as DisclosureFrame<Payload>,
{ header }
)
Expand Down Expand Up @@ -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<Payload>, {
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
Expand All @@ -166,19 +177,20 @@ 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 = {
isValid: false,
isSignatureValid: false,
}

await sdjwt.verify(compactSdJwtVc, requiredClaimKeys, !!keyBinding)
await sdjwt.verify(compactSdJwtVc, requiredClaimKeys, keyBinding !== undefined)

verificationResult.isValid = true
verificationResult.isSignatureValid = true
Expand Down Expand Up @@ -206,6 +218,7 @@ export class SdJwtVcService {
}
} catch (error) {
verificationResult.isKeyBindingValid = false
verificationResult.containsExpectedKeyBinding = false
verificationResult.isValid = false
}

Expand Down Expand Up @@ -369,13 +382,13 @@ export class SdJwtVcService {

private parseHolderBindingFromCredential<Header extends SdJwtVcHeader, Payload extends SdJwtVcPayload>(
sdJwtVc: SDJwt<Header, Payload>
): 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']

Expand Down
105 changes: 100 additions & 5 deletions packages/core/src/modules/sd-jwt-vc/__tests__/SdJwtVcService.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,6 +14,7 @@ import {
sdJwtVcWithSingleDisclosurePresentation,
simpleJwtVc,
simpleJwtVcPresentation,
simpleJwtVcWithoutHolderBinding,
} from './sdjwtvc.fixtures'

import {
Expand Down Expand Up @@ -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',
Expand All @@ -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: {
Expand All @@ -144,7 +177,7 @@ describe('SdJwtVcService', () => {
},
})

const sdJwtVc = await sdJwtVcService.fromCompact(compact)
const sdJwtVc = sdJwtVcService.fromCompact(compact)

expect(sdJwtVc.header).toEqual({
alg: 'EdDSA',
Expand Down Expand Up @@ -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)

Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading