diff --git a/src/configuration/verifier/VerifierConfigurationService.ts b/src/configuration/verifier/VerifierConfigurationService.ts index 5ffa427..0666de7 100644 --- a/src/configuration/verifier/VerifierConfigurationService.ts +++ b/src/configuration/verifier/VerifierConfigurationService.ts @@ -4,11 +4,19 @@ import { authorizationServerMetadataConfiguration } from "../../authorizationSer import { config } from "../../../config"; import { VerifierConfigurationInterface } from "../../services/interfaces"; import "reflect-metadata"; +import { PresentationParserChain } from "../../vp_token/PresentationParserChain"; +import { PublicKeyResolverChain } from "../../vp_token/PublicKeyResolverChain"; @injectable() export class VerifierConfigurationService implements VerifierConfigurationInterface { + getPresentationParserChain(): PresentationParserChain { + return new PresentationParserChain(); + } + getPublicKeyResolverChain(): PublicKeyResolverChain { + return new PublicKeyResolverChain(); + } getPresentationDefinitions(): any[] { return [] diff --git a/src/services/interfaces.ts b/src/services/interfaces.ts index fa004cd..01fb422 100644 --- a/src/services/interfaces.ts +++ b/src/services/interfaces.ts @@ -6,6 +6,8 @@ import { SupportedCredentialProtocol } from "../lib/CredentialIssuerConfig/Suppo import { CredentialView } from "../authorization/types"; import { AuthorizationServerState } from "../entities/AuthorizationServerState.entity"; import { PresentationClaims, RelyingPartyState } from "../entities/RelyingPartyState.entity"; +import { PresentationParserChain } from "../vp_token/PresentationParserChain"; +import { PublicKeyResolverChain } from "../vp_token/PublicKeyResolverChain"; export interface CredentialSigner { sign(payload: any, headers?: any, disclosureFrame?: any): Promise<{ jws: string }>; @@ -32,8 +34,8 @@ export interface OpenidForPresentationsReceivingInterface { getSignedRequestObject(ctx: { req: Request, res: Response }): Promise; generateAuthorizationRequestURL(ctx: { req: Request, res: Response }, presentationDefinition: object, sessionId: string, callbackEndpoint?: string): Promise<{ url: URL; stateId: string }>; - getPresentationBySessionId(ctx: { req: Request, res: Response }): Promise<{ status: true, rpState: RelyingPartyState } | { status: false }>; - getPresentationById(id: string): Promise<{ status: boolean, presentationClaims?: PresentationClaims, rawPresentation?: string }>; + getPresentationBySessionId(sessionId: string): Promise<{ status: true, rpState: RelyingPartyState, presentations: unknown[] } | { status: false }>; + getPresentationById(id: string): Promise<{ status: boolean, presentationClaims?: PresentationClaims, presentations?: unknown[] }>; responseHandler(ctx: { req: Request, res: Response }): Promise; } @@ -41,6 +43,8 @@ export interface OpenidForPresentationsReceivingInterface { export interface VerifierConfigurationInterface { getConfiguration(): OpenidForPresentationsConfiguration; getPresentationDefinitions(): any[]; + getPresentationParserChain(): PresentationParserChain; + getPublicKeyResolverChain(): PublicKeyResolverChain; } @@ -82,4 +86,4 @@ export interface CredentialDataModel { export interface CredentialDataModelRegistry extends CredentialDataModel { register(dm: CredentialDataModel): void; -} \ No newline at end of file +} diff --git a/src/vp_token/IPresentationParser.ts b/src/vp_token/IPresentationParser.ts new file mode 100644 index 0000000..2c66551 --- /dev/null +++ b/src/vp_token/IPresentationParser.ts @@ -0,0 +1,4 @@ + +export interface IPresentationParser { + parse(presentationRawFormat: string | object): Promise<{ credentialImage: string, credentialPayload: any } | { error: "PARSE_ERROR" }>; +} \ No newline at end of file diff --git a/src/vp_token/IPublicKeyResolver.ts b/src/vp_token/IPublicKeyResolver.ts new file mode 100644 index 0000000..e239c3b --- /dev/null +++ b/src/vp_token/IPublicKeyResolver.ts @@ -0,0 +1,5 @@ +import { KeyLike } from "jose"; + +export interface IPublicKeyResolver { + resolve(rawPresentation: string | object, format: string): Promise<{ publicKey: KeyLike, isTrusted: boolean } | { error: "UNABLE_TO_RESOLVE_PUBKEY" }>; +} diff --git a/src/vp_token/PresentationParserChain.ts b/src/vp_token/PresentationParserChain.ts new file mode 100644 index 0000000..a58ebca --- /dev/null +++ b/src/vp_token/PresentationParserChain.ts @@ -0,0 +1,25 @@ +import { IPresentationParser } from "./IPresentationParser"; +import { sdJwtDefaultParser } from "./sdJwtDefaultParser"; + +export class PresentationParserChain { + + constructor(private parserList: IPresentationParser[] = [ + sdJwtDefaultParser + ]) { } + + addParser(p: IPresentationParser): this { + this.parserList.push(p); + return this; + } + + async parse(rawPresentation: any): Promise<{ credentialImage: string, credentialPayload: any } | { error: "PARSE_ERROR" }> { + for (const p of [...this.parserList].reverse()) { + const result = await p.parse(rawPresentation); + if ('error' in result) { + continue; + } + return { ...result }; + } + return { error: "PARSE_ERROR" }; + } +} diff --git a/src/vp_token/PublicKeyResolverChain.ts b/src/vp_token/PublicKeyResolverChain.ts new file mode 100644 index 0000000..5585e67 --- /dev/null +++ b/src/vp_token/PublicKeyResolverChain.ts @@ -0,0 +1,26 @@ +import { KeyLike } from "jose"; +import { IPublicKeyResolver } from "./IPublicKeyResolver"; +import { sdJwtPublicKeyResolverUsingX5CHeader } from "./sdJwtPublicKeyResolverUsingX5CHeader"; + +export class PublicKeyResolverChain { + + constructor(private resolverList: IPublicKeyResolver[] = [ + sdJwtPublicKeyResolverUsingX5CHeader + ]) { } + + addResolver(p: IPublicKeyResolver): this { + this.resolverList.push(p); + return this; + } + + async resolve(rawPresentation: any, format: string): Promise<{ publicKey: KeyLike, isTrusted: boolean } | { error: "UNABLE_TO_RESOLVE_PUBKEY" }> { + for (const p of [...this.resolverList].reverse()) { + const result = await p.resolve(rawPresentation, format); + if ('error' in result) { + continue; + } + return { ...result }; + } + return { error: "UNABLE_TO_RESOLVE_PUBKEY" }; + } +} diff --git a/src/vp_token/sdJwtDefaultParser.ts b/src/vp_token/sdJwtDefaultParser.ts new file mode 100644 index 0000000..d04cff8 --- /dev/null +++ b/src/vp_token/sdJwtDefaultParser.ts @@ -0,0 +1,87 @@ +import { config } from "../../config"; +import { generateDataUriFromSvg } from "../lib/generateDataUriFromSvg"; +import { + HasherAlgorithm, + HasherAndAlgorithm, + SdJwt, +} from '@sd-jwt/core' + +import base64url from "base64url"; +import crypto from 'node:crypto'; +import axios from "axios"; +import { IPresentationParser } from "./IPresentationParser"; + + +// Encoding the string into a Uint8Array +const hasherAndAlgorithm: HasherAndAlgorithm = { + hasher: (input: string) => { + // return crypto.subtle.digest('SHA-256', encoder.encode(input)).then((v) => new Uint8Array(v)); + return new Promise((resolve, _reject) => { + const hash = crypto.createHash('sha256'); + hash.update(input); + resolve(new Uint8Array(hash.digest())); + }); + }, + algorithm: HasherAlgorithm.Sha256 +} + +export const sdJwtDefaultParser: IPresentationParser = { + async parse(presentationRawFormat) { + if (typeof presentationRawFormat != 'string') { + return { error: "PARSE_ERROR" }; + } + + try { + let defaultLocale = 'en-US'; + let credentialImage = null; + let credentialPayload = null; + + if (presentationRawFormat.includes('~')) { + const parsedCredential = await SdJwt.fromCompact, any>(presentationRawFormat) + .withHasher(hasherAndAlgorithm) + .getPrettyClaims(); + const sdJwtHeader = JSON.parse(base64url.decode(presentationRawFormat.split('.')[0])) as any; + credentialPayload = parsedCredential; + console.log("Parsed credential = ", parsedCredential) + const credentialIssuerMetadata = await axios.get(parsedCredential.iss + "/.well-known/openid-credential-issuer").catch(() => null); + let fistImageUri; + if (!credentialIssuerMetadata) { + console.error("Couldnt get image for the credential " + presentationRawFormat); + } + else { + console.log("Credential issuer metadata = ", credentialIssuerMetadata?.data) + fistImageUri = Object.values(credentialIssuerMetadata?.data?.credential_configurations_supported).map((conf: any) => { + if (conf?.vct == parsedCredential?.vct) { + return conf?.display && conf?.display[0] && conf?.display[0]?.background_image?.uri ? conf?.display[0]?.background_image?.uri : undefined; + } + return undefined; + }).filter((val) => val)[0]; + } + + if (sdJwtHeader?.vctm && sdJwtHeader?.vctm?.display?.length > 0 && sdJwtHeader?.vctm?.display[0][defaultLocale]?.rendering?.svg_templates.length > 0 && sdJwtHeader?.vctm?.display[0][defaultLocale]?.rendering?.svg_templates[0]?.uri) { + const response = await axios.get(sdJwtHeader?.vctm?.display[0][defaultLocale].rendering.svg_templates[0].uri); + const svgText = response.data; + const pathsWithValues: any[] = []; + const dataUri = generateDataUriFromSvg(svgText, pathsWithValues); // replaces all with empty string + credentialImage = dataUri; + } + else if (sdJwtHeader?.vctm && sdJwtHeader?.vctm?.display?.length > 0 && sdJwtHeader?.vctm?.display[0][defaultLocale]?.rendering?.simple?.logo?.uri) { + credentialImage = sdJwtHeader?.vctm?.display[0][defaultLocale]?.rendering?.simple?.logo?.uri; + } + else if (fistImageUri) { + credentialImage = fistImageUri; + } + else { + credentialImage = config.url + "/images/card.png"; + } + return { credentialImage, credentialPayload }; + } + + return { error: "PARSE_ERROR" }; + } + catch (err) { + console.error(err); + return { error: "PARSE_ERROR" }; + } + }, +} \ No newline at end of file diff --git a/src/vp_token/sdJwtPublicKeyResolverUsingX5CHeader.ts b/src/vp_token/sdJwtPublicKeyResolverUsingX5CHeader.ts new file mode 100644 index 0000000..e180f06 --- /dev/null +++ b/src/vp_token/sdJwtPublicKeyResolverUsingX5CHeader.ts @@ -0,0 +1,40 @@ +import base64url from "base64url"; +import { VerifiableCredentialFormat } from "../types/oid4vci"; +import { IPublicKeyResolver } from "./IPublicKeyResolver"; +import { importX509, JWTHeaderParameters, KeyLike } from "jose"; +import { verifyCertificateChain } from "../util/verifyCertificateChain"; +import { config } from "../../config"; + +export const sdJwtPublicKeyResolverUsingX5CHeader: IPublicKeyResolver = { + async resolve(rawPresentation: string | object, format: string): Promise<{ publicKey: KeyLike, isTrusted: boolean } | { error: "UNABLE_TO_RESOLVE_PUBKEY" }> { + if (format != VerifiableCredentialFormat.VC_SD_JWT || typeof rawPresentation != 'string') { + return { error: "UNABLE_TO_RESOLVE_PUBKEY" }; + } + + let isTrusted = false; + const [h, , ] = rawPresentation.split('.'); + const header = JSON.parse(base64url.decode(h)) as JWTHeaderParameters; + if (header['x5c'] && header['x5c'] instanceof Array && header['x5c'][0]) { + const pemCerts = header['x5c'].map(cert => { + const pemCert = `-----BEGIN CERTIFICATE-----\n${cert}\n-----END CERTIFICATE-----`; + return pemCert; + }); + + // check if at least one root certificate verifies this credential + const result: boolean[] = await Promise.all(config.trustedRootCertificates.map(async (rootCert: string) => { + return verifyCertificateChain(rootCert, pemCerts); + })); + + + if (config.trustedRootCertificates.length != 0 && !result.includes(true)) { + console.log("Chain is not trusted"); + isTrusted = false; + } + isTrusted = true; + console.info("Chain is trusted"); + const cert = await importX509(pemCerts[0], header['alg'] as string); + return { isTrusted: isTrusted, publicKey: cert }; + } + return { error: "UNABLE_TO_RESOLVE_PUBKEY" }; + } +}