From 607659ac16313ceacbcc09ed4cf94d8f0bf27e44 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Wed, 20 Nov 2024 13:57:43 +0100 Subject: [PATCH] feat: fetch sd-jwt type metadata (#2092) Signed-off-by: Timo Glastra --- .changeset/hungry-ducks-yawn.md | 6 ++ package.json | 2 +- packages/core/package.json | 3 +- .../src/modules/sd-jwt-vc/SdJwtVcOptions.ts | 7 ++ .../src/modules/sd-jwt-vc/SdJwtVcService.ts | 97 +++++++++++-------- .../__tests__/SdJwtVcService.test.ts | 16 +++ .../sd-jwt-vc/__tests__/sdJwtVc.test.ts | 21 +++- .../src/modules/sd-jwt-vc/decodeSdJwtVc.ts | 27 ++++++ packages/core/src/modules/sd-jwt-vc/index.ts | 1 + .../sd-jwt-vc/repository/SdJwtVcRecord.ts | 14 ++- .../src/modules/sd-jwt-vc/typeMetadata.ts | 59 +++++++++++ .../OpenId4VciHolderService.ts | 4 +- pnpm-lock.yaml | 17 +--- 13 files changed, 210 insertions(+), 64 deletions(-) create mode 100644 .changeset/hungry-ducks-yawn.md create mode 100644 packages/core/src/modules/sd-jwt-vc/decodeSdJwtVc.ts create mode 100644 packages/core/src/modules/sd-jwt-vc/typeMetadata.ts diff --git a/.changeset/hungry-ducks-yawn.md b/.changeset/hungry-ducks-yawn.md new file mode 100644 index 0000000000..c2c916770b --- /dev/null +++ b/.changeset/hungry-ducks-yawn.md @@ -0,0 +1,6 @@ +--- +"@credo-ts/core": patch +"@credo-ts/openid4vc": patch +--- + +feat: fetch sd-jwt type metadata diff --git a/package.json b/package.json index c041195c18..0786d91883 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index e01eb2b583..8488342433 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" } } diff --git a/packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts b/packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts index f2f9062784..1167ddc973 100644 --- a/packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts +++ b/packages/core/src/modules/sd-jwt-vc/SdJwtVcOptions.ts @@ -118,4 +118,11 @@ export type SdJwtVcVerifyOptions = { // TODO: update to requiredClaimFrame requiredClaimKeys?: Array + + /** + * 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 } diff --git a/packages/core/src/modules/sd-jwt-vc/SdJwtVcService.ts b/packages/core/src/modules/sd-jwt-vc/SdJwtVcService.ts index fcc00d214c..346e512737 100644 --- a/packages/core/src/modules/sd-jwt-vc/SdJwtVcService.ts +++ b/packages/core/src/modules/sd-jwt-vc/SdJwtVcService.ts @@ -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' @@ -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'] @@ -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 { @@ -134,18 +137,10 @@ export class SdJwtVcService { } public fromCompact
( - compactSdJwtVc: string + compactSdJwtVc: string, + typeMetadata?: SdJwtVcTypeMetadata ): SdJwtVc { - // 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( @@ -196,18 +191,24 @@ export class SdJwtVcService { public async verify
( agentContext: AgentContext, - { compactSdJwtVc, keyBinding, requiredClaimKeys }: SdJwtVcVerifyOptions + { compactSdJwtVc, keyBinding, requiredClaimKeys, fetchTypeMetadata }: SdJwtVcVerifyOptions ): Promise< | { isValid: true; verification: VerificationResult; sdJwtVc: SdJwtVc } | { isValid: false; verification: VerificationResult; sdJwtVc?: SdJwtVc; 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) @@ -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 try { @@ -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 @@ -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 @@ -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, @@ -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) 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 8fa430b367..345091c609 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 @@ -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'), }) @@ -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'), }) @@ -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), @@ -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), diff --git a/packages/core/src/modules/sd-jwt-vc/__tests__/sdJwtVc.test.ts b/packages/core/src/modules/sd-jwt-vc/__tests__/sdJwtVc.test.ts index 1df450b847..ce63972fe0 100644 --- a/packages/core/src/modules/sd-jwt-vc/__tests__/sdJwtVc.test.ts +++ b/packages/core/src/modules/sd-jwt-vc/__tests__/sdJwtVc.test.ts @@ -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' @@ -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: 'johndoe@example.com', @@ -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: { @@ -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) @@ -217,5 +226,7 @@ describe('sd-jwt-vc end to end test', () => { }) expect(presentationVerification.isValid).toBeTruthy() + + cleanAll() }) }) diff --git a/packages/core/src/modules/sd-jwt-vc/decodeSdJwtVc.ts b/packages/core/src/modules/sd-jwt-vc/decodeSdJwtVc.ts new file mode 100644 index 0000000000..81341c7878 --- /dev/null +++ b/packages/core/src/modules/sd-jwt-vc/decodeSdJwtVc.ts @@ -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, + } +} diff --git a/packages/core/src/modules/sd-jwt-vc/index.ts b/packages/core/src/modules/sd-jwt-vc/index.ts index 0d1891ea62..ed70a9f30e 100644 --- a/packages/core/src/modules/sd-jwt-vc/index.ts +++ b/packages/core/src/modules/sd-jwt-vc/index.ts @@ -4,3 +4,4 @@ export * from './SdJwtVcService' export * from './SdJwtVcError' export * from './SdJwtVcOptions' export * from './repository' +export * from './typeMetadata' diff --git a/packages/core/src/modules/sd-jwt-vc/repository/SdJwtVcRecord.ts b/packages/core/src/modules/sd-jwt-vc/repository/SdJwtVcRecord.ts index 0f3e25c919..402c220e64 100644 --- a/packages/core/src/modules/sd-jwt-vc/repository/SdJwtVcRecord.ts +++ b/packages/core/src/modules/sd-jwt-vc/repository/SdJwtVcRecord.ts @@ -1,5 +1,7 @@ import type { TagsBase } from '../../../storage/BaseRecord' import type { Constructable } from '../../../utils/mixins' +import type { SdJwtVc } from '../SdJwtVcService' +import type { SdJwtVcTypeMetadata } from '../typeMetadata' import { decodeSdJwtSync } from '@sd-jwt/decode' @@ -7,6 +9,7 @@ import { Hasher, type JwaSignatureAlgorithm } from '../../../crypto' import { BaseRecord } from '../../../storage/BaseRecord' import { JsonTransformer } from '../../../utils' import { uuid } from '../../../utils/uuid' +import { decodeSdJwtVc } from '../decodeSdJwtVc' export type DefaultSdJwtVcRecordTags = { vct: string @@ -27,6 +30,8 @@ export type SdJwtVcRecordStorageProps = { createdAt?: Date tags?: TagsBase compactSdJwtVc: string + + typeMetadata?: SdJwtVcTypeMetadata } export class SdJwtVcRecord extends BaseRecord { @@ -36,8 +41,8 @@ export class SdJwtVcRecord extends BaseRecord { // We store the sdJwtVc in compact format. public compactSdJwtVc!: string - // TODO: should we also store the pretty claims so it's not needed to - // re-calculate the hashes each time? I think for now it's fine to re-calculate + public typeMetadata?: SdJwtVcTypeMetadata + public constructor(props: SdJwtVcRecordStorageProps) { super() @@ -45,10 +50,15 @@ export class SdJwtVcRecord extends BaseRecord { this.id = props.id ?? uuid() this.createdAt = props.createdAt ?? new Date() this.compactSdJwtVc = props.compactSdJwtVc + this.typeMetadata = props.typeMetadata this._tags = props.tags ?? {} } } + public get sdJwtVc(): SdJwtVc { + return decodeSdJwtVc(this.compactSdJwtVc, this.typeMetadata) + } + public getTags() { const sdjwt = decodeSdJwtSync(this.compactSdJwtVc, Hasher.hash) const vct = sdjwt.jwt.payload['vct'] as string diff --git a/packages/core/src/modules/sd-jwt-vc/typeMetadata.ts b/packages/core/src/modules/sd-jwt-vc/typeMetadata.ts new file mode 100644 index 0000000000..69d3b37392 --- /dev/null +++ b/packages/core/src/modules/sd-jwt-vc/typeMetadata.ts @@ -0,0 +1,59 @@ +export interface SdJwtVcTypeMetadataClaim { + path: Array + display?: Array<{ + lang: string + label: string + description?: string + }> + /** + * @default allowed + */ + sd?: 'allowed' | 'always' | 'never' + svg_id?: string +} + +export interface SdJwtVcTypeMetadataRenderingMethodSimple { + logo?: { + uri: string + 'uri#integrity'?: string + alt_text?: string + } + background_color?: string + text_color?: string +} + +export interface SdJwtVcTypeMetadataRenderingMethodSvgTemplate { + uri: string + 'uri#integrity'?: string + properties?: { + orientation?: 'portrait' | 'landscape' + color_scheme?: 'light' | 'dark' + contrast?: 'normal' | 'high' + } +} + +export interface SdJwtVcTypeMetadataDisplay { + lang: string + name: string + description?: string + rendering?: { + simple?: SdJwtVcTypeMetadataRenderingMethodSimple + svg_templates?: SdJwtVcTypeMetadataRenderingMethodSvgTemplate[] + [key: string]: unknown + } +} + +export interface SdJwtVcTypeMetadata { + name?: string + description?: string + + extends?: string + 'extends#integrity'?: string + + display?: SdJwtVcTypeMetadataDisplay[] + claims?: SdJwtVcTypeMetadataClaim[] + + schema?: object + schema_uri?: string + 'schema_uri#integrity'?: string +} diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts index aa262c8dad..1e342c8976 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts @@ -744,9 +744,11 @@ export class OpenId4VciHolderService { const sdJwtVcApi = agentContext.dependencyManager.resolve(SdJwtVcApi) const verificationResults = await Promise.all( - credentials.map((compactSdJwtVc) => + credentials.map((compactSdJwtVc, index) => sdJwtVcApi.verify({ compactSdJwtVc, + // Only load and verify it for the first instance + fetchTypeMetadata: index === 0, }) ) ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a16b3cc4e..f686589ffb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,8 +94,8 @@ importers: specifier: ^29.7.0 version: 29.7.0(@types/node@18.18.8)(ts-node@10.9.2(@types/node@18.18.8)(typescript@5.5.4)) nock: - specifier: ^14.0.0-beta.15 - version: 14.0.0-beta.15 + specifier: ^14.0.0-beta.16 + version: 14.0.0-beta.16 prettier: specifier: ^2.3.1 version: 2.8.8 @@ -581,6 +581,9 @@ importers: '@types/varint': specifier: ^6.0.0 version: 6.0.3 + nock: + specifier: ^14.0.0-beta.16 + version: 14.0.0-beta.16 rimraf: specifier: ^4.4.0 version: 4.4.1 @@ -6345,10 +6348,6 @@ packages: resolution: {integrity: sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==} engines: {node: '>=12.0.0'} - nock@14.0.0-beta.15: - resolution: {integrity: sha512-rp72chatxoZbR/2cYHwtb+IX6n6kkanYKGN2PKn4c12JBrj9n4xGUKFykuQHB+Gkz3fynlikFbMH2LI6VoebuQ==} - engines: {node: '>= 18'} - nock@14.0.0-beta.16: resolution: {integrity: sha512-H6ZyT+Naz9wfy0gNrhD0m+VIkCq9li/eaNQPEUEjXg06gsLR3/jDctROt44Z+iT3gFnkTQ0wXtwKJPdvbueBbg==} engines: {node: '>= 18'} @@ -17323,12 +17322,6 @@ snapshots: nocache@3.0.4: {} - nock@14.0.0-beta.15: - dependencies: - '@mswjs/interceptors': 0.36.9 - json-stringify-safe: 5.0.1 - propagate: 2.0.1 - nock@14.0.0-beta.16: dependencies: '@mswjs/interceptors': 0.36.9