From 62dd947d0e09360719e6f704db33d766dff2363a Mon Sep 17 00:00:00 2001 From: Niels Klomp Date: Sun, 18 Jun 2023 17:51:45 +0200 Subject: [PATCH] feat: Add support for alg, kid, did, did document to Jwt Verification callback so we can ensure to set proper values in the resulting VC. --- .../lib/__tests__/issuerCallback.spec.ts | 27 ++++++-- packages/callback-example/package.json | 1 + .../client/lib/CredentialRequestClient.ts | 8 +-- packages/client/lib/OpenID4VCIClient.ts | 2 +- .../client/lib/ProofOfPossessionBuilder.ts | 25 +++++--- .../CredentialRequestClientBuilder.spec.ts | 16 ++++- packages/client/lib/functions/ProofUtil.ts | 28 ++++----- .../lib/types/CredentialIssuance.types.ts | 17 +++-- packages/common/lib/types/OpenID4VCIErrors.ts | 2 + .../issuer-rest/lib/IssuerTokenEndpoint.ts | 4 +- packages/issuer-rest/lib/OID4VCIServer.ts | 8 +-- .../lib/__tests__/ClientIssuerIT.spec.ts | 32 ++++++++-- .../lib/__tests__/IssuerTokenServer.spec.ts | 3 +- packages/issuer-rest/package.json | 1 + packages/issuer/lib/VcIssuer.ts | 63 +++++++++++++------ .../issuer/lib/__tests__/VcIssuer.spec.ts | 30 ++++++--- .../issuer/lib/builder/VcIssuerBuilder.ts | 8 +-- packages/issuer/lib/types/index.ts | 6 ++ packages/issuer/package.json | 1 + pnpm-lock.yaml | 21 +++++-- 20 files changed, 209 insertions(+), 94 deletions(-) diff --git a/packages/callback-example/lib/__tests__/issuerCallback.spec.ts b/packages/callback-example/lib/__tests__/issuerCallback.spec.ts index da26f199..7b0e4a7b 100644 --- a/packages/callback-example/lib/__tests__/issuerCallback.spec.ts +++ b/packages/callback-example/lib/__tests__/issuerCallback.spec.ts @@ -9,6 +9,7 @@ import { IssuerCredentialSubjectDisplay, IssueStatus, Jwt, + JwtVerifyResult, OpenId4VCIVersion, ProofOfPossession, } from '@sphereon/oid4vci-common' @@ -17,6 +18,7 @@ import { CredentialSupportedBuilderV1_11, VcIssuer, VcIssuerBuilder } from '@sph import { MemoryStates } from '@sphereon/oid4vci-issuer' import { CredentialDataSupplierResult } from '@sphereon/oid4vci-issuer/dist/types' import { ICredential, IProofPurpose, IProofType, W3CVerifiableCredential } from '@sphereon/ssi-types' +import { DIDDocument } from 'did-resolver' import * as jose from 'jose' import { generateDid, getIssuerCallback, verifyCredential } from '../IssuerCallback' @@ -43,12 +45,25 @@ async function proofOfPossessionCallbackFunction(args: Jwt, kid?: string): Promi .sign(keypair.privateKey) } -async function verifyCallbackFunction(args: { jwt: string; kid?: string }): Promise { +async function verifyCallbackFunction(args: { jwt: string; kid?: string }): Promise> { const result = await jose.jwtVerify(args.jwt, keypair.publicKey) + const kid = result.protectedHeader.kid ?? args.kid + const did = kid!.split('#')[0] + const didDocument: DIDDocument = { + '@context': 'https://www.w3.org/ns/did/v1', + id: did, + } + const alg = result.protectedHeader.alg return { - header: result.protectedHeader, - payload: result.payload, - } as Jwt + alg, + kid, + did, + didDocument, + jwt: { + header: result.protectedHeader, + payload: result.payload, + }, + } } interface KeyPair { @@ -66,7 +81,7 @@ afterAll(async () => { await new Promise((resolve) => setTimeout((v: void) => resolve(v), 500)) }) describe('issuerCallback', () => { - let vcIssuer: VcIssuer + let vcIssuer: VcIssuer const state = 'existing-state' const clientId = 'sphereon:wallet' @@ -121,7 +136,7 @@ describe('issuerCallback', () => { const nonces = new MemoryStates() nonces.set('test_value', { cNonce: 'test_value', createdAt: +new Date(), issuerState: 'existing-state' }) - vcIssuer = new VcIssuerBuilder() + vcIssuer = new VcIssuerBuilder() .withAuthorizationServer('https://authorization-server') .withCredentialEndpoint('https://credential-endpoint') .withCredentialIssuer(IDENTIPROOF_ISSUER_URL) diff --git a/packages/callback-example/package.json b/packages/callback-example/package.json index 827141fa..ee0e2ce8 100644 --- a/packages/callback-example/package.json +++ b/packages/callback-example/package.json @@ -21,6 +21,7 @@ "jose": "^4.10.0" }, "devDependencies": { + "did-resolver": "^4.1.0", "@babel/core": "^7.21.4", "@babel/preset-env": "^7.21.4", "@types/jest": "^29.5.0", diff --git a/packages/client/lib/CredentialRequestClient.ts b/packages/client/lib/CredentialRequestClient.ts index 54716129..055c8006 100644 --- a/packages/client/lib/CredentialRequestClient.ts +++ b/packages/client/lib/CredentialRequestClient.ts @@ -41,8 +41,8 @@ export class CredentialRequestClient { this._credentialRequestOpts = { ...builder }; } - public async acquireCredentialsUsingProof(opts: { - proofInput: ProofOfPossessionBuilder | ProofOfPossession; + public async acquireCredentialsUsingProof(opts: { + proofInput: ProofOfPossessionBuilder | ProofOfPossession; credentialTypes?: string | string[]; format?: CredentialFormat | OID4VCICredentialFormat; }): Promise> { @@ -83,8 +83,8 @@ export class CredentialRequestClient { return response; } - public async createCredentialRequest(opts: { - proofInput: ProofOfPossessionBuilder | ProofOfPossession; + public async createCredentialRequest(opts: { + proofInput: ProofOfPossessionBuilder | ProofOfPossession; credentialTypes?: string | string[]; format?: CredentialFormat | OID4VCICredentialFormat; version: OpenId4VCIVersion; diff --git a/packages/client/lib/OpenID4VCIClient.ts b/packages/client/lib/OpenID4VCIClient.ts index 797d2655..e8cc6854 100644 --- a/packages/client/lib/OpenID4VCIClient.ts +++ b/packages/client/lib/OpenID4VCIClient.ts @@ -276,7 +276,7 @@ export class OpenID4VCIClient { jti, }: { credentialTypes: string | string[]; - proofCallbacks: ProofOfPossessionCallbacks; + proofCallbacks: ProofOfPossessionCallbacks; format?: CredentialFormat | OID4VCICredentialFormat; kid?: string; alg?: Alg | string; diff --git a/packages/client/lib/ProofOfPossessionBuilder.ts b/packages/client/lib/ProofOfPossessionBuilder.ts index d0947dd3..35b2dd4d 100644 --- a/packages/client/lib/ProofOfPossessionBuilder.ts +++ b/packages/client/lib/ProofOfPossessionBuilder.ts @@ -13,9 +13,9 @@ import { import { createProofOfPossession } from './functions'; -export class ProofOfPossessionBuilder { +export class ProofOfPossessionBuilder { private readonly proof?: ProofOfPossession; - private readonly callbacks?: ProofOfPossessionCallbacks; + private readonly callbacks?: ProofOfPossessionCallbacks; private readonly version: OpenId4VCIVersion; private kid?: string; @@ -35,7 +35,7 @@ export class ProofOfPossessionBuilder { version, }: { proof?: ProofOfPossession; - callbacks?: ProofOfPossessionCallbacks; + callbacks?: ProofOfPossessionCallbacks; accessTokenResponse?: AccessTokenResponse; jwt?: Jwt; version: OpenId4VCIVersion; @@ -53,31 +53,31 @@ export class ProofOfPossessionBuilder { } } - static fromJwt({ + static fromJwt({ jwt, callbacks, version, }: { jwt: Jwt; - callbacks: ProofOfPossessionCallbacks; + callbacks: ProofOfPossessionCallbacks; version: OpenId4VCIVersion; - }): ProofOfPossessionBuilder { + }): ProofOfPossessionBuilder { return new ProofOfPossessionBuilder({ callbacks, jwt, version }); } - static fromAccessTokenResponse({ + static fromAccessTokenResponse({ accessTokenResponse, callbacks, version, }: { accessTokenResponse: AccessTokenResponse; - callbacks: ProofOfPossessionCallbacks; + callbacks: ProofOfPossessionCallbacks; version: OpenId4VCIVersion; - }): ProofOfPossessionBuilder { + }): ProofOfPossessionBuilder { return new ProofOfPossessionBuilder({ callbacks, accessTokenResponse, version }); } - static fromProof(proof: ProofOfPossession, version: OpenId4VCIVersion): ProofOfPossessionBuilder { + static fromProof(proof: ProofOfPossession, version: OpenId4VCIVersion): ProofOfPossessionBuilder { return new ProofOfPossessionBuilder({ proof, version }); } @@ -159,6 +159,11 @@ export class ProofOfPossessionBuilder { } this.withAlg(jwt.header.alg); + if (Array.isArray(jwt.payload.aud)) { + // Rather do this than take the first value, as it might be very hard to figure out why something is failing + throw Error('We cannot handle multiple aud values currently'); + } + if (jwt.payload) { if (jwt.payload.iss) this.withClientId(jwt.payload.iss); if (jwt.payload.aud) this.withIssuer(jwt.payload.aud); diff --git a/packages/client/lib/__tests__/CredentialRequestClientBuilder.spec.ts b/packages/client/lib/__tests__/CredentialRequestClientBuilder.spec.ts index 4abf0537..41aa35c1 100644 --- a/packages/client/lib/__tests__/CredentialRequestClientBuilder.spec.ts +++ b/packages/client/lib/__tests__/CredentialRequestClientBuilder.spec.ts @@ -4,7 +4,7 @@ import { Alg, CredentialIssuerMetadata, Jwt, - JWTPayload, + JwtVerifyResult, OpenId4VCIVersion, ProofOfPossession, UniformCredentialRequest, @@ -51,9 +51,19 @@ interface KeyPair { privateKey: KeyObject; } -async function proofOfPossessionVerifierCallbackFunction(args: { jwt: string; kid?: string }): Promise { +async function proofOfPossessionVerifierCallbackFunction(args: { jwt: string; kid?: string }): Promise> { const result = await jose.jwtVerify(args.jwt, keypair.publicKey); - return { header: result.protectedHeader, payload: result.payload as unknown as JWTPayload }; + const kid = result.protectedHeader.kid ?? args.kid; + const did = kid!.split('#')[0]; + const didDocument = {}; + const alg = result.protectedHeader.alg; + return { + alg, + did, + kid, + didDocument, + jwt: { header: result.protectedHeader, payload: result.payload }, + }; } describe('Credential Request Client Builder', () => { diff --git a/packages/client/lib/functions/ProofUtil.ts b/packages/client/lib/functions/ProofUtil.ts index ffa7e8ac..e2d9262f 100644 --- a/packages/client/lib/functions/ProofUtil.ts +++ b/packages/client/lib/functions/ProofUtil.ts @@ -20,8 +20,8 @@ const debug = Debug('sphereon:openid4vci:token'); * @param existingJwt * - Optional, clientId of the party requesting the credential */ -export const createProofOfPossession = async ( - callbacks: ProofOfPossessionCallbacks, +export const createProofOfPossession = async ( + callbacks: ProofOfPossessionCallbacks, jwtProps?: JwtProps, existingJwt?: Jwt ): Promise => { @@ -69,14 +69,14 @@ export interface JwtProps { } const createJWT = (jwtProps?: JwtProps, existingJwt?: Jwt): Jwt => { - const aud = getJwtProperty('aud', true, jwtProps?.issuer, existingJwt?.payload?.aud); - const iss = getJwtProperty('iss', false, jwtProps?.clientId, existingJwt?.payload?.iss); - const jti = getJwtProperty('jti', false, jwtProps?.jti, existingJwt?.payload?.jti); - const typ = getJwtProperty('typ', true, jwtProps?.typ, existingJwt?.header?.typ, 'jwt'); - const nonce = getJwtProperty('nonce', false, jwtProps?.nonce, existingJwt?.payload?.nonce); // Officially this is required, but some implementations don't have it + const aud = getJwtProperty('aud', true, jwtProps?.issuer, existingJwt?.payload?.aud); + const iss = getJwtProperty('iss', false, jwtProps?.clientId, existingJwt?.payload?.iss); + const jti = getJwtProperty('jti', false, jwtProps?.jti, existingJwt?.payload?.jti); + const typ = getJwtProperty('typ', true, jwtProps?.typ, existingJwt?.header?.typ, 'jwt'); + const nonce = getJwtProperty('nonce', false, jwtProps?.nonce, existingJwt?.payload?.nonce); // Officially this is required, but some implementations don't have it // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const alg = getJwtProperty('alg', false, jwtProps?.alg, existingJwt?.header?.alg, 'ES256')!; - const kid = getJwtProperty('kid', true, jwtProps?.kid, existingJwt?.header?.kid); + const alg = getJwtProperty('alg', false, jwtProps?.alg, existingJwt?.header?.alg, 'ES256')!; + const kid = getJwtProperty('kid', true, jwtProps?.kid, existingJwt?.header?.kid); const jwt: Partial = existingJwt ? existingJwt : {}; const now = +new Date(); const jwtPayload: Partial = { @@ -99,17 +99,11 @@ const createJWT = (jwtProps?: JwtProps, existingJwt?: Jwt): Jwt => { }; }; -const getJwtProperty = ( - propertyName: string, - required: boolean, - option?: string, - jwtProperty?: string, - defaultValue?: string -): string | undefined => { +const getJwtProperty = (propertyName: string, required: boolean, option?: string, jwtProperty?: T, defaultValue?: T): T | undefined => { if (option && jwtProperty && option !== jwtProperty) { throw Error(`Cannot have a property '${propertyName}' with value '${option}' and different JWT value '${jwtProperty}' at the same time`); } - let result = jwtProperty ? jwtProperty : option; + let result = (jwtProperty ? jwtProperty : option) as T | undefined; if (!result) { if (required) { throw Error(`No ${propertyName} property provided either in a JWT or as option`); diff --git a/packages/common/lib/types/CredentialIssuance.types.ts b/packages/common/lib/types/CredentialIssuance.types.ts index a09bc4ff..0e45a56e 100644 --- a/packages/common/lib/types/CredentialIssuance.types.ts +++ b/packages/common/lib/types/CredentialIssuance.types.ts @@ -108,9 +108,9 @@ export interface Jwt { payload: JWTPayload; } -export interface ProofOfPossessionCallbacks { +export interface ProofOfPossessionCallbacks { signCallback: JWTSignerCallback; - verifyCallback?: JWTVerifyCallback; + verifyCallback?: JWTVerifyCallback; } export enum Alg { @@ -155,7 +155,7 @@ export type JWTHeader = JWTHeaderParameters; export interface JWTPayload { iss?: string; // REQUIRED (string). The value of this claim MUST be the client_id of the client making the credential request. - aud?: string; // REQUIRED (string). The value of this claim MUST be the issuer URL of credential issuer. + aud?: string | string[]; // REQUIRED (string). The value of this claim MUST be the issuer URL of credential issuer. iat?: number; // REQUIRED (number). The value of this claim MUST be the time at which the proof was issued using the syntax defined in [RFC7519]. nonce?: string; // REQUIRED (string). The value type of this claim MUST be a string, where the value is a c_nonce provided by the credential issuer. //TODO: Marked as required not present in NGI flow jti?: string; // A new nonce chosen by the wallet. Used to prevent replay @@ -164,4 +164,13 @@ export interface JWTPayload { } export type JWTSignerCallback = (jwt: Jwt, kid?: string) => Promise; -export type JWTVerifyCallback = (args: { jwt: string; kid?: string }) => Promise; +export type JWTVerifyCallback = (args: { jwt: string; kid?: string }) => Promise>; +export interface JwtVerifyResult { + jwt: Jwt; + kid?: string; + alg: string; + did?: string; + didDocument?: DIDDoc; + x5c?: string; + jwk?: BaseJWK; +} diff --git a/packages/common/lib/types/OpenID4VCIErrors.ts b/packages/common/lib/types/OpenID4VCIErrors.ts index 7bf5860e..bf7e8c55 100644 --- a/packages/common/lib/types/OpenID4VCIErrors.ts +++ b/packages/common/lib/types/OpenID4VCIErrors.ts @@ -8,6 +8,8 @@ export const NO_JWT_PROVIDED = 'No JWT provided'; export const TYP_ERROR = 'Typ must be "openid4vci-proof+jwt"'; export const ALG_ERROR = `Algorithm is a required field and must be one of: ${Object.keys(Alg).join(', ')}`; export const KID_JWK_X5C_ERROR = 'Only one must be present: kid, jwk or x5c'; +export const KID_DID_NO_DID_ERROR = 'A DID value needs to be returned when kid is present'; +export const DID_NO_DIDDOC_ERROR = 'A DID Document needs to be resolved when a DID is encountered'; export const AUD_ERROR = 'aud must be the URL of the credential issuer'; export const IAT_ERROR = 'iat must be the time at which the proof was issued'; export const NONCE_ERROR = 'nonce must be c_nonce provided by the credential issuer'; diff --git a/packages/issuer-rest/lib/IssuerTokenEndpoint.ts b/packages/issuer-rest/lib/IssuerTokenEndpoint.ts index 9f3424f8..3e581386 100644 --- a/packages/issuer-rest/lib/IssuerTokenEndpoint.ts +++ b/packages/issuer-rest/lib/IssuerTokenEndpoint.ts @@ -22,7 +22,7 @@ export const handleTokenRequest = ({ issuer, interval, }: Required> & { - issuer: VcIssuer + issuer: VcIssuer }) => { return async (request: Request, response: Response) => { response.set({ @@ -66,7 +66,7 @@ export const handleTokenRequest = ({ export const verifyTokenRequest = ({ preAuthorizedCodeExpirationDuration, issuer, -}: Required & { issuer: VcIssuer }>) => { +}: Required & { issuer: VcIssuer }>) => { return async (request: Request, response: Response, next: NextFunction) => { try { await assertValidAccessTokenRequest(request.body, { diff --git a/packages/issuer-rest/lib/OID4VCIServer.ts b/packages/issuer-rest/lib/OID4VCIServer.ts index 2aa3a979..21260ff2 100644 --- a/packages/issuer-rest/lib/OID4VCIServer.ts +++ b/packages/issuer-rest/lib/OID4VCIServer.ts @@ -55,7 +55,7 @@ function buildVCIFromEnvironment() { } as IssuerCredentialSubjectDisplay // fixme: This is wrong (remove the cast and see it has no matches) ) .build() - return new VcIssuerBuilder() + return new VcIssuerBuilder() .withUserPinRequired(process.env.user_pin_required as unknown as boolean) .withAuthorizationServer(process.env.authorization_server as string) .withCredentialEndpoint(process.env.credential_endpoint as string) @@ -105,7 +105,7 @@ export interface IOID4VCIServerOpts { } export class OID4VCIServer { - private readonly _issuer: VcIssuer + private readonly _issuer: VcIssuer private authRequestsData: Map = new Map() private readonly _app: Express private readonly _baseUrl: URL @@ -127,7 +127,7 @@ export class OID4VCIServer { } constructor( - opts?: IOID4VCIServerOpts & { issuer?: VcIssuer } /*If not supplied as argument, it will be fully configured from environment variables*/ + opts?: IOID4VCIServerOpts & { issuer?: VcIssuer } /*If not supplied as argument, it will be fully configured from environment variables*/ ) { dotenv.config() @@ -420,7 +420,7 @@ export class OID4VCIServer { }) } - get issuer(): VcIssuer { + get issuer(): VcIssuer { return this._issuer } diff --git a/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts b/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts index 12dcfbad..51e21da6 100644 --- a/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts +++ b/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts @@ -10,12 +10,15 @@ import { CredentialSupported, IssuerCredentialSubjectDisplay, Jwt, + JWTHeader, + JWTPayload, OpenId4VCIVersion, } from '@sphereon/oid4vci-common' import { VcIssuer } from '@sphereon/oid4vci-issuer/dist/VcIssuer' import { CredentialSupportedBuilderV1_11, VcIssuerBuilder } from '@sphereon/oid4vci-issuer/dist/builder' import { MemoryStates } from '@sphereon/oid4vci-issuer/dist/state-manager' import { IProofPurpose, IProofType } from '@sphereon/ssi-types' +import { DIDDocument } from 'did-resolver' import * as jose from 'jose' import { OID4VCIServer } from '../OID4VCIServer' @@ -40,7 +43,7 @@ interface KeyPair { jest.setTimeout(15000) describe('VcIssuer', () => { - let vcIssuer: VcIssuer + let vcIssuer: VcIssuer let server: OID4VCIServer let accessToken: AccessTokenResponse const issuerState = 'previously-created-state' @@ -96,7 +99,7 @@ describe('VcIssuer', () => { credentialSubject: {}, } - vcIssuer = new VcIssuerBuilder() + vcIssuer = new VcIssuerBuilder() // .withAuthorizationServer('https://authorization-server') .withCredentialEndpoint('http://localhost:3456/test/credential-endpoint') .withDefaultCredentialOfferBaseUri('http://localhost:3456/test') @@ -127,12 +130,29 @@ describe('VcIssuer', () => { }, }) ) - .withJWTVerifyCallback((args: { jwt: string; _kid?: string }) => { + .withJWTVerifyCallback((args: { jwt: string; kid?: string }) => { + const header = jose.decodeProtectedHeader(args.jwt) + const payload = jose.decodeJwt(args.jwt) + + const kid = header.kid ?? args.kid + const did = kid!.split('#')[0] + const didDocument: DIDDocument = { + '@context': 'https://www.w3.org/ns/did/v1', + id: did, + } + const alg = header.alg ?? 'ES256k' return Promise.resolve({ - header: jose.decodeProtectedHeader(args.jwt), - payload: jose.decodeJwt(args.jwt), - } as Jwt) + alg, + kid, + did, + didDocument, + jwt: { + header: header as JWTHeader, + payload: payload as JWTPayload, + }, + }) }) + .build() server = new OID4VCIServer({ diff --git a/packages/issuer-rest/lib/__tests__/IssuerTokenServer.spec.ts b/packages/issuer-rest/lib/__tests__/IssuerTokenServer.spec.ts index 853292cc..807bc422 100644 --- a/packages/issuer-rest/lib/__tests__/IssuerTokenServer.spec.ts +++ b/packages/issuer-rest/lib/__tests__/IssuerTokenServer.spec.ts @@ -14,6 +14,7 @@ import { } from '@sphereon/oid4vci-common' import { VcIssuer } from '@sphereon/oid4vci-issuer' import { MemoryStates } from '@sphereon/oid4vci-issuer/dist/state-manager' +import { DIDDocument } from 'did-resolver' import { Express } from 'express' import * as jose from 'jose' import requests from 'supertest' @@ -82,7 +83,7 @@ describe('OID4VCIServer', () => { await credentialOfferSessions.set(preAuthorizedCode2, credentialOfferState2) await credentialOfferSessions.set(preAuthorizedCode3, credentialOfferState3) - const vcIssuer: VcIssuer = new VcIssuer( + const vcIssuer: VcIssuer = new VcIssuer( { // authorization_server: 'https://authorization-server', // credential_endpoint: 'https://credential-endpoint', diff --git a/packages/issuer-rest/package.json b/packages/issuer-rest/package.json index 33a90c85..9995b697 100644 --- a/packages/issuer-rest/package.json +++ b/packages/issuer-rest/package.json @@ -22,6 +22,7 @@ }, "devDependencies": { "@sphereon/oid4vci-client": "workspace:*", + "did-resolver": "^4.1.0", "@digitalcredentials/did-method-key": "^2.0.3", "@types/body-parser": "^1.19.2", "@types/cookie-parser": "^1.4.3", diff --git a/packages/issuer/lib/VcIssuer.ts b/packages/issuer/lib/VcIssuer.ts index 3c8237ac..48533d03 100644 --- a/packages/issuer/lib/VcIssuer.ts +++ b/packages/issuer/lib/VcIssuer.ts @@ -15,15 +15,18 @@ import { CredentialOfferV1_0_11, CredentialRequestV1_0_11, CredentialResponse, + DID_NO_DIDDOC_ERROR, Grant, IAT_ERROR, ISSUER_CONFIG_ERROR, IssuerCredentialDefinition, IssueStatus, IStateManager, - Jwt, JWT_VERIFY_CONFIG_ERROR, JWTVerifyCallback, + JwtVerifyResult, + KID_DID_NO_DID_ERROR, + KID_JWK_X5C_ERROR, NO_ISS_IN_AUTHORIZATION_CODE_CONTEXT, OID4VCICredentialFormat, OpenId4VCIVersion, @@ -42,12 +45,12 @@ import { CredentialDataSupplier, CredentialSignerCallback } from './types' const SECOND = 1000 -export class VcIssuer { +export class VcIssuer { private readonly _issuerMetadata: CredentialIssuerMetadataOpts private readonly _userPinRequired: boolean private readonly _defaultCredentialOfferBaseUri?: string private readonly _credentialSignerCallback?: CredentialSignerCallback - private readonly _jwtVerifyCallback?: JWTVerifyCallback + private readonly _jwtVerifyCallback?: JWTVerifyCallback private readonly _credentialDataSupplier?: CredentialDataSupplier private readonly _credentialOfferSessions: IStateManager private readonly _cNonces: IStateManager @@ -64,7 +67,7 @@ export class VcIssuer { cNonces: IStateManager uris?: IStateManager credentialSignerCallback?: CredentialSignerCallback - jwtVerifyCallback?: JWTVerifyCallback + jwtVerifyCallback?: JWTVerifyCallback credentialDataSupplier?: CredentialDataSupplier cNonceExpiresIn?: number | undefined // expiration duration in seconds } @@ -214,7 +217,7 @@ export class VcIssuer { * - issuerCallback callback to issue a Verifiable Credential * - cNonce an existing c_nonce */ - public async issueCredential(opts: { + public async issueCredential(opts: { credentialRequest: CredentialRequestV1_0_11 credential?: ICredential credentialDataSupplier?: CredentialDataSupplier @@ -222,7 +225,7 @@ export class VcIssuer { newCNonce?: string cNonceExpiresIn?: number tokenExpiresIn?: number - jwtVerifyCallback?: JWTVerifyCallback + jwtVerifyCallback?: JWTVerifyCallback credentialSignerCallback?: CredentialSignerCallback responseCNonce?: string }): Promise { @@ -239,7 +242,8 @@ export class VcIssuer { preAuthorizedCode = validated.preAuthorizedCode issuerState = validated.issuerState - const { preAuthSession, authSession, cNonceState } = validated + const { preAuthSession, authSession, cNonceState, jwtVerifyResult } = validated + const did = jwtVerifyResult.did const newcNonce = opts.newCNonce ? opts.newCNonce : v4() const newcNonceState = { cNonce: newcNonce, @@ -288,12 +292,17 @@ export class VcIssuer { if (!credential) { throw Error('A credential needs to be supplied at this point') } + if (did) { + const subjects = Array.isArray(credential.credentialSubject) ? credential.credentialSubject : [credential.credentialSubject] + subjects.filter((subject) => !!subject.id).forEach((subject) => (subject.id = did)) + } const verifiableCredential = await this.issueCredentialImpl( { credentialRequest: opts.credentialRequest, format, credential, + jwtVerifyResult, }, signerCallback ) @@ -373,7 +382,7 @@ export class VcIssuer { return { session, clientId, grants } }*/ - private async validateCredentialRequestProof({ + private async validateCredentialRequestProof({ credentialRequest, jwtVerifyCallback, tokenExpiresIn, @@ -382,7 +391,7 @@ export class VcIssuer { tokenExpiresIn: number // grants?: Grant, clientId?: string - jwtVerifyCallback?: JWTVerifyCallback + jwtVerifyCallback?: JWTVerifyCallback }) { let preAuthorizedCode: string | undefined let issuerState: string | undefined @@ -395,11 +404,13 @@ export class VcIssuer { throw Error('Proof of possession is required. No proof value present in credential request') } - const { payload, header }: Jwt = jwtVerifyCallback + const jwtVerifyResult = jwtVerifyCallback ? await jwtVerifyCallback(credentialRequest.proof) : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await this._jwtVerifyCallback!(credentialRequest.proof) + const { didDocument, did, jwt } = jwtVerifyResult + const { header, payload } = jwt const { iss, aud, iat, nonce } = payload if (!nonce) { throw Error('No nonce was found in the Proof of Possession') @@ -408,15 +419,26 @@ export class VcIssuer { preAuthorizedCode = cNonceState.preAuthorizedCode issuerState = cNonceState.issuerState const createdAt = cNonceState.createdAt - - const { typ, alg, kid, jwk, x5c } = header + // The verify callback should set the correct values, but let's look at the JWT ourselves to to be sure + const alg = jwtVerifyResult.alg ?? header.alg + const kid = jwtVerifyResult.kid ?? header.kid + const jwk = jwtVerifyResult.jwk ?? header.jwk + const x5c = jwtVerifyResult.x5c ?? header.x5c + const typ = header.typ if (typ !== 'openid4vci-proof+jwt') { - throw new Error(TYP_ERROR) + throw Error(TYP_ERROR) } else if (!alg || !(alg in Alg)) { - throw new Error(ALG_ERROR) + throw Error(ALG_ERROR) } else if (!([kid, jwk, x5c].filter((x) => !!x).length === 1)) { - // throw new Error(KID_JWK_X5C_ERROR) // todo: whut. An x5c is very specific to X509 certs only + // only 1 is allowed, but need to look into whether jwk and x5c are allowed together + throw Error(KID_JWK_X5C_ERROR) + } else if (kid && !did) { + // Make sure the callback function extracts the DID from the kid + throw Error(KID_DID_NO_DID_ERROR) + } else if (did && !didDocument) { + // Make sure the callback function does DID resolution when a did is present + throw Error(DID_NO_DIDDOC_ERROR) } const preAuthSession = preAuthorizedCode ? await this.credentialOfferSessions.get(preAuthorizedCode) : undefined @@ -471,7 +493,7 @@ export class VcIssuer { } // todo: Add a check of iat against current TS on server with a skew - return { jwt: { header, payload } as Jwt, preAuthorizedCode, preAuthSession, issuerState, authSession, cNonceState } + return { jwtVerifyResult, preAuthorizedCode, preAuthSession, issuerState, authSession, cNonceState } } catch (error: unknown) { await this.updateErrorStatus({ preAuthorizedCode, issuerState, error }) throw error @@ -494,7 +516,12 @@ export class VcIssuer { } private async issueCredentialImpl( - opts: { credentialRequest: UniformCredentialRequest; credential: ICredential; format?: OID4VCICredentialFormat }, + opts: { + credentialRequest: UniformCredentialRequest + credential: ICredential + jwtVerifyResult: JwtVerifyResult + format?: OID4VCICredentialFormat + }, issuerCallback?: CredentialSignerCallback ): Promise { if ((!opts.credential && !opts.credentialRequest) || !this._credentialSignerCallback) { @@ -511,7 +538,7 @@ export class VcIssuer { return this._credentialSignerCallback } - get jwtVerifyCallback(): JWTVerifyCallback | undefined { + get jwtVerifyCallback(): JWTVerifyCallback | undefined { return this._jwtVerifyCallback } diff --git a/packages/issuer/lib/__tests__/VcIssuer.spec.ts b/packages/issuer/lib/__tests__/VcIssuer.spec.ts index 54eb6c6b..0ecfffc9 100644 --- a/packages/issuer/lib/__tests__/VcIssuer.spec.ts +++ b/packages/issuer/lib/__tests__/VcIssuer.spec.ts @@ -10,6 +10,7 @@ import { STATE_MISSING_ERROR, } from '@sphereon/oid4vci-common' import { IProofPurpose, IProofType } from '@sphereon/ssi-types' +import { DIDDocument } from 'did-resolver' import { VcIssuer } from '../VcIssuer' import { CredentialSupportedBuilderV1_11, VcIssuerBuilder } from '../builder' @@ -18,7 +19,7 @@ import { MemoryStates } from '../state-manager' const IDENTIPROOF_ISSUER_URL = 'https://issuer.research.identiproof.io' describe('VcIssuer', () => { - let vcIssuer: VcIssuer + let vcIssuer: VcIssuer const issuerState = 'previously-created-state' const clientId = 'sphereon:wallet' const preAuthorizedCode = 'test_code' @@ -73,7 +74,7 @@ describe('VcIssuer', () => { } as CredentialOfferJwtVcJsonLdAndLdpVcV1_0_11, }, }) - vcIssuer = new VcIssuerBuilder() + vcIssuer = new VcIssuerBuilder() .withAuthorizationServer('https://authorization-server') .withCredentialEndpoint('https://credential-endpoint') .withCredentialIssuer(IDENTIPROOF_ISSUER_URL) @@ -103,15 +104,24 @@ describe('VcIssuer', () => { ) .withJWTVerifyCallback(() => Promise.resolve({ - header: { - typ: 'openid4vci-proof+jwt', - alg: Alg.ES256K, - kid: 'test-kid', + did: 'did:example:1234', + kid: 'did:example:1234#auth', + alg: 'ES256k', + didDocument: { + '@context': 'https://www.w3.org/ns/did/v1', + id: 'did:example:1234', }, - payload: { - aud: 'https://credential-issuer', - iat: +new Date(), - nonce: 'test-nonce', + jwt: { + header: { + typ: 'openid4vci-proof+jwt', + alg: Alg.ES256K, + kid: 'test-kid', + }, + payload: { + aud: 'https://credential-issuer', + iat: +new Date(), + nonce: 'test-nonce', + }, }, }) ) diff --git a/packages/issuer/lib/builder/VcIssuerBuilder.ts b/packages/issuer/lib/builder/VcIssuerBuilder.ts index c8bdce4b..f73e77f6 100644 --- a/packages/issuer/lib/builder/VcIssuerBuilder.ts +++ b/packages/issuer/lib/builder/VcIssuerBuilder.ts @@ -16,7 +16,7 @@ import { CredentialDataSupplier, CredentialSignerCallback } from '../types' import { IssuerMetadataBuilderV1_11 } from './IssuerMetadataBuilderV1_11' -export class VcIssuerBuilder { +export class VcIssuerBuilder { issuerMetadataBuilder?: IssuerMetadataBuilderV1_11 issuerMetadata: Partial = {} defaultCredentialOfferBaseUri?: string @@ -26,7 +26,7 @@ export class VcIssuerBuilder { credentialOfferURIManager?: IStateManager cNonceStateManager?: IStateManager credentialSignerCallback?: CredentialSignerCallback - jwtVerifyCallback?: JWTVerifyCallback + jwtVerifyCallback?: JWTVerifyCallback credentialDataSupplier?: CredentialDataSupplier public withIssuerMetadata(issuerMetadata: CredentialIssuerMetadata) { @@ -135,7 +135,7 @@ export class VcIssuerBuilder { return this } - public withJWTVerifyCallback(verifyCallback: JWTVerifyCallback): this { + public withJWTVerifyCallback(verifyCallback: JWTVerifyCallback): this { this.jwtVerifyCallback = verifyCallback return this } @@ -145,7 +145,7 @@ export class VcIssuerBuilder { return this } - public build(): VcIssuer { + public build(): VcIssuer { if (!this.credentialOfferStateManager) { throw new Error(TokenErrorResponse.invalid_request) } diff --git a/packages/issuer/lib/types/index.ts b/packages/issuer/lib/types/index.ts index af0972e1..11dda9f6 100644 --- a/packages/issuer/lib/types/index.ts +++ b/packages/issuer/lib/types/index.ts @@ -2,6 +2,7 @@ import { AssertedUniformCredentialOffer, CNonceState, CredentialDataSupplierInput, + JwtVerifyResult, OID4VCICredentialFormat, UniformCredentialRequest, } from '@sphereon/oid4vci-common' @@ -11,6 +12,11 @@ export type CredentialSignerCallback = (opts: { credentialRequest: UniformCredentialRequest credential: ICredential format?: OID4VCICredentialFormat + /** + * We use any since we don't want to expose the DID Document TS type to too many interfaces. + * An implementation that wants to look into the DIDDoc would have to do a cast in the signer callback implementation + */ + jwtVerifyResult: JwtVerifyResult }) => Promise export interface CredentialDataSupplierArgs extends CNonceState { diff --git a/packages/issuer/package.json b/packages/issuer/package.json index e7a561aa..2cc3e842 100644 --- a/packages/issuer/package.json +++ b/packages/issuer/package.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@sphereon/oid4vci-client": "workspace:*", + "did-resolver": "^4.1.0", "@types/jest": "^29.5.0", "@types/node": "^18.15.3", "@types/uuid": "^9.0.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d017c52e..916a253f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@types/uuid': specifier: ^9.0.1 version: 9.0.1 + did-resolver: + specifier: ^4.1.0 + version: 4.1.0 expo: specifier: ^48.0.11 version: 48.0.11(@babel/core@7.21.4) @@ -221,6 +224,9 @@ importers: '@types/uuid': specifier: ^9.0.1 version: 9.0.1 + did-resolver: + specifier: ^4.1.0 + version: 4.1.0 packages/issuer-rest: dependencies: @@ -282,6 +288,9 @@ importers: '@types/uuid': specifier: ^9.0.1 version: 9.0.1 + did-resolver: + specifier: ^4.1.0 + version: 4.1.0 jest: specifier: ^29.5.0 version: 29.5.0(@types/node@18.16.0)(ts-node@10.9.1) @@ -5538,6 +5547,10 @@ packages: resolution: {integrity: sha512-iFpszgSxc7d1kNBJWC+PAzNTpe5LPalzsIunTMIpbG3O37Q7Zi7u4iIaedaM7UhziBhT+Agr9DyvAiXSUyfepQ==} dev: false + /did-resolver@4.1.0: + resolution: {integrity: sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==} + dev: true + /diff-sequences@29.4.3: resolution: {integrity: sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6642,7 +6655,7 @@ packages: resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} engines: {node: '>=14.14'} dependencies: - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 jsonfile: 6.1.0 universalify: 2.0.0 dev: true @@ -8431,7 +8444,7 @@ packages: dependencies: universalify: 2.0.0 optionalDependencies: - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 /jsonld-document-loader@1.2.1: resolution: {integrity: sha512-CtFyIBZApeVvs6QgyS7Gcp8h1dUs+1XNHcV4Sr6O9ItPaL0hVgqe47Tgs3RNH0A5Bc4p3UFPKAJVHKSOQq4mhQ==} @@ -9769,7 +9782,7 @@ packages: env-paths: 2.2.1 exponential-backoff: 3.1.1 glob: 7.2.3 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 make-fetch-happen: 11.1.1 nopt: 6.0.0 npmlog: 6.0.2 @@ -13045,7 +13058,7 @@ packages: engines: {node: '>=6'} dependencies: detect-indent: 5.0.0 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 make-dir: 2.1.0 pify: 4.0.1 sort-keys: 2.0.0