From a21d812ca0027eb0da8955d629a4022f9bab0a10 Mon Sep 17 00:00:00 2001 From: "A.G.J. Cate" Date: Fri, 6 Dec 2024 13:24:32 +0100 Subject: [PATCH 1/4] feat: added sd-jwt vct metadata branding support --- .gitignore | 1 + .../oid4vci-holder/src/agent/OID4VCIHolder.ts | 6 +- .../src/agent/OID4VCIHolderService.ts | 43 ++- .../src/agent/OIDC4VCIBrandingMapper.ts | 270 ++++++++++++------ .../src/types/IOID4VCIHolder.ts | 36 ++- packages/sd-jwt/src/action-handler.ts | 95 +++--- packages/sd-jwt/src/index.ts | 1 + packages/sd-jwt/src/types.ts | 32 ++- packages/sd-jwt/src/utils.ts | 22 ++ .../src/types/sd-jwt-type-metadata.ts | 124 ++++---- 10 files changed, 437 insertions(+), 193 deletions(-) create mode 100644 packages/sd-jwt/src/utils.ts diff --git a/.gitignore b/.gitignore index bd2159892..01b6b4b2f 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ test/*.js /packages/oidf-client/plugin.schema.json /packages/anomaly-detection/plugin.schema.json /packages/geolocation-store/plugin.schema.json +/packages/oidf-metadata-server/plugin.schema.json **/.env.energyshr **/.env.local diff --git a/packages/oid4vci-holder/src/agent/OID4VCIHolder.ts b/packages/oid4vci-holder/src/agent/OID4VCIHolder.ts index 2402768b4..6759a52b8 100644 --- a/packages/oid4vci-holder/src/agent/OID4VCIHolder.ts +++ b/packages/oid4vci-holder/src/agent/OID4VCIHolder.ts @@ -209,7 +209,7 @@ export class OID4VCIHolder implements IAgentPlugin { oid4vciHolderStart: this.oid4vciHolderStart.bind(this), oid4vciHolderGetIssuerMetadata: this.oid4vciHolderGetIssuerMetadata.bind(this), oid4vciHolderGetMachineInterpreter: this.oid4vciHolderGetMachineInterpreter.bind(this), - oid4vciHolderCreateCredentialsToSelectFrom: this.oid4vciHoldercreateCredentialsToSelectFrom.bind(this), + oid4vciHolderCreateCredentialsToSelectFrom: this.oid4vciHolderCreateCredentialsToSelectFrom.bind(this), oid4vciHolderGetContact: this.oid4vciHolderGetContact.bind(this), oid4vciHolderGetCredentials: this.oid4vciHolderGetCredentials.bind(this), oid4vciHolderGetCredential: this.oid4vciHolderGetCredential.bind(this), @@ -315,7 +315,7 @@ export class OID4VCIHolder implements IAgentPlugin { }, context, ), - createCredentialsToSelectFrom: (args: createCredentialsToSelectFromArgs) => this.oid4vciHoldercreateCredentialsToSelectFrom(args, context), + createCredentialsToSelectFrom: (args: createCredentialsToSelectFromArgs) => this.oid4vciHolderCreateCredentialsToSelectFrom(args, context), getContact: (args: GetContactArgs) => this.oid4vciHolderGetContact(args, context), getCredentials: (args: GetCredentialsArgs) => this.oid4vciHolderGetCredentials({ accessTokenOpts: args.accessTokenOpts ?? opts.accessTokenOpts, ...args }, context), @@ -461,7 +461,7 @@ export class OID4VCIHolder implements IAgentPlugin { } } - private async oid4vciHoldercreateCredentialsToSelectFrom( + private async oid4vciHolderCreateCredentialsToSelectFrom( args: createCredentialsToSelectFromArgs, context: RequiredContext, ): Promise> { diff --git a/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts b/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts index 62611ae6f..c1adaf288 100644 --- a/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts +++ b/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts @@ -1,6 +1,8 @@ import { LOG } from '@sphereon/oid4vci-client' import { CredentialConfigurationSupported, + CredentialSupportedSdJwtVc, + CredentialConfigurationSupportedSdJwtVcV1_0_13, CredentialOfferFormatV1_0_11, CredentialResponse, getSupportedCredentials, @@ -30,6 +32,7 @@ import { OriginalVerifiableCredential, sdJwtDecodedCredentialToUniformCredential, SdJwtDecodedVerifiableCredential, + SdJwtTypeMetadata, W3CVerifiableCredential, WrappedVerifiableCredential, } from '@sphereon/ssi-types' @@ -54,31 +57,51 @@ import { VerificationResult, VerifyCredentialToAcceptArgs, } from '../types/IOID4VCIHolder' -import { getCredentialBrandingFrom, issuerLocaleBrandingFrom } from './OIDC4VCIBrandingMapper' +import { + oid4vciGetCredentialBrandingFrom, + sdJwtGetCredentialBrandingFrom, + issuerLocaleBrandingFrom +} from './OIDC4VCIBrandingMapper' export const getCredentialBranding = async (args: GetCredentialBrandingArgs): Promise>> => { const { credentialsSupported, context } = args const credentialBranding: Record> = {} await Promise.all( - Object.entries(credentialsSupported).map(async ([configId, credentialsConfigSupported]) => { - const mappedLocaleBranding = await getCredentialBrandingFrom({ - credentialDisplay: credentialsConfigSupported.display, - issuerCredentialSubject: + Object.entries(credentialsSupported).map(async ([configId, credentialsConfigSupported]): Promise => { + let sdJwtTypeMetadata: SdJwtTypeMetadata | undefined + if (credentialsConfigSupported.format === 'vc+sd-jwt') { + const vct = (credentialsConfigSupported).vct + if (vct.startsWith('http')) { + try { + sdJwtTypeMetadata = await context.agent.fetchSdJwtTypeMetadataFromVctUrl({ vct }) + } catch (error) { + // For now, we are just going to ignore and continue without any branding as we still have a fallback + } + } + } + let mappedLocaleBranding: Array = [] + if (sdJwtTypeMetadata) { + mappedLocaleBranding = await sdJwtGetCredentialBrandingFrom({ + credentialDisplay: sdJwtTypeMetadata.display, + claimsMetadata: sdJwtTypeMetadata.claims + }) + } else { + mappedLocaleBranding = await oid4vciGetCredentialBrandingFrom({ + credentialDisplay: credentialsConfigSupported.display, + issuerCredentialSubject: // @ts-ignore // FIXME SPRIND-123 add proper support for type recognition as claim display can be located elsewhere for v13 credentialsSupported.claims !== undefined ? credentialsConfigSupported.claims : credentialsConfigSupported.credentialSubject, - }) - + }) + } // TODO we should make the mapper part of the plugin, so that the logic for getting the branding becomes more clear and easier to use const localeBranding = await Promise.all( - (mappedLocaleBranding ?? []).map( + mappedLocaleBranding.map( async (localeBranding): Promise => await context.agent.ibCredentialLocaleBrandingFrom({ localeBranding }), ), ) - const defaultCredentialType = 'VerifiableCredential' const configSupportedTypes = getTypesFromCredentialSupported(credentialsConfigSupported) const credentialTypes: Array = configSupportedTypes.length === 0 ? asArray(defaultCredentialType) : configSupportedTypes - const filteredCredentialTypes = credentialTypes.filter((type: string): boolean => type !== defaultCredentialType) credentialBranding[filteredCredentialTypes[0]] = localeBranding // TODO for now taking the first type }), diff --git a/packages/oid4vci-holder/src/agent/OIDC4VCIBrandingMapper.ts b/packages/oid4vci-holder/src/agent/OIDC4VCIBrandingMapper.ts index 3138f2719..4558778c1 100644 --- a/packages/oid4vci-holder/src/agent/OIDC4VCIBrandingMapper.ts +++ b/packages/oid4vci-holder/src/agent/OIDC4VCIBrandingMapper.ts @@ -1,16 +1,82 @@ import { CredentialsSupportedDisplay, NameAndLocale } from '@sphereon/oid4vci-common' -import { IBasicCredentialClaim, IBasicCredentialLocaleBranding, IBasicIssuerLocaleBranding } from '@sphereon/ssi-sdk.data-store' import { - CredentialLocaleBrandingFromArgs, + IBasicCredentialClaim, + IBasicCredentialLocaleBranding, + IBasicIssuerLocaleBranding +} from '@sphereon/ssi-sdk.data-store' +import { + SdJwtClaimDisplayMetadata, + SdJwtClaimMetadata, + SdJwtClaimPath, + SdJwtTypeDisplayMetadata +} from '@sphereon/ssi-types' +import { IssuerLocaleBrandingFromArgs, - CredentialBrandingFromArgs, - CredentialDisplayLocalesFromArgs, - IssuerCredentialSubjectLocalesFromArgs, - CombineLocalesFromArgs, + Oid4vciCombineDisplayLocalesFromArgs, + Oid4vciCredentialDisplayLocalesFromArgs, + Oid4vciCredentialLocaleBrandingFromArgs, + Oid4vciGetCredentialBrandingFromArgs, + Oid4vciIssuerCredentialSubjectLocalesFromArgs, + SdJwtCombineDisplayLocalesFromArgs, + SdJwtCredentialClaimLocalesFromArgs, + SdJwtCredentialDisplayLocalesFromArgs, + SdJwtCredentialLocaleBrandingFromArgs, + SdJwtGetCredentialBrandingFromArgs, } from '../types/IOID4VCIHolder' // FIXME should we not move this to the branding plugin? -export const credentialLocaleBrandingFrom = async (args: CredentialLocaleBrandingFromArgs): Promise => { + +export const oid4vciGetCredentialBrandingFrom = async (args: Oid4vciGetCredentialBrandingFromArgs): Promise> => { + const { credentialDisplay, issuerCredentialSubject } = args + + return oid4vciCombineDisplayLocalesFrom({ + ...(issuerCredentialSubject && { issuerCredentialSubjectLocales: await oid4vciIssuerCredentialSubjectLocalesFrom({ issuerCredentialSubject }) }), + ...(credentialDisplay && { credentialDisplayLocales: await oid4vciCredentialDisplayLocalesFrom({ credentialDisplay }) }), + }) +} + +export const oid4vciCredentialDisplayLocalesFrom = async (args: Oid4vciCredentialDisplayLocalesFromArgs): Promise> => { + const { credentialDisplay } = args + return credentialDisplay.reduce((localeDisplays, display) => { + const localeKey = display.locale || '' + localeDisplays.set(localeKey, display) + return localeDisplays + }, new Map()) +} + +export const oid4vciIssuerCredentialSubjectLocalesFrom = async (args: Oid4vciIssuerCredentialSubjectLocalesFromArgs): Promise>> => { + const { issuerCredentialSubject } = args + const localeClaims = new Map>() + + const processClaimObject = (claim: any, parentKey: string = ''): void => { + Object.entries(claim).forEach(([key, value]): void => { + if (key === 'mandatory' || key === 'value_type') { + return + } + + if (key === 'display' && Array.isArray(value)) { + value.forEach(({ name, locale = '' }: NameAndLocale): void => { + if (!name) { + return + } + + //const localeKey = locale || '' + if (!localeClaims.has(locale)) { + localeClaims.set(locale, []) + } + localeClaims.get(locale)!.push({ key: parentKey, name }) + }) + } else if (typeof value === 'object' && value !== null) { + processClaimObject(value, parentKey ? `${parentKey}.${key}` : key) + } + }) + } + + processClaimObject(issuerCredentialSubject) + return localeClaims +} + +export const oid4vciCredentialLocaleBrandingFrom = async (args: Oid4vciCredentialLocaleBrandingFromArgs): Promise => { const { credentialDisplay } = args return { @@ -59,6 +125,122 @@ export const credentialLocaleBrandingFrom = async (args: CredentialLocaleBrandin } } +export const oid4vciCombineDisplayLocalesFrom = async (args: Oid4vciCombineDisplayLocalesFromArgs): Promise> => { + const { + credentialDisplayLocales = new Map(), + issuerCredentialSubjectLocales = new Map>(), + } = args + + const locales: Array = Array.from(new Set([...issuerCredentialSubjectLocales.keys(), ...credentialDisplayLocales.keys()])) + + return Promise.all( + locales.map(async (locale: string): Promise => { + const display = credentialDisplayLocales.get(locale) + const claims = issuerCredentialSubjectLocales.get(locale) + + return { + ...(display && (await oid4vciCredentialLocaleBrandingFrom({ credentialDisplay: display }))), + ...(locale.length > 0 && { locale }), + claims, + } + }), + ) +} + +export const sdJwtGetCredentialBrandingFrom = async (args: SdJwtGetCredentialBrandingFromArgs): Promise> => { + const { credentialDisplay, claimsMetadata } = args + + return sdJwtCombineDisplayLocalesFrom({ + ...(claimsMetadata && { claimsMetadata: await sdJwtCredentialClaimLocalesFrom({ claimsMetadata }) }), + ...(credentialDisplay && { credentialDisplayLocales: await sdJwtCredentialDisplayLocalesFrom({ credentialDisplay }) }), + }) +} + +export const sdJwtCredentialDisplayLocalesFrom = async (args: SdJwtCredentialDisplayLocalesFromArgs): Promise> => { + const { credentialDisplay } = args + return credentialDisplay.reduce((localeDisplays, display) => { + const localeKey = display.lang || '' + localeDisplays.set(localeKey, display) + return localeDisplays + }, new Map()) +} + +export const sdJwtCredentialClaimLocalesFrom = async (args: SdJwtCredentialClaimLocalesFromArgs): Promise>> => { + const { claimsMetadata } = args + const localeClaims = new Map>() + + claimsMetadata.forEach((claim: SdJwtClaimMetadata): void => { + claim.display?.forEach((display: SdJwtClaimDisplayMetadata): void => { + const { lang = '', label } = display; + const key = claim.path.map((value: SdJwtClaimPath) => String(value)).join('.'); + if (!localeClaims.has(lang)) { + localeClaims.set(lang, []) + } + localeClaims.get(lang)!.push({ key, name: label }) + }) + }) + + return localeClaims; +} + +export const sdJwtCredentialLocaleBrandingFrom = async (args: SdJwtCredentialLocaleBrandingFromArgs): Promise => { + const { credentialDisplay } = args + + return { + ...(credentialDisplay.name && { + alias: credentialDisplay.name, + }), + ...(credentialDisplay.lang && { + locale: credentialDisplay.lang, + }), + ...(credentialDisplay.rendering?.simple?.logo && { + logo: { + ...(credentialDisplay.rendering.simple.logo.uri && { + uri: credentialDisplay.rendering.simple.logo.uri, + }), + ...(credentialDisplay.rendering.simple.logo.alt_text && { + alt: credentialDisplay.rendering.simple.logo.alt_text, + }), + }, + }), + ...(credentialDisplay.description && { + description: credentialDisplay.description, + }), + ...(credentialDisplay.rendering?.simple?.text_color && { + text: { + color: credentialDisplay.rendering.simple.text_color, + }, + }), + ...(credentialDisplay.rendering?.simple?.background_color && { + background: { + color: credentialDisplay.rendering.simple.background_color , + }, + }), + } +} + +export const sdJwtCombineDisplayLocalesFrom = async (args: SdJwtCombineDisplayLocalesFromArgs): Promise> => { + const { + credentialDisplayLocales = new Map(), + claimsMetadata = new Map>(), + } = args + + const locales: Array = Array.from(new Set([...claimsMetadata.keys(), ...credentialDisplayLocales.keys()])) + + return Promise.all( + locales.map(async (locale: string): Promise => { + const display = credentialDisplayLocales.get(locale) + const claims = claimsMetadata.get(locale) + + return { + ...(display && (await sdJwtCredentialLocaleBrandingFrom({ credentialDisplay: display }))), + ...(locale.length > 0 && { locale }), + claims, + } + }), + ) +} + // TODO since dynamicRegistrationClientMetadata can also be on a RP, we should start using this mapper in a more general way export const issuerLocaleBrandingFrom = async (args: IssuerLocaleBrandingFromArgs): Promise => { const { issuerDisplay, dynamicRegistrationClientMetadata } = args @@ -108,77 +290,3 @@ export const issuerLocaleBrandingFrom = async (args: IssuerLocaleBrandingFromArg }), } } - -export const getCredentialBrandingFrom = async (args: CredentialBrandingFromArgs): Promise> => { - const { credentialDisplay, issuerCredentialSubject } = args - - return combineDisplayLocalesFrom({ - ...(issuerCredentialSubject && { issuerCredentialSubjectLocales: await issuerCredentialSubjectLocalesFrom({ issuerCredentialSubject }) }), - ...(credentialDisplay && { credentialDisplayLocales: await credentialDisplayLocalesFrom({ credentialDisplay }) }), - }) -} - -const credentialDisplayLocalesFrom = async (args: CredentialDisplayLocalesFromArgs): Promise> => { - const { credentialDisplay } = args - return credentialDisplay.reduce((localeDisplays, display) => { - const localeKey = display.locale || '' - localeDisplays.set(localeKey, display) - return localeDisplays - }, new Map()) -} - -const issuerCredentialSubjectLocalesFrom = async ( - args: IssuerCredentialSubjectLocalesFromArgs, -): Promise>> => { - const { issuerCredentialSubject } = args - const localeClaims = new Map>() - - const processClaimObject = (claim: any, parentKey: string = ''): void => { - Object.entries(claim).forEach(([key, value]): void => { - if (key === 'mandatory' || key === 'value_type') { - return - } - - if (key === 'display' && Array.isArray(value)) { - value.forEach(({ name, locale }: NameAndLocale): void => { - if (!name) { - return - } - - const localeKey = locale || '' - if (!localeClaims.has(localeKey)) { - localeClaims.set(localeKey, []) - } - localeClaims.get(localeKey)!.push({ key: parentKey, name }) - }) - } else if (typeof value === 'object' && value !== null) { - processClaimObject(value, parentKey ? `${parentKey}.${key}` : key) - } - }) - } - - processClaimObject(issuerCredentialSubject) - return localeClaims -} - -const combineDisplayLocalesFrom = async (args: CombineLocalesFromArgs): Promise> => { - const { - credentialDisplayLocales = new Map(), - issuerCredentialSubjectLocales = new Map>(), - } = args - - const locales: Array = Array.from(new Set([...issuerCredentialSubjectLocales.keys(), ...credentialDisplayLocales.keys()])) - - return Promise.all( - locales.map(async (locale: string): Promise => { - const display = credentialDisplayLocales.get(locale) - const claims = issuerCredentialSubjectLocales.get(locale) - - return { - ...(display && (await credentialLocaleBrandingFrom({ credentialDisplay: display }))), - ...(locale.length > 0 && { locale }), - claims, - } - }), - ) -} diff --git a/packages/oid4vci-holder/src/types/IOID4VCIHolder.ts b/packages/oid4vci-holder/src/types/IOID4VCIHolder.ts index ddb119065..e2199aa92 100644 --- a/packages/oid4vci-holder/src/types/IOID4VCIHolder.ts +++ b/packages/oid4vci-holder/src/types/IOID4VCIHolder.ts @@ -43,9 +43,11 @@ import { JoseSignatureAlgorithm, JoseSignatureAlgorithmString, OriginalVerifiableCredential, + SdJwtTypeDisplayMetadata, + SdJwtClaimMetadata, W3CVerifiableCredential, WrappedVerifiableCredential, - WrappedVerifiablePresentation, + WrappedVerifiablePresentation } from '@sphereon/ssi-types' import { IAgentContext, @@ -667,33 +669,55 @@ export type VerifyEBSICredentialIssuerResult = { attributes: Attribute[] } -export type CredentialLocaleBrandingFromArgs = { +export type Oid4vciCredentialLocaleBrandingFromArgs = { credentialDisplay: CredentialsSupportedDisplay } +export type SdJwtCredentialLocaleBrandingFromArgs = { + credentialDisplay: SdJwtTypeDisplayMetadata +} + +export type SdJwtGetCredentialBrandingFromArgs = { + credentialDisplay?: Array + claimsMetadata?: Array +} + +export type SdJwtCredentialClaimLocalesFromArgs = { + claimsMetadata: Array +} + export type IssuerLocaleBrandingFromArgs = { issuerDisplay: MetadataDisplay dynamicRegistrationClientMetadata?: DynamicRegistrationClientMetadataDisplay } -export type CredentialBrandingFromArgs = { +export type Oid4vciGetCredentialBrandingFromArgs = { credentialDisplay?: Array issuerCredentialSubject?: IssuerCredentialSubject } -export type CredentialDisplayLocalesFromArgs = { +export type Oid4vciCredentialDisplayLocalesFromArgs = { credentialDisplay: Array } -export type IssuerCredentialSubjectLocalesFromArgs = { +export type SdJwtCredentialDisplayLocalesFromArgs = { + credentialDisplay: Array +} + +export type Oid4vciIssuerCredentialSubjectLocalesFromArgs = { issuerCredentialSubject: IssuerCredentialSubject } -export type CombineLocalesFromArgs = { +export type Oid4vciCombineDisplayLocalesFromArgs = { credentialDisplayLocales?: Map issuerCredentialSubjectLocales?: Map> } +export type SdJwtCombineDisplayLocalesFromArgs = { + credentialDisplayLocales?: Map + claimsMetadata?: Map> +} + export type DynamicRegistrationClientMetadataDisplay = Pick< DynamicRegistrationClientMetadata, 'client_name' | 'client_uri' | 'contacts' | 'tos_uri' | 'policy_uri' | 'logo_uri' diff --git a/packages/sd-jwt/src/action-handler.ts b/packages/sd-jwt/src/action-handler.ts index 12e6f2913..3df46d5fb 100644 --- a/packages/sd-jwt/src/action-handler.ts +++ b/packages/sd-jwt/src/action-handler.ts @@ -3,15 +3,22 @@ import { SDJwtVcInstance, SdJwtVcPayload } from '@sd-jwt/sd-jwt-vc' import { DisclosureFrame, JwtPayload, KbVerifier, PresentationFrame, Signer, Verifier } from '@sd-jwt/types' import { calculateJwkThumbprint, signatureAlgorithmFromKey } from '@sphereon/ssi-sdk-ext.key-utils' import { X509CertificateChainValidationOpts } from '@sphereon/ssi-sdk-ext.x509-utils' -import { JWK } from '@sphereon/ssi-types' +import { JWK, SdJwtTypeMetadata } from '@sphereon/ssi-types' import { IAgentPlugin } from '@veramo/core' import { decodeBase64url } from '@veramo/utils' import Debug from 'debug' import { defaultGenerateDigest, defaultGenerateSalt, defaultVerifySignature } from './defaultCallbacks' -import { SdJwtVerifySignature, SignKeyArgs, SignKeyResult } from './index' import { funkeTestCA, sphereonCA } from './trustAnchors' +import { + assertValidTypeMetadata, + fetchUrlWithErrorHandling, + validateIntegrity +} from './utils' import { Claims, + FetchSdJwtTypeMetadataFromVctUrlArgs, + GetSignerForIdentifierArgs, + GetSignerResult, ICreateSdJwtPresentationArgs, ICreateSdJwtPresentationResult, ICreateSdJwtVcArgs, @@ -23,8 +30,10 @@ import { IVerifySdJwtVcArgs, IVerifySdJwtVcResult, SdJWTImplementation, + SdJwtVerifySignature, + SignKeyArgs, + SignKeyResult } from './types' -import { ManagedIdentifierResult } from '@sphereon/ssi-sdk-ext.identifier-resolution' const debug = Debug('@sphereon/ssi-sdk.sd-jwt') @@ -69,16 +78,11 @@ export class SDJwtPlugin implements IAgentPlugin { createSdJwtPresentation: this.createSdJwtPresentation.bind(this), verifySdJwtVc: this.verifySdJwtVc.bind(this), verifySdJwtPresentation: this.verifySdJwtPresentation.bind(this), + fetchSdJwtTypeMetadataFromVctUrl: this.fetchSdJwtTypeMetadataFromVctUrl.bind(this), } - private async getSignerForIdentifier( - { identifier, resolution }: { identifier: string; resolution?: ManagedIdentifierResult }, - context: IRequiredContext, - ): Promise<{ - signer: Signer - alg?: string - signingKey?: SignKeyResult - }> { + private async getSignerForIdentifier(args: GetSignerForIdentifierArgs, context: IRequiredContext): Promise { + const { identifier, resolution } = args if (Object.keys(this._signers).includes(identifier) && typeof this._signers[identifier] === 'function') { return { signer: this._signers[identifier] } } else if (typeof this._defaultSigner === 'function') { @@ -243,28 +247,6 @@ export class SDJwtPlugin implements IAgentPlugin { return this.verifySignatureCallback(context)(data, signature, this.getJwk(payload)) } - private getJwk(payload: JwtPayload): JsonWebKey { - if (payload.cnf?.jwk !== undefined) { - return payload.cnf.jwk as JsonWebKey - } else if (payload.cnf !== undefined && 'kid' in payload.cnf && typeof payload.cnf.kid === 'string' && payload.cnf.kid.startsWith('did:jwk:')) { - // extract JWK from kid FIXME isn't there a did function for this already? Otherwise create one - // FIXME this is a quick-fix to make verification but we need a real solution - const encoded = this.extractBase64FromDIDJwk(payload.cnf.kid) - const decoded = decodeBase64url(encoded) - const jwt = JSON.parse(decoded) - return jwt as JsonWebKey - } - throw Error('Unable to extract JWK from SD-JWT payload') - } - - private extractBase64FromDIDJwk(did: string): string { - const parts = did.split(':') - if (parts.length < 3) { - throw new Error('Invalid DID format') - } - return parts[2].split('#')[0] - } - /** * Validates the signature of a SD-JWT * @param sdjwt - SD-JWT instance @@ -357,6 +339,30 @@ export class SDJwtPlugin implements IAgentPlugin { return sdjwt.verify(args.presentation, args.requiredClaimKeys, args.kb) } + /** + * Fetch and validate Type Metadata. + * @param args - Arguments necessary for fetching and validating the type metadata. + * @param context - This reserved param is automatically added and handled by the framework, *do not override* + * @returns + */ + async fetchSdJwtTypeMetadataFromVctUrl(args: FetchSdJwtTypeMetadataFromVctUrlArgs, context: IRequiredContext): Promise { + const {vct, opts} = args + const url = new URL(vct) + const wellKnownUrl = `${url.origin}/.well-known/vct${url.pathname}` + + const response = await fetchUrlWithErrorHandling(wellKnownUrl) + const metadata: SdJwtTypeMetadata = await response.json() + assertValidTypeMetadata(metadata, vct) + + if (opts?.integrity && opts.hasher) { + if (!(await validateIntegrity(metadata, opts.integrity, opts.hasher))) { + throw new Error('Integrity check failed') + } + } + + return metadata + } + private verifySignatureCallback(context: IRequiredContext): SdJwtVerifySignature { if (typeof this.registeredImplementations.verifySignature === 'function') { return this.registeredImplementations.verifySignature @@ -364,4 +370,27 @@ export class SDJwtPlugin implements IAgentPlugin { return defaultVerifySignature(context) } + + private getJwk(payload: JwtPayload): JsonWebKey { + if (payload.cnf?.jwk !== undefined) { + return payload.cnf.jwk as JsonWebKey + } else if (payload.cnf !== undefined && 'kid' in payload.cnf && typeof payload.cnf.kid === 'string' && payload.cnf.kid.startsWith('did:jwk:')) { + // extract JWK from kid FIXME isn't there a did function for this already? Otherwise create one + // FIXME this is a quick-fix to make verification but we need a real solution + const encoded = this.extractBase64FromDIDJwk(payload.cnf.kid) + const decoded = decodeBase64url(encoded) + const jwt = JSON.parse(decoded) + return jwt as JsonWebKey + } + throw Error('Unable to extract JWK from SD-JWT payload') + } + + private extractBase64FromDIDJwk(did: string): string { + const parts = did.split(':') + if (parts.length < 3) { + throw new Error('Invalid DID format') + } + return parts[2].split('#')[0] + } + } diff --git a/packages/sd-jwt/src/index.ts b/packages/sd-jwt/src/index.ts index 184cfc483..b179dc59c 100644 --- a/packages/sd-jwt/src/index.ts +++ b/packages/sd-jwt/src/index.ts @@ -1,2 +1,3 @@ export { SDJwtPlugin } from './action-handler' +export * from './utils' export * from './types' diff --git a/packages/sd-jwt/src/types.ts b/packages/sd-jwt/src/types.ts index bb1480931..7b7d1085c 100644 --- a/packages/sd-jwt/src/types.ts +++ b/packages/sd-jwt/src/types.ts @@ -1,11 +1,11 @@ import { SdJwtVcPayload as SdJwtPayload } from '@sd-jwt/sd-jwt-vc' -import { Hasher, kbHeader, KBOptions, kbPayload, SaltGenerator } from '@sd-jwt/types' +import { Hasher, kbHeader, KBOptions, kbPayload, SaltGenerator, Signer } from '@sd-jwt/types' import { IIdentifierResolution, ManagedIdentifierResult } from '@sphereon/ssi-sdk-ext.identifier-resolution' import { IJwtService } from '@sphereon/ssi-sdk-ext.jwt-service' import { X509CertificateChainValidationOpts } from '@sphereon/ssi-sdk-ext.x509-utils' import { contextHasPlugin } from '@sphereon/ssi-sdk.agent-config' import { ImDLMdoc } from '@sphereon/ssi-sdk.mdl-mdoc' -import { JoseSignatureAlgorithm } from '@sphereon/ssi-types' +import { AsyncHasher, JoseSignatureAlgorithm, SdJwtTypeMetadata } from '@sphereon/ssi-types' import { DIDDocumentSection, IAgentContext, IDIDManager, IKeyManager, IPluginMethodMap, IResolver } from '@veramo/core' export const sdJwtPluginContextMethods: Array = ['createSdJwtVc', 'createSdJwtPresentation', 'verifySdJwtVc', 'verifySdJwtPresentation'] @@ -66,6 +66,13 @@ export interface ISDJwtPlugin extends IPluginMethodMap { * @param context - This reserved param is automatically added and handled by the framework, *do not override* */ verifySdJwtPresentation(args: IVerifySdJwtPresentationArgs, context: IRequiredContext): Promise + + /** + * Fetch and validate Type Metadata. + * @param args - Arguments necessary for fetching and validating the type metadata. + * @param context - This reserved param is automatically added and handled by the framework, *do not override* + */ + fetchSdJwtTypeMetadataFromVctUrl(args: FetchSdJwtTypeMetadataFromVctUrlArgs, context: IRequiredContext): Promise } export function contextHasSDJwtPlugin(context: IAgentContext): context is IAgentContext { @@ -242,3 +249,24 @@ export interface Claims { [key: string]: unknown } + +export type FetchSdJwtTypeMetadataFromVctUrlArgs = { + vct: string + opts?: FetchSdJwtTypeMetadataFromVctUrlOpts +} + +export type FetchSdJwtTypeMetadataFromVctUrlOpts = { + hasher?: AsyncHasher + integrity?: string +} + +export type GetSignerForIdentifierArgs = { + identifier: string + resolution?: ManagedIdentifierResult +} + +export type GetSignerResult = { + signer: Signer + alg?: string + signingKey?: SignKeyResult +} diff --git a/packages/sd-jwt/src/utils.ts b/packages/sd-jwt/src/utils.ts new file mode 100644 index 000000000..9441e4c85 --- /dev/null +++ b/packages/sd-jwt/src/utils.ts @@ -0,0 +1,22 @@ +import { AsyncHasher, SdJwtTypeMetadata } from '@sphereon/ssi-types' +import * as u8a from 'uint8arrays' + +// Helper function to fetch API with error handling +export async function fetchUrlWithErrorHandling(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`${response.status}: ${response.statusText}`) + } + return response +} + +export async function validateIntegrity(input: any, integrityValue: string, hasher: AsyncHasher, alg?: string): Promise { + const hash = await hasher(input, alg ?? 'sha256') + return u8a.toString(hash, 'utf-8') === integrityValue +} + +export function assertValidTypeMetadata(metadata: SdJwtTypeMetadata, vct: string): void { + if (metadata.vct !== vct) { + throw new Error('VCT mismatch in metadata and credential') + } +} diff --git a/packages/ssi-types/src/types/sd-jwt-type-metadata.ts b/packages/ssi-types/src/types/sd-jwt-type-metadata.ts index 5d272b5e1..3c2c1c56d 100644 --- a/packages/ssi-types/src/types/sd-jwt-type-metadata.ts +++ b/packages/ssi-types/src/types/sd-jwt-type-metadata.ts @@ -1,10 +1,7 @@ -import * as u8a from 'uint8arrays' -import { AsyncHasher } from './sd-jwt-vc' - /** * Represents the metadata associated with a specific SD-JWT VC type. */ -interface SdJwtTypeMetadata { +export interface SdJwtTypeMetadata { /** * REQUIRED. The VC type URI. */ @@ -48,14 +45,58 @@ interface SdJwtTypeMetadata { /** * OPTIONAL. Metadata for the claims within the VC. */ - // TODO: - claims?: Array + claims?: Array +} + +/** + * Represents the metadata associated with a specific SD-JWT claim. + */ +export interface SdJwtClaimMetadata { + /** + * REQUIRED. An array indicating the claim or claims that are being addressed. + */ + path: Array + + /** + * OPTIONAL. Display information for the claim. + */ + display?: Array + + /** + * OPTIONAL. A string indicating whether the claim is selectively disclosable. + */ + sd?: SdJwtClaimSelectiveDisclosure + + /** + * OPTIONAL. A string defining the ID of the claim for reference in the SVG template. + */ + svg_id?: string +} + +/** + * Represents claim display metadata for a specific language. + */ +export interface SdJwtClaimDisplayMetadata { + /** + * REQUIRED. Language tag for the display information. + */ + lang: string + + /** + * REQUIRED. A human-readable label for the claim, intended for end users. + */ + label: string + + /** + * REQUIRED. A human-readable description for the claim, intended for end users. + */ + description?: string } /** * Represents display metadata for a specific language. */ -interface SdJwtTypeDisplayMetadata { +export interface SdJwtTypeDisplayMetadata { /** * REQUIRED. Language tag for the display information. */ @@ -80,7 +121,7 @@ interface SdJwtTypeDisplayMetadata { /** * Contains rendering metadata for different methods. */ -interface SdJwtTypeRenderingMetadata { +export interface SdJwtTypeRenderingMetadata { /** * OPTIONAL. Simple rendering method metadata. */ @@ -95,7 +136,7 @@ interface SdJwtTypeRenderingMetadata { /** * Represents metadata for simple rendering. */ -interface SdJwtSimpleRenderingMetadata { +export interface SdJwtSimpleRenderingMetadata { /** * OPTIONAL. Metadata for the logo image. */ @@ -115,7 +156,7 @@ interface SdJwtSimpleRenderingMetadata { /** * Represents metadata for a logo. */ -interface SdJwtLogoMetadata { +export interface SdJwtLogoMetadata { /** * REQUIRED. URI pointing to the logo image. */ @@ -135,7 +176,7 @@ interface SdJwtLogoMetadata { /** * Represents metadata for SVG templates. */ -interface SdJwtSVGTemplateMetadata { +export interface SdJwtSVGTemplateMetadata { /** * REQUIRED. URI pointing to the SVG template. */ @@ -155,7 +196,7 @@ interface SdJwtSVGTemplateMetadata { /** * Contains properties for SVG templates. */ -interface SdJwtSVGTemplateProperties { +export interface SdJwtSVGTemplateProperties { /** * OPTIONAL. The orientation for which the SVG template is optimized. */ @@ -167,51 +208,18 @@ interface SdJwtSVGTemplateProperties { color_scheme?: string } -// Helper function to fetch API with error handling -async function fetchUrlWithErrorHandling(url: string): Promise { - const response = await fetch(url) - if (!response.ok) { - throw new Error(`${response.status}: ${response.statusText}`) - } - return response -} - -export type SdJwtTypeHasher = (input: any, alg?: string) => string - -async function validateIntegrity(input: any, integrityValue: string, hasher: AsyncHasher, alg?: string): Promise { - const hash = await hasher(input, alg ?? 'sha256') - return u8a.toString(hash, 'utf-8') === integrityValue -} - -// Fetch and validate Type Metadata -export async function fetchSdJwtTypeMetadataFromVctUrl(vct: string, opts?: { hasher?: AsyncHasher; integrity?: string }): Promise { - const url = new URL(vct) - const wellKnownUrl = `${url.origin}/.well-known/vct${url.pathname}` - - const response = await fetchUrlWithErrorHandling(wellKnownUrl) - const metadata: SdJwtTypeMetadata = await response.json() - assertValidTypeMetadata(metadata, vct) - if (opts?.integrity && opts.hasher) { - if (!(await validateIntegrity(metadata, opts.integrity, opts.hasher))) { - throw new Error('Integrity check failed') - } - } - return metadata -} +/** + * A string indicates that the respective key is to be selected. + * A null value indicates that all elements of the currently selected array(s) are to be selected. + * A non-negative integer indicates that the respective index in an array is to be selected. + */ +export type SdJwtClaimPath = string | null | number -function assertValidTypeMetadata(metadata: SdJwtTypeMetadata, vct: string): void { - if (metadata.vct !== vct) { - throw new Error('VCT mismatch in metadata and credential') - } -} +/** + * always: The Issuer MUST make the claim selectively disclosable. + * allowed: The Issuer MAY make the claim selectively disclosable. + * never: The Issuer MUST NOT make the claim selectively disclosable. + */ +export type SdJwtClaimSelectiveDisclosure = 'always' | 'allowed' | 'never' -/* -// Example usage - try { - const vct = 'https://betelgeuse.example.com/education_credential' - const typeMetadata = await fetchSdJwtTypeMetadataFromVctUrl(vct) - console.log('Type Metadata retrieved successfully:', typeMetadata) - } catch (error) { - console.error('Error fetching type metadata:', error.message) - } -*/ +export type SdJwtTypeHasher = (input: any, alg?: string) => string From 657f6b5f66be60ce09bf5d568f10aef129bc12d7 Mon Sep 17 00:00:00 2001 From: "A.G.J. Cate" Date: Fri, 6 Dec 2024 15:55:06 +0100 Subject: [PATCH 2/4] chore: testing --- packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts b/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts index c1adaf288..0d2e667ff 100644 --- a/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts +++ b/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts @@ -1,8 +1,8 @@ import { LOG } from '@sphereon/oid4vci-client' import { CredentialConfigurationSupported, - CredentialSupportedSdJwtVc, - CredentialConfigurationSupportedSdJwtVcV1_0_13, + // CredentialSupportedSdJwtVc, + // CredentialConfigurationSupportedSdJwtVcV1_0_13, CredentialOfferFormatV1_0_11, CredentialResponse, getSupportedCredentials, @@ -70,7 +70,7 @@ export const getCredentialBranding = async (args: GetCredentialBrandingArgs): Pr Object.entries(credentialsSupported).map(async ([configId, credentialsConfigSupported]): Promise => { let sdJwtTypeMetadata: SdJwtTypeMetadata | undefined if (credentialsConfigSupported.format === 'vc+sd-jwt') { - const vct = (credentialsConfigSupported).vct + const vct = "https://raw.githubusercontent.com/Sphereon-Opensource/vc-contexts/refs/heads/master/funke/sd-jwt-metadata/age_group.json"//(credentialsConfigSupported).vct if (vct.startsWith('http')) { try { sdJwtTypeMetadata = await context.agent.fetchSdJwtTypeMetadataFromVctUrl({ vct }) From ab5cf6c47b4e5625f42350ac05739045330707ae Mon Sep 17 00:00:00 2001 From: "A.G.J. Cate" Date: Fri, 6 Dec 2024 16:37:52 +0100 Subject: [PATCH 3/4] chore: removed well-known vct url location requirement --- packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts | 8 ++++---- packages/sd-jwt/src/action-handler.ts | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts b/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts index 0d2e667ff..4ac379450 100644 --- a/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts +++ b/packages/oid4vci-holder/src/agent/OID4VCIHolderService.ts @@ -1,8 +1,8 @@ import { LOG } from '@sphereon/oid4vci-client' import { CredentialConfigurationSupported, - // CredentialSupportedSdJwtVc, - // CredentialConfigurationSupportedSdJwtVcV1_0_13, + CredentialSupportedSdJwtVc, + CredentialConfigurationSupportedSdJwtVcV1_0_13, CredentialOfferFormatV1_0_11, CredentialResponse, getSupportedCredentials, @@ -70,11 +70,11 @@ export const getCredentialBranding = async (args: GetCredentialBrandingArgs): Pr Object.entries(credentialsSupported).map(async ([configId, credentialsConfigSupported]): Promise => { let sdJwtTypeMetadata: SdJwtTypeMetadata | undefined if (credentialsConfigSupported.format === 'vc+sd-jwt') { - const vct = "https://raw.githubusercontent.com/Sphereon-Opensource/vc-contexts/refs/heads/master/funke/sd-jwt-metadata/age_group.json"//(credentialsConfigSupported).vct + const vct = (credentialsConfigSupported).vct if (vct.startsWith('http')) { try { sdJwtTypeMetadata = await context.agent.fetchSdJwtTypeMetadataFromVctUrl({ vct }) - } catch (error) { + } catch { // For now, we are just going to ignore and continue without any branding as we still have a fallback } } diff --git a/packages/sd-jwt/src/action-handler.ts b/packages/sd-jwt/src/action-handler.ts index 3df46d5fb..49e0e648c 100644 --- a/packages/sd-jwt/src/action-handler.ts +++ b/packages/sd-jwt/src/action-handler.ts @@ -348,9 +348,8 @@ export class SDJwtPlugin implements IAgentPlugin { async fetchSdJwtTypeMetadataFromVctUrl(args: FetchSdJwtTypeMetadataFromVctUrlArgs, context: IRequiredContext): Promise { const {vct, opts} = args const url = new URL(vct) - const wellKnownUrl = `${url.origin}/.well-known/vct${url.pathname}` - const response = await fetchUrlWithErrorHandling(wellKnownUrl) + const response = await fetchUrlWithErrorHandling(url.toString()) const metadata: SdJwtTypeMetadata = await response.json() assertValidTypeMetadata(metadata, vct) From c8ce2ad7b6994202b3665c2f7a2b55313d5329bb Mon Sep 17 00:00:00 2001 From: "A.G.J. Cate" Date: Fri, 6 Dec 2024 16:42:14 +0100 Subject: [PATCH 4/4] chore: add vct value to error message --- packages/sd-jwt/src/action-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sd-jwt/src/action-handler.ts b/packages/sd-jwt/src/action-handler.ts index 49e0e648c..101f762a5 100644 --- a/packages/sd-jwt/src/action-handler.ts +++ b/packages/sd-jwt/src/action-handler.ts @@ -355,7 +355,7 @@ export class SDJwtPlugin implements IAgentPlugin { if (opts?.integrity && opts.hasher) { if (!(await validateIntegrity(metadata, opts.integrity, opts.hasher))) { - throw new Error('Integrity check failed') + throw new Error(`Integrity check failed. vct: ${vct}`) } }