Skip to content

Commit

Permalink
feat: fetch sd-jwt type metadata (#2092)
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <[email protected]>
  • Loading branch information
TimoGlastra authored Nov 20, 2024
1 parent 17ec6b8 commit 607659a
Show file tree
Hide file tree
Showing 13 changed files with 210 additions and 64 deletions.
6 changes: 6 additions & 0 deletions .changeset/hungry-ducks-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@credo-ts/core": patch
"@credo-ts/openid4vc": patch
---

feat: fetch sd-jwt type metadata
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"eslint-plugin-workspaces": "^0.8.0",
"express": "^4.17.1",
"jest": "^29.7.0",
"nock": "^14.0.0-beta.15",
"nock": "^14.0.0-beta.16",
"prettier": "^2.3.1",
"rxjs": "^7.8.0",
"supertest": "^7.0.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@types/varint": "^6.0.0",
"rimraf": "^4.4.0",
"tslog": "^4.8.2",
"typescript": "~5.5.2"
"typescript": "~5.5.2",
"nock": "^14.0.0-beta.16"
}
}
7 changes: 7 additions & 0 deletions packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,11 @@ export type SdJwtVcVerifyOptions = {

// TODO: update to requiredClaimFrame
requiredClaimKeys?: Array<string>

/**
* Whether to fetch the `vct` type metadata if the `vct` is an https URL.
*
* It will will not influence the verification result if fetching of type metadata fails
*/
fetchTypeMetadata?: boolean
}
97 changes: 55 additions & 42 deletions packages/core/src/modules/sd-jwt-vc/SdJwtVcService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,14 @@ import type {
import type { JwkJson, Key } from '../../crypto'
import type { Query, QueryOptions } from '../../storage/StorageService'
import type { SDJwt } from '@sd-jwt/core'
import type { Signer, Verifier, HasherSync, PresentationFrame, DisclosureFrame } from '@sd-jwt/types'
import type { Signer, Verifier, PresentationFrame, DisclosureFrame } from '@sd-jwt/types'

import { decodeSdJwtSync, getClaimsSync } from '@sd-jwt/decode'
import { SDJwtVcInstance } from '@sd-jwt/sd-jwt-vc'
import { uint8ArrayToBase64Url } from '@sd-jwt/utils'
import { injectable } from 'tsyringe'

import { AgentContext } from '../../agent'
import { JwtPayload, Jwk, getJwkFromJson, getJwkFromKey, Hasher } from '../../crypto'
import { JwtPayload, Jwk, getJwkFromJson, getJwkFromKey } from '../../crypto'
import { CredoError } from '../../error'
import { X509Service } from '../../modules/x509/X509Service'
import { TypedArrayEncoder, nowInSeconds } from '../../utils'
Expand All @@ -28,7 +27,9 @@ import { DidResolverService, parseDid, getKeyFromVerificationMethod } from '../d
import { X509Certificate, X509ModuleConfig } from '../x509'

import { SdJwtVcError } from './SdJwtVcError'
import { decodeSdJwtVc, sdJwtVcHasher } from './decodeSdJwtVc'
import { SdJwtVcRecord, SdJwtVcRepository } from './repository'
import { SdJwtVcTypeMetadata } from './typeMetadata'

type SdJwtVcConfig = SDJwtVcInstance['userConfig']

Expand All @@ -42,6 +43,8 @@ export interface SdJwtVc<
// TODO: payload type here is a lie, as it is the signed payload (so fields replaced with _sd)
payload: Payload
prettyClaims: Payload

typeMetadata?: SdJwtVcTypeMetadata
}

export interface CnfPayload {
Expand Down Expand Up @@ -134,18 +137,10 @@ export class SdJwtVcService {
}

public fromCompact<Header extends SdJwtVcHeader = SdJwtVcHeader, Payload extends SdJwtVcPayload = SdJwtVcPayload>(
compactSdJwtVc: string
compactSdJwtVc: string,
typeMetadata?: SdJwtVcTypeMetadata
): SdJwtVc<Header, Payload> {
// NOTE: we use decodeSdJwtSync so we can make this method sync
const { jwt, disclosures } = decodeSdJwtSync(compactSdJwtVc, this.hasher)
const prettyClaims = getClaimsSync(jwt.payload, disclosures, this.hasher)

return {
compact: compactSdJwtVc,
header: jwt.header as Header,
payload: jwt.payload as Payload,
prettyClaims: prettyClaims as Payload,
}
return decodeSdJwtVc(compactSdJwtVc, typeMetadata)
}

public async present<Payload extends SdJwtVcPayload = SdJwtVcPayload>(
Expand Down Expand Up @@ -196,18 +191,24 @@ export class SdJwtVcService {

public async verify<Header extends SdJwtVcHeader = SdJwtVcHeader, Payload extends SdJwtVcPayload = SdJwtVcPayload>(
agentContext: AgentContext,
{ compactSdJwtVc, keyBinding, requiredClaimKeys }: SdJwtVcVerifyOptions
{ compactSdJwtVc, keyBinding, requiredClaimKeys, fetchTypeMetadata }: SdJwtVcVerifyOptions
): Promise<
| { isValid: true; verification: VerificationResult; sdJwtVc: SdJwtVc<Header, Payload> }
| { isValid: false; verification: VerificationResult; sdJwtVc?: SdJwtVc<Header, Payload>; error: Error }
> {
const sdjwt = new SDJwtVcInstance(this.getBaseSdJwtConfig(agentContext))
const sdjwt = new SDJwtVcInstance({
...this.getBaseSdJwtConfig(agentContext),
// FIXME: will break if using url but no type metadata
// https://github.com/openwallet-foundation/sd-jwt-js/issues/258
// loadTypeMetadataFormat: false,
})

const verificationResult: VerificationResult = {
isValid: false,
}

let sdJwtVc: SDJwt
let _error: Error | undefined = undefined

try {
sdJwtVc = await sdjwt.decode(compactSdJwtVc)
Expand All @@ -224,7 +225,7 @@ export class SdJwtVcService {
payload: sdJwtVc.jwt.payload as Payload,
header: sdJwtVc.jwt.header as Header,
compact: compactSdJwtVc,
prettyClaims: await sdJwtVc.getClaims(this.hasher),
prettyClaims: await sdJwtVc.getClaims(sdJwtVcHasher),
} satisfies SdJwtVc<Header, Payload>

try {
Expand All @@ -247,26 +248,18 @@ export class SdJwtVcService {
verificationResult.areRequiredClaimsIncluded = true
verificationResult.isStatusValid = true
} catch (error) {
return {
verification: verificationResult,
error,
isValid: false,
sdJwtVc: returnSdJwtVc,
}
_error = error
verificationResult.isSignatureValid = false
verificationResult.areRequiredClaimsIncluded = false
verificationResult.isStatusValid = false
}

try {
JwtPayload.fromJson(returnSdJwtVc.payload).validate()
verificationResult.isValidJwtPayload = true
} catch (error) {
_error = error
verificationResult.isValidJwtPayload = false

return {
isValid: false,
error,
verification: verificationResult,
sdJwtVc: returnSdJwtVc,
}
}

// If keyBinding is present, verify the key binding
Expand All @@ -290,16 +283,28 @@ export class SdJwtVcService {
verificationResult.containsRequiredVcProperties = true
}
} catch (error) {
_error = error
verificationResult.isKeyBindingValid = false
verificationResult.containsExpectedKeyBinding = false
verificationResult.isValid = false
verificationResult.containsRequiredVcProperties = false
}

return {
isValid: false,
error,
verification: verificationResult,
sdJwtVc: returnSdJwtVc,
try {
const vct = returnSdJwtVc.payload?.vct
if (fetchTypeMetadata && typeof vct === 'string' && vct.startsWith('https://')) {
// modify the uri based on https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-04.html#section-6.3.1
const vctElements = vct.split('/')
vctElements.splice(3, 0, '.well-known/vct')
const vctUrl = vctElements.join('/')

const response = await agentContext.config.agentDependencies.fetch(vctUrl)
if (response.ok) {
const typeMetadata = await response.json()
returnSdJwtVc.typeMetadata = typeMetadata as SdJwtVcTypeMetadata
}
}
} catch (error) {
// we allow vct without type metadata for now
}
} catch (error) {
verificationResult.isValid = false
Expand All @@ -311,7 +316,19 @@ export class SdJwtVcService {
}
}

verificationResult.isValid = true
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { isValid: _, ...allVerifications } = verificationResult
verificationResult.isValid = Object.values(allVerifications).every((verification) => verification === true)

if (_error) {
return {
isValid: false,
error: _error,
sdJwtVc: returnSdJwtVc,
verification: verificationResult,
}
}

return {
isValid: true,
verification: verificationResult,
Expand Down Expand Up @@ -595,16 +612,12 @@ export class SdJwtVcService {

private getBaseSdJwtConfig(agentContext: AgentContext): SdJwtVcConfig {
return {
hasher: this.hasher,
hasher: sdJwtVcHasher,
statusListFetcher: this.getStatusListFetcher(agentContext),
saltGenerator: agentContext.wallet.generateNonce,
}
}

private get hasher(): HasherSync {
return Hasher.hash
}

private getStatusListFetcher(agentContext: AgentContext) {
return async (uri: string) => {
const response = await fetchWithTimeout(agentContext.config.agentDependencies.fetch, uri)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,10 @@ describe('SdJwtVcService', () => {
sdJwtVc: expect.any(Object),
verification: {
isValid: false,
areRequiredClaimsIncluded: false,
isSignatureValid: false,
isStatusValid: false,
isValidJwtPayload: true,
},
error: new SDJWTException('Status is not valid'),
})
Expand Down Expand Up @@ -980,7 +984,11 @@ describe('SdJwtVcService', () => {
isValid: false,
sdJwtVc: expect.any(Object),
verification: {
areRequiredClaimsIncluded: false,
isSignatureValid: false,
isStatusValid: false,
isValid: false,
isValidJwtPayload: true,
},
error: new Error('Index out of bounds'),
})
Expand Down Expand Up @@ -1149,7 +1157,11 @@ describe('SdJwtVcService', () => {
expect(verificationResult).toEqual({
isValid: false,
verification: {
areRequiredClaimsIncluded: false,
isSignatureValid: false,
isStatusValid: false,
isValid: false,
isValidJwtPayload: true,
},
error: new SDJWTException('Verify Error: Invalid JWT Signature'),
sdJwtVc: expect.any(Object),
Expand All @@ -1165,6 +1177,10 @@ describe('SdJwtVcService', () => {
isValid: false,
verification: {
isValid: false,
areRequiredClaimsIncluded: false,
isSignatureValid: false,
isStatusValid: false,
isValidJwtPayload: true,
},
error: new SDJWTException('Verify Error: Invalid JWT Signature'),
sdJwtVc: expect.any(Object),
Expand Down
21 changes: 16 additions & 5 deletions packages/core/src/modules/sd-jwt-vc/__tests__/sdJwtVc.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Key } from '@credo-ts/core'

import nock, { cleanAll } from 'nock'

import { getInMemoryAgentOptions } from '../../../../tests'

import { Agent, DidKey, getJwkFromKey, KeyType, TypedArrayEncoder } from '@credo-ts/core'
Expand Down Expand Up @@ -37,8 +39,10 @@ describe('sd-jwt-vc end to end test', () => {
})

test('end to end flow', async () => {
nock('https://example.com').get('/.well-known/vct/vct-type').reply(200, { vct: 'https://example.com/vct-type' })

const credential = {
vct: 'IdentityCredential',
vct: 'https://example.com/vct-type',
given_name: 'John',
family_name: 'Doe',
email: '[email protected]',
Expand Down Expand Up @@ -129,7 +133,7 @@ describe('sd-jwt-vc end to end test', () => {
},
iat: expect.any(Number),
iss: 'did:key:z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW',
vct: 'IdentityCredential',
vct: 'https://example.com/vct-type',
},
prettyClaims: {
address: {
Expand All @@ -155,15 +159,20 @@ describe('sd-jwt-vc end to end test', () => {
is_over_65: true,
iss: 'did:key:z6MktqtXNG8CDUY9PrrtoStFzeCnhpMmgxYL1gikcW3BzvNW',
phone_number: '+1-202-555-0101',
vct: 'IdentityCredential',
vct: 'https://example.com/vct-type',
},
typeMetadata: undefined,
})

// Verify SD-JWT (does not require key binding)
const { verification } = await holder.sdJwtVc.verify({
const verificationResult = await holder.sdJwtVc.verify({
compactSdJwtVc: compact,
fetchTypeMetadata: true,
})
expect(verificationResult.verification.isValid).toBe(true)
expect(verificationResult.sdJwtVc?.typeMetadata).toEqual({
vct: 'https://example.com/vct-type',
})
expect(verification.isValid).toBe(true)

// Store credential
await holder.sdJwtVc.store(compact)
Expand Down Expand Up @@ -217,5 +226,7 @@ describe('sd-jwt-vc end to end test', () => {
})

expect(presentationVerification.isValid).toBeTruthy()

cleanAll()
})
})
27 changes: 27 additions & 0 deletions packages/core/src/modules/sd-jwt-vc/decodeSdJwtVc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { SdJwtVcHeader, SdJwtVcPayload } from './SdJwtVcOptions'
import type { SdJwtVcTypeMetadata } from './typeMetadata'

import { decodeSdJwtSync, getClaimsSync } from '@sd-jwt/decode'

import { Hasher } from '../../crypto'

export function sdJwtVcHasher(data: string | ArrayBufferLike, alg: string) {
return Hasher.hash(typeof data === 'string' ? data : new Uint8Array(data), alg)
}

export function decodeSdJwtVc<
Header extends SdJwtVcHeader = SdJwtVcHeader,
Payload extends SdJwtVcPayload = SdJwtVcPayload
>(compactSdJwtVc: string, typeMetadata?: SdJwtVcTypeMetadata) {
// NOTE: we use decodeSdJwtSync so we can make this method sync
const { jwt, disclosures } = decodeSdJwtSync(compactSdJwtVc, sdJwtVcHasher)
const prettyClaims = getClaimsSync(jwt.payload, disclosures, sdJwtVcHasher)

return {
compact: compactSdJwtVc,
header: jwt.header as Header,
payload: jwt.payload as Payload,
prettyClaims: prettyClaims as Payload,
typeMetadata,
}
}
1 change: 1 addition & 0 deletions packages/core/src/modules/sd-jwt-vc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './SdJwtVcService'
export * from './SdJwtVcError'
export * from './SdJwtVcOptions'
export * from './repository'
export * from './typeMetadata'
Loading

0 comments on commit 607659a

Please sign in to comment.