From f93426f85584adfb59db74097e791152950ea02f Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Sun, 24 Nov 2024 00:25:41 +0100 Subject: [PATCH] feat: add key attestations Signed-off-by: Timo Glastra --- packages/oauth2/src/Oauth2Client.ts | 2 +- .../parse-access-token-request.test.ts | 2 +- .../src/access-token/create-access-token.ts | 39 ++-- ...create-authorization-challenge-response.ts | 2 +- packages/oauth2/src/callbacks.ts | 23 ++- .../client-attestation/clent-attestation.ts | 2 +- .../client-attestation-pop.ts | 2 +- packages/oauth2/src/common/jwk/jwks.ts | 32 +++- packages/oauth2/src/common/jwt/decode-jwt.ts | 22 +++ packages/oauth2/src/common/jwt/v-jwt.ts | 20 ++- packages/oauth2/src/common/jwt/verify-jwt.ts | 23 ++- packages/oauth2/src/dpop/dpop.ts | 2 +- packages/oauth2/src/index.ts | 5 + packages/oauth2/tests/util.ts | 14 +- packages/oid4vci/src/Oid4vciClient.ts | 13 +- packages/oid4vci/src/Oid4vciIssuer.ts | 49 +++++ .../src/__tests__/Oid4vciClient.test.ts | 6 +- .../src/__tests__/Oid4vciIssuer.test.ts | 167 +++++++++++------ .../parse-credential-request.test.ts | 72 ++++---- .../__tests__/v-credential-request.test.ts | 46 ++--- .../parse-credential-request.ts | 5 +- .../v-credential-request-common.ts | 29 ++- .../attestation/attestation-proof-type.ts | 39 ++++ .../attestation/v-attestation-proof-type.ts | 25 +++ .../oid4vci/src/formats/proof-type/index.ts | 17 +- .../formats/proof-type/jwt/jwt-proof-type.ts | 84 +++++++-- .../proof-type/jwt/v-jwt-proof-type.ts | 1 + .../src/key-attestation/key-attestation.ts | 169 ++++++++++++++++++ .../src/key-attestation/v-key-attestation.ts | 55 ++++++ ...edential-configuration-supported-common.ts | 9 +- packages/utils/src/index.ts | 2 +- packages/utils/src/type.ts | 3 +- 32 files changed, 792 insertions(+), 189 deletions(-) create mode 100644 packages/oid4vci/src/formats/proof-type/attestation/attestation-proof-type.ts create mode 100644 packages/oid4vci/src/formats/proof-type/attestation/v-attestation-proof-type.ts create mode 100644 packages/oid4vci/src/key-attestation/key-attestation.ts create mode 100644 packages/oid4vci/src/key-attestation/v-key-attestation.ts diff --git a/packages/oauth2/src/Oauth2Client.ts b/packages/oauth2/src/Oauth2Client.ts index 7a327a6..a4ed38e 100644 --- a/packages/oauth2/src/Oauth2Client.ts +++ b/packages/oauth2/src/Oauth2Client.ts @@ -21,12 +21,12 @@ import { createClientAttestationJwt, } from './client-attestation/clent-attestation' import { Oauth2ErrorCodes } from './common/v-oauth2-error' +import { extractDpopNonceFromHeaders } from './dpop/dpop' import { Oauth2ClientAuthorizationChallengeError } from './error/Oauth2ClientAuthorizationChallengeError' import { fetchAuthorizationServerMetadata } from './metadata/authorization-server/authorization-server-metadata' import type { AuthorizationServerMetadata } from './metadata/authorization-server/v-authorization-server-metadata' import { createPkce } from './pkce' import { type ResourceRequestOptions, resourceRequest } from './resource-request/make-resource-request' -import { extractDpopNonceFromHeaders } from './dpop/dpop' export interface Oauth2ClientOptions { /** diff --git a/packages/oauth2/src/access-token/__tests__/parse-access-token-request.test.ts b/packages/oauth2/src/access-token/__tests__/parse-access-token-request.test.ts index 72022ed..4bb409b 100644 --- a/packages/oauth2/src/access-token/__tests__/parse-access-token-request.test.ts +++ b/packages/oauth2/src/access-token/__tests__/parse-access-token-request.test.ts @@ -85,7 +85,7 @@ describe('Parse Access Token Request', () => { }, request: { headers: new Headers({ - DPoP: ['hello', 'two'], + DPoP: ['ey.ey.S', 'ey.ey.S'], }), method: 'POST', url: 'https://request.com/token', diff --git a/packages/oauth2/src/access-token/create-access-token.ts b/packages/oauth2/src/access-token/create-access-token.ts index 5a28260..9581738 100644 --- a/packages/oauth2/src/access-token/create-access-token.ts +++ b/packages/oauth2/src/access-token/create-access-token.ts @@ -1,11 +1,16 @@ -import { addSecondsToDate, dateToSeconds, encodeToBase64Url } from '@animo-id/oauth2-utils' +import { addSecondsToDate, dateToSeconds, encodeToBase64Url, parseWithErrorHandling } from '@animo-id/oauth2-utils' import type { CallbackContext } from '../callbacks' import { HashAlgorithm } from '../callbacks' import { calculateJwkThumbprint } from '../common/jwk/jwk-thumbprint' import type { Jwk } from '../common/jwk/v-jwk' import { jwtHeaderFromJwtSigner } from '../common/jwt/decode-jwt' import type { JwtSigner } from '../common/jwt/v-jwt' -import type { AccessTokenProfileJwtHeader, AccessTokenProfileJwtPayload } from './v-access-token-jwt' +import { + type AccessTokenProfileJwtHeader, + type AccessTokenProfileJwtPayload, + vAccessTokenProfileJwtHeader, + vAccessTokenProfileJwtPayload, +} from './v-access-token-jwt' export interface CreateAccessTokenOptions { callbacks: Pick @@ -70,14 +75,14 @@ export interface CreateAccessTokenOptions { * @see https://datatracker.ietf.org/doc/html/rfc9068 */ export async function createAccessTokenJwt(options: CreateAccessTokenOptions) { - const header = { + const header = parseWithErrorHandling(vAccessTokenProfileJwtHeader, { ...jwtHeaderFromJwtSigner(options.signer), typ: 'at+jwt', - } satisfies AccessTokenProfileJwtHeader + } satisfies AccessTokenProfileJwtHeader) const now = options.now ?? new Date() - const payload: AccessTokenProfileJwtPayload = { + const payload = parseWithErrorHandling(vAccessTokenProfileJwtPayload, { iat: dateToSeconds(now), exp: dateToSeconds(addSecondsToDate(now, options.expiresInSeconds)), aud: options.audience, @@ -86,23 +91,23 @@ export async function createAccessTokenJwt(options: CreateAccessTokenOptions) { client_id: options.clientId, sub: options.subject, scope: options.scope, + cnf: options.dpopJwk + ? { + jkt: await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: options.callbacks.hash, + jwk: options.dpopJwk, + }), + } + : undefined, ...options.additionalPayload, - } - - if (options.dpopJwk) { - payload.cnf = { - jkt: await calculateJwkThumbprint({ - hashAlgorithm: HashAlgorithm.Sha256, - hashCallback: options.callbacks.hash, - jwk: options.dpopJwk, - }), - } - } + } satisfies AccessTokenProfileJwtPayload) - const jwt = await options.callbacks.signJwt(options.signer, { + const { jwt } = await options.callbacks.signJwt(options.signer, { header, payload, }) + return { jwt, } diff --git a/packages/oauth2/src/authorization-challenge/create-authorization-challenge-response.ts b/packages/oauth2/src/authorization-challenge/create-authorization-challenge-response.ts index 614b605..ff525db 100644 --- a/packages/oauth2/src/authorization-challenge/create-authorization-challenge-response.ts +++ b/packages/oauth2/src/authorization-challenge/create-authorization-challenge-response.ts @@ -50,7 +50,7 @@ export interface CreateAuthorizationChallengeErrorResponseOptions { * * If you want to require presentation of a */ - error: Oauth2ErrorCodes | StringWithAutoCompletion + error: StringWithAutoCompletion /** * Optional error description diff --git a/packages/oauth2/src/callbacks.ts b/packages/oauth2/src/callbacks.ts index 93d03ab..4c0c19d 100644 --- a/packages/oauth2/src/callbacks.ts +++ b/packages/oauth2/src/callbacks.ts @@ -1,5 +1,6 @@ -import type { Fetch } from '@animo-id/oauth2-utils' +import type { Fetch, OrPromise } from '@animo-id/oauth2-utils' import type { ClientAuthenticationCallback } from './client-authentication' +import type { Jwk } from './common/jwk/v-jwk' import type { JwtHeader, JwtPayload, JwtSigner } from './common/jwt/v-jwt' /** @@ -12,19 +13,31 @@ export enum HashAlgorithm { /** * Callback used for operations that require hashing */ -export type HashCallback = (data: Uint8Array, alg: HashAlgorithm) => Promise | Uint8Array +export type HashCallback = (data: Uint8Array, alg: HashAlgorithm) => OrPromise -export type GenerateRandomCallback = (byteLength: number) => Promise | Uint8Array +export type GenerateRandomCallback = (byteLength: number) => OrPromise export type SignJwtCallback = ( jwtSigner: JwtSigner, jwt: { header: JwtHeader; payload: JwtPayload } -) => Promise | string +) => OrPromise<{ + jwt: string + signerJwk: Jwk +}> export type VerifyJwtCallback = ( jwtSigner: JwtSigner, jwt: { header: JwtHeader; payload: JwtPayload; compact: string } -) => Promise | boolean +) => OrPromise< + | { + verified: true + signerJwk: Jwk + } + | { + verified: false + signerJwk?: Jwk + } +> /** * Callback context provides the callbacks that are required for the oid4vc library diff --git a/packages/oauth2/src/client-attestation/clent-attestation.ts b/packages/oauth2/src/client-attestation/clent-attestation.ts index 386a37a..02fa558 100644 --- a/packages/oauth2/src/client-attestation/clent-attestation.ts +++ b/packages/oauth2/src/client-attestation/clent-attestation.ts @@ -117,7 +117,7 @@ export async function createClientAttestationJwt(options: CreateClientAttestatio ...options.additionalPayload, } satisfies ClientAttestationJwtPayload) - const jwt = await options.callbacks.signJwt(options.signer, { + const { jwt } = await options.callbacks.signJwt(options.signer, { header, payload, }) diff --git a/packages/oauth2/src/client-attestation/client-attestation-pop.ts b/packages/oauth2/src/client-attestation/client-attestation-pop.ts index c860cef..942ce95 100644 --- a/packages/oauth2/src/client-attestation/client-attestation-pop.ts +++ b/packages/oauth2/src/client-attestation/client-attestation-pop.ts @@ -220,7 +220,7 @@ export async function createClientAttestationPopJwt(options: CreateClientAttesta ...options.additionalPayload, } satisfies ClientAttestationPopJwtPayload) - const jwt = await options.callbacks.signJwt(options.signer, { + const { jwt } = await options.callbacks.signJwt(options.signer, { header, payload, }) diff --git a/packages/oauth2/src/common/jwk/jwks.ts b/packages/oauth2/src/common/jwk/jwks.ts index 93acc8f..0a4aa3d 100644 --- a/packages/oauth2/src/common/jwk/jwks.ts +++ b/packages/oauth2/src/common/jwk/jwks.ts @@ -1,5 +1,7 @@ +import { type CallbackContext, HashAlgorithm } from '../../callbacks' import { Oauth2Error } from '../../error/Oauth2Error' -import type { JwkSet } from './v-jwk' +import { calculateJwkThumbprint } from './jwk-thumbprint' +import type { Jwk, JwkSet } from './v-jwk' interface ExtractJwkFromJwksForJwtOptions { kid?: string @@ -32,3 +34,31 @@ export function extractJwkFromJwksForJwt(options: ExtractJwkFromJwksForJwtOption `Unable to extract jwk from jwks for use '${options.use}'${options.kid ? `with kid '${options.kid}'.` : '. No kid provided and more than jwk.'}` ) } + +export async function isJwkInSet({ + jwk, + jwks, + callbacks, +}: { + jwk: Jwk + jwks: Jwk[] + callbacks: Pick +}) { + const jwkThumbprint = await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: callbacks.hash, + jwk, + }) + + for (const jwkFromSet of jwks) { + const jwkFromSetThumbprint = await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: callbacks.hash, + jwk: jwkFromSet, + }) + + if (jwkFromSetThumbprint === jwkThumbprint) return true + } + + return false +} diff --git a/packages/oauth2/src/common/jwt/decode-jwt.ts b/packages/oauth2/src/common/jwt/decode-jwt.ts index c09c5c4..74046a8 100644 --- a/packages/oauth2/src/common/jwt/decode-jwt.ts +++ b/packages/oauth2/src/common/jwt/decode-jwt.ts @@ -83,6 +83,14 @@ export function jwtHeaderFromJwtSigner(signer: JwtSigner) { } as const } + if (signer.method === 'trustChain') { + return { + alg: signer.alg, + kid: signer.kid, + trust_chain: signer.trustChain, + } as const + } + if (signer.method === 'jwk') { return { alg: signer.alg, @@ -111,6 +119,20 @@ export function jwtSignerFromJwt({ header, payload }: Pick diff --git a/packages/oauth2/src/common/jwt/verify-jwt.ts b/packages/oauth2/src/common/jwt/verify-jwt.ts index df14cbc..e358537 100644 --- a/packages/oauth2/src/common/jwt/verify-jwt.ts +++ b/packages/oauth2/src/common/jwt/verify-jwt.ts @@ -1,7 +1,8 @@ import { dateToSeconds } from '@animo-id/oauth2-utils' import type { VerifyJwtCallback } from '../../callbacks' import { Oauth2JwtVerificationError } from '../../error/Oauth2JwtVerificationError' -import type { JwtHeader, JwtPayload, JwtSigner } from './v-jwt' +import type { Jwk } from '../jwk/v-jwk' +import type { JwtHeader, JwtPayload, JwtSigner, JwtSignerWithJwk } from './v-jwt' export interface VerifyJwtOptions { /** @@ -76,16 +77,23 @@ export interface VerifyJwtOptions { expectedSubject?: string } -export async function verifyJwt(options: VerifyJwtOptions) { +export interface VerifyJwtReturn { + signer: JwtSignerWithJwk +} + +export async function verifyJwt(options: VerifyJwtOptions): Promise { const errorMessage = options.errorMessage ?? 'Error during verification of jwt.' + + let signerJwk: Jwk try { - const isValid = await options.verifyJwtCallback(options.signer, { + const result = await options.verifyJwtCallback(options.signer, { header: options.header, payload: options.payload, compact: options.compact, }) - if (!isValid) throw new Oauth2JwtVerificationError(errorMessage) + if (!result.verified) throw new Oauth2JwtVerificationError(errorMessage) + signerJwk = result.signerJwk } catch (error) { if (error instanceof Oauth2JwtVerificationError) throw error throw new Oauth2JwtVerificationError(errorMessage, { cause: error }) @@ -118,4 +126,11 @@ export async function verifyJwt(options: VerifyJwtOptions) { if (options.expectedSubject && options.expectedSubject !== options.payload.sub) { throw new Oauth2JwtVerificationError(`${errorMessage} jwt 'sub' does not match expected value.`) } + + return { + signer: { + ...options.signer, + publicJwk: signerJwk, + }, + } } diff --git a/packages/oauth2/src/dpop/dpop.ts b/packages/oauth2/src/dpop/dpop.ts index bb22f57..225aa5b 100644 --- a/packages/oauth2/src/dpop/dpop.ts +++ b/packages/oauth2/src/dpop/dpop.ts @@ -97,7 +97,7 @@ export async function createDpopJwt(options: CreateDpopJwtOptions) { ...options.additionalPayload, } satisfies DpopJwtPayload) - const jwt = await options.callbacks.signJwt(options.signer, { + const { jwt } = await options.callbacks.signJwt(options.signer, { header, payload, }) diff --git a/packages/oauth2/src/index.ts b/packages/oauth2/src/index.ts index 5b105ef..1087712 100644 --- a/packages/oauth2/src/index.ts +++ b/packages/oauth2/src/index.ts @@ -8,6 +8,10 @@ export { type HttpMethod, getGlobalConfig, setGlobalConfig, type Oid4vcTsConfig export { Oauth2ErrorCodes, type Oauth2ErrorResponse, vOauth2ErrorResponse } from './common/v-oauth2-error' export { calculateJwkThumbprint, type CalculateJwkThumbprintOptions } from './common/jwk/jwk-thumbprint' + +// TODO: should we move this to oauth2-utils? +export { isJwkInSet } from './common/jwk/jwks' + export { type Jwk, type JwkSet, vJwk } from './common/jwk/v-jwk' export type { AccessTokenProfileJwtPayload } from './access-token/v-access-token-jwt' @@ -28,6 +32,7 @@ export { JwtSignerDid, JwtSignerJwk, JwtSignerX5c, + JwtSignerWithJwk, vJwtHeader, vJwtPayload, vCompactJwt, diff --git a/packages/oauth2/tests/util.ts b/packages/oauth2/tests/util.ts index 0b7c821..7c98408 100644 --- a/packages/oauth2/tests/util.ts +++ b/packages/oauth2/tests/util.ts @@ -29,9 +29,14 @@ export const callbacks = { await jose.jwtVerify(compact, josePublicKey, { currentDate: payload.exp ? new Date((payload.exp - 300) * 1000) : undefined, }) - return true + return { + verified: true, + signerJwk: jwk, + } } catch (error) { - return false + return { + verified: false, + } } }, } as const satisfies Partial @@ -71,6 +76,9 @@ export const getSignJwtCallback = (privateJwks: Jwk[]): SignJwtCallback => { const josePrivateKey = await jose.importJWK(privateJwk as jose.JWK, signer.alg) const jwt = await new jose.SignJWT(payload).setProtectedHeader(header).sign(josePrivateKey) - return jwt + return { + jwt: jwt, + signerJwk: jwk, + } } } diff --git a/packages/oid4vci/src/Oid4vciClient.ts b/packages/oid4vci/src/Oid4vciClient.ts index cd149eb..c80cb79 100644 --- a/packages/oid4vci/src/Oid4vciClient.ts +++ b/packages/oid4vci/src/Oid4vciClient.ts @@ -394,7 +394,10 @@ export class Oid4vciClient { * Creates the jwt proof payload and header to be included in a credential request. */ public async createCredentialRequestJwtProof( - options: Pick & { + options: Pick< + CreateCredentialRequestJwtProofOptions, + 'signer' | 'nonce' | 'issuedAt' | 'clientId' | 'keyAttestationJwt' + > & { issuerMetadata: IssuerMetadataResult credentialConfigurationId: string } @@ -423,6 +426,13 @@ export class Oid4vciClient { `Credential configuration with id '${options.credentialConfigurationId}' does not support the '${options.signer.alg}' alg for 'jwt' proof type.` ) } + + // TODO: might be beneficial to also decode the key attestation and see if the required level is reached + if (credentialConfiguration.proof_types_supported.jwt.key_attestations_required && !options.keyAttestationJwt) { + throw new Oid4vciError( + `Credential configuration with id '${options.credentialConfigurationId}' requires key attestations for 'jwt' proof type but no 'keyAttestationJwt' was provided` + ) + } } const jwt = await createCredentialRequestJwtProof({ @@ -431,6 +441,7 @@ export class Oid4vciClient { clientId: options.clientId, issuedAt: options.issuedAt, nonce: options.nonce, + keyAttestationJwt: options.keyAttestationJwt, callbacks: this.options.callbacks, }) diff --git a/packages/oid4vci/src/Oid4vciIssuer.ts b/packages/oid4vci/src/Oid4vciIssuer.ts index 60ba4af..e099093 100644 --- a/packages/oid4vci/src/Oid4vciIssuer.ts +++ b/packages/oid4vci/src/Oid4vciIssuer.ts @@ -16,6 +16,10 @@ import { parseCredentialRequest, } from './credential-request/parse-credential-request' import { Oid4vciError } from './error/Oid4vciError' +import { + type VerifyCredentialRequestAttestationProofOptions, + verifyCredentialRequestAttestationProof, +} from './formats/proof-type/attestation/attestation-proof-type' import { type VerifyCredentialRequestJwtProofOptions, verifyCredentialRequestJwtProof, @@ -99,6 +103,7 @@ export class Oid4vciIssuer { callbacks: this.options.callbacks, credentialIssuer: options.issuerMetadata.credentialIssuer.credential_issuer, expectedNonce: options.expectedNonce, + nonceExpiresAt: options.nonceExpiresAt, jwt: options.jwt, clientId: options.clientId, now: options.now, @@ -122,6 +127,45 @@ export class Oid4vciIssuer { } } + /** + * @throws Oauth2ServerErrorResponseError - if verification of the key attestation failed. You can extract + * the credential error response from this. + */ + public async verifyCredentialRequestAttestationProof( + options: Pick< + VerifyCredentialRequestAttestationProofOptions, + 'keyAttestationJwt' | 'expectedNonce' | 'nonceExpiresAt' | 'now' + > & { + issuerMetadata: IssuerMetadataResult + } + ) { + try { + return await verifyCredentialRequestAttestationProof({ + callbacks: this.options.callbacks, + expectedNonce: options.expectedNonce, + keyAttestationJwt: options.keyAttestationJwt, + nonceExpiresAt: options.nonceExpiresAt, + now: options.now, + }) + } catch (error) { + throw new Oauth2ServerErrorResponseError( + { + error: Oauth2ErrorCodes.InvalidProof, + error_description: + // TOOD: error should have a internalErrorMessage and a publicErrorMessage + error instanceof Oauth2JwtVerificationError || error instanceof Oid4vciError + ? error.message + : 'Invalid proof', + }, + + { + internalMessage: 'Error verifying credential request proof attestation', + cause: error, + } + ) + } + } + /** * @throws Oauth2ServerErrorResponseError - when validation of the credential request fails * You can extract the credential error response from this. @@ -144,6 +188,11 @@ export class Oid4vciIssuer { } ) } + + // TOOD: might be nice to add some extra validation params here so it's + // easy for an issuer to verify whether the request matches with the configuration + // e.g. alg of holder binding, key_attestations_required, proof_types_supported, + // request matches offer, etc.. } /** diff --git a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts index 6ce49dd..a823dca 100644 --- a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts +++ b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts @@ -99,7 +99,7 @@ describe('Oid4vciClient', () => { nonce: accessTokenResponse.c_nonce, }) expect(proofJwt).toMatch( - 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDpqd2s6ZXlKcmRIa2lPaUpGUXlJc0luZ2lPaUpCUlZod1NIa3hNRWRvZEZkb2JGWlFUbTF5Um5OaWVYUmZkMFJ6VVY4M2NUTmthazV1Y21oNmFsODBJaXdpZVNJNklrUkhWRUZEVDBGQmJsRlVaWEJoUkRRd1ozbEhPVnBzTFc5RWFFOXNkak5WUW14VWRIaEpaWEkxWlc4aUxDSmpjbllpT2lKUUxUSTFOaUo5IzAiLCJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCJ9.eyJub25jZSI6IjQ2MzI1MzkxNzA5NDg2OTE3MjA3ODMxMCIsImF1ZCI6Imh0dHBzOi8vYWdlbnQucGFyYWR5bS5pZC9vaWQ0dmNpL2RyYWZ0LTEzLWlzc3VlciIsImlhdCI6MTcyODUxODQwMH0.' + 'eyJhbGciOiJFUzI1NiIsInR5cCI6Im9wZW5pZDR2Y2ktcHJvb2Yrand0Iiwia2lkIjoiZGlkOmp3azpleUpyZEhraU9pSkZReUlzSW5naU9pSkJSVmh3U0hreE1FZG9kRmRvYkZaUVRtMXlSbk5pZVhSZmQwUnpVVjgzY1ROa2FrNXVjbWg2YWw4MElpd2llU0k2SWtSSFZFRkRUMEZCYmxGVVpYQmhSRFF3WjNsSE9WcHNMVzlFYUU5c2RqTlZRbXhVZEhoSlpYSTFaVzhpTENKamNuWWlPaUpRTFRJMU5pSjkjMCJ9.eyJhdWQiOiJodHRwczovL2FnZW50LnBhcmFkeW0uaWQvb2lkNHZjaS9kcmFmdC0xMy1pc3N1ZXIiLCJpYXQiOjE3Mjg1MTg0MDAsIm5vbmNlIjoiNDYzMjUzOTE3MDk0ODY5MTcyMDc4MzEwIn0.' ) expect(decodeJwt({ jwt: proofJwt })).toStrictEqual({ header: { @@ -258,7 +258,7 @@ describe('Oid4vciClient', () => { nonce: accessTokenResponse.c_nonce, }) expect(proofJwt).toMatch( - 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDpqd2s6ZXlKcmRIa2lPaUpGUXlJc0luZ2lPaUpCUlZod1NIa3hNRWRvZEZkb2JGWlFUbTF5Um5OaWVYUmZkMFJ6VVY4M2NUTmthazV1Y21oNmFsODBJaXdpZVNJNklrUkhWRUZEVDBGQmJsRlVaWEJoUkRRd1ozbEhPVnBzTFc5RWFFOXNkak5WUW14VWRIaEpaWEkxWlc4aUxDSmpjbllpT2lKUUxUSTFOaUo5IzAiLCJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCJ9.eyJub25jZSI6IjQ2MzI1MzkxNzA5NDg2OTE3MjA3ODMxMCIsImF1ZCI6Imh0dHBzOi8vYWdlbnQucGFyYWR5bS5pZC9vaWQ0dmNpL2RyYWZ0LTExLWlzc3VlciIsImlhdCI6MTcyODUxODQwMH0.' + 'eyJhbGciOiJFUzI1NiIsInR5cCI6Im9wZW5pZDR2Y2ktcHJvb2Yrand0Iiwia2lkIjoiZGlkOmp3azpleUpyZEhraU9pSkZReUlzSW5naU9pSkJSVmh3U0hreE1FZG9kRmRvYkZaUVRtMXlSbk5pZVhSZmQwUnpVVjgzY1ROa2FrNXVjbWg2YWw4MElpd2llU0k2SWtSSFZFRkRUMEZCYmxGVVpYQmhSRFF3WjNsSE9WcHNMVzlFYUU5c2RqTlZRbXhVZEhoSlpYSTFaVzhpTENKamNuWWlPaUpRTFRJMU5pSjkjMCJ9.eyJhdWQiOiJodHRwczovL2FnZW50LnBhcmFkeW0uaWQvb2lkNHZjaS9kcmFmdC0xMS1pc3N1ZXIiLCJpYXQiOjE3Mjg1MTg0MDAsIm5vbmNlIjoiNDYzMjUzOTE3MDk0ODY5MTcyMDc4MzEwIn0.' ) expect(decodeJwt({ jwt: proofJwt })).toStrictEqual({ header: { @@ -462,7 +462,7 @@ describe('Oid4vciClient', () => { }) expect(proofJwt).toMatch( - 'eyJhbGciOiJFUzI1NiIsImp3ayI6eyJrdHkiOiJFQyIsIngiOiJBRVhwSHkxMEdodFdobFZQTm1yRnNieXRfd0RzUV83cTNkak5ucmh6al80IiwieSI6IkRHVEFDT0FBblFUZXBhRDQwZ3lHOVpsLW9EaE9sdjNVQmxUdHhJZXI1ZW8iLCJjcnYiOiJQLTI1NiJ9LCJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCJ9.eyJub25jZSI6InNqTk1pcXlmbUJlRDFxaW9DVnlxdlMiLCJhdWQiOiJodHRwczovL2RlbW8ucGlkLWlzc3Vlci5idW5kZXNkcnVja2VyZWkuZGUvYyIsImlhdCI6MTcyODUxODQwMCwiaXNzIjoiNzZjN2M4OWItODc5OS00YmQxLWE2OTMtZDQ5OTQ4YTkxYjAwIn0.' + 'eyJhbGciOiJFUzI1NiIsInR5cCI6Im9wZW5pZDR2Y2ktcHJvb2Yrand0IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiQUVYcEh5MTBHaHRXaGxWUE5tckZzYnl0X3dEc1FfN3EzZGpObnJoempfNCIsInkiOiJER1RBQ09BQW5RVGVwYUQ0MGd5RzlabC1vRGhPbHYzVUJsVHR4SWVyNWVvIn19.eyJpc3MiOiI3NmM3Yzg5Yi04Nzk5LTRiZDEtYTY5My1kNDk5NDhhOTFiMDAiLCJhdWQiOiJodHRwczovL2RlbW8ucGlkLWlzc3Vlci5idW5kZXNkcnVja2VyZWkuZGUvYyIsImlhdCI6MTcyODUxODQwMCwibm9uY2UiOiJzak5NaXF5Zm1CZUQxcWlvQ1Z5cXZTIn0.' ) expect(decodeJwt({ jwt: proofJwt })).toStrictEqual({ header: { diff --git a/packages/oid4vci/src/__tests__/Oid4vciIssuer.test.ts b/packages/oid4vci/src/__tests__/Oid4vciIssuer.test.ts index cd364fc..41fe210 100644 --- a/packages/oid4vci/src/__tests__/Oid4vciIssuer.test.ts +++ b/packages/oid4vci/src/__tests__/Oid4vciIssuer.test.ts @@ -1,9 +1,11 @@ import { preAuthorizedCodeGrantIdentifier } from '@animo-id/oauth2' +import { addSecondsToDate } from '@animo-id/oauth2-utils' import { describe, expect, test } from 'vitest' import { callbacks, getSignJwtCallback } from '../../../oauth2/tests/util' import { Oid4vciIssuer } from '../Oid4vciIssuer' import { parseCredentialRequest } from '../credential-request/parse-credential-request' import { createCredentialRequestJwtProof } from '../formats/proof-type/jwt/jwt-proof-type' +import { createKeyAttestationJwt } from '../key-attestation/key-attestation' import type { IssuerMetadataResult } from '../metadata/fetch-issuer-metadata' import { Oid4vciDraftVersion } from '../version' @@ -16,75 +18,84 @@ const credentialRequestProofJwk = { } const { d, ...credentialRequestProofJwkPublic } = credentialRequestProofJwk -describe('Oid4vciIssuer', () => { - test('create issuer metadata, create a credential offer, parse a credential request, create a credential response', async () => { - const issuer = new Oid4vciIssuer({ - callbacks: { - ...callbacks, - signJwt: getSignJwtCallback([]), - }, - }) +const keyAttestationJwk = { + kty: 'EC', + d: 'e3q7tfa5NRbTUAslncGRUEXe2uf-xKlBsRvLVBHwY0U', + crv: 'P-256', + x: 'fcwkU-4CswPMud4mTz0fZP9a-cfE00OG7dEkQlfPuk0', + y: 'Cs2h5PnM16hWXObTZGg8BQLbprCIQDebP1GH9oAvAq0', +} +const { d: _, ...keyAttestationJwkPublic } = keyAttestationJwk - const credentialIssuerMetadata = issuer.createCredentialIssuerMetadata({ - credential_issuer: 'https://credential-issuer.com', - credential_configurations_supported: { - pidSdJwt: { - format: 'vc+sd-jwt', - vct: 'https://sd-jwt.com', - proof_types_supported: { - jwt: { - proof_signing_alg_values_supported: ['ES256'], - }, - }, - credential_signing_alg_values_supported: ['ES256'], - cryptographic_binding_methods_supported: ['jwk'], - scope: 'PidSdJwt', - display: [ - { - name: 'PID SD JWT', - background_color: '#FFFFFF', - background_image: { - uri: 'https://background-image.com', - }, - description: 'PID SD JWT Credential', - locale: 'en-US', - logo: { - uri: 'https://logo.com/logo.png', - alt_text: 'logo of logo', - }, - text_color: '#GGGGGG', - }, - ], +const issuer = new Oid4vciIssuer({ + callbacks: { + ...callbacks, + signJwt: getSignJwtCallback([]), + }, +}) + +const credentialIssuerMetadata = issuer.createCredentialIssuerMetadata({ + credential_issuer: 'https://credential-issuer.com', + credential_configurations_supported: { + pidSdJwt: { + format: 'vc+sd-jwt', + vct: 'https://sd-jwt.com', + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: ['ES256'], }, }, - credential_endpoint: 'https://credential-issuer.com/credential', - authorization_servers: ['https://one.com'], - batch_credential_issuance: { - batch_size: 10, - }, + credential_signing_alg_values_supported: ['ES256'], + cryptographic_binding_methods_supported: ['jwk'], + scope: 'PidSdJwt', display: [ { - name: 'Oid4vciIssuer', - locale: 'nl-NL', + name: 'PID SD JWT', + background_color: '#FFFFFF', + background_image: { + uri: 'https://background-image.com', + }, + description: 'PID SD JWT Credential', + locale: 'en-US', logo: { - alt_text: 'some-log', - uri: 'https://some-logo.com', + uri: 'https://logo.com/logo.png', + alt_text: 'logo of logo', }, + text_color: '#GGGGGG', }, ], - }) + }, + }, + credential_endpoint: 'https://credential-issuer.com/credential', + authorization_servers: ['https://one.com'], + batch_credential_issuance: { + batch_size: 10, + }, + display: [ + { + name: 'Oid4vciIssuer', + locale: 'nl-NL', + logo: { + alt_text: 'some-log', + uri: 'https://some-logo.com', + }, + }, + ], +}) - const issuerMetadata = { - credentialIssuer: credentialIssuerMetadata, - authorizationServers: [ - { - issuer: 'https://one.com', - token_endpoint: 'https://one.com/token', - }, - ], - originalDraftVersion: Oid4vciDraftVersion.Draft11, - } as const satisfies IssuerMetadataResult +const issuerMetadata = { + credentialIssuer: credentialIssuerMetadata, + authorizationServers: [ + { + issuer: 'https://one.com', + token_endpoint: 'https://one.com/token', + }, + ], + originalDraftVersion: Oid4vciDraftVersion.Draft11, +} as const satisfies IssuerMetadataResult +describe('Oid4vciIssuer', () => { + test('create issuer metadata, create a credential offer, parse a credential request with jwt proof, create a credential response', async () => { const credentialOffer = await issuer.createCredentialOffer({ credentialConfigurationIds: ['pidSdJwt'], grants: { @@ -115,11 +126,30 @@ describe('Oid4vciIssuer', () => { }, }) + const keyAttestationJwt = await createKeyAttestationJwt({ + callbacks: { + ...callbacks, + signJwt: getSignJwtCallback([keyAttestationJwk]), + }, + attestedKeys: [credentialRequestProofJwkPublic], + expiresAt: addSecondsToDate(new Date(), 500), + use: 'proof_type.jwt', + keyStorage: ['iso_18045_high'], + userAuthentication: ['iso_18045_high'], + signer: { + method: 'jwk', + alg: 'ES256', + publicJwk: keyAttestationJwkPublic, + }, + nonce: 'some-nonce', + }) + const credentialRequestJwt = await createCredentialRequestJwtProof({ callbacks: { ...callbacks, signJwt: getSignJwtCallback([credentialRequestProofJwk]), }, + keyAttestationJwt, credentialIssuer: credentialIssuerMetadata.credential_issuer, signer: { method: 'jwk', @@ -173,12 +203,33 @@ describe('Oid4vciIssuer', () => { alg: 'ES256', jwk: credentialRequestProofJwkPublic, typ: 'openid4vci-proof+jwt', + key_attestation: keyAttestationJwt, }, payload: { aud: 'https://credential-issuer.com', iat: expect.any(Number), nonce: 'some-nonce', }, + keyAttestation: { + header: { + alg: 'ES256', + jwk: keyAttestationJwkPublic, + typ: 'keyattestation+jwt', + }, + payload: { + attested_keys: [credentialRequestProofJwkPublic], + exp: expect.any(Number), + iat: expect.any(Number), + key_storage: ['iso_18045_high'], + nonce: 'some-nonce', + user_authentication: ['iso_18045_high'], + }, + signer: { + alg: 'ES256', + method: 'jwk', + publicJwk: keyAttestationJwkPublic, + }, + }, }) const credentialResponse = issuer.createCredentialResponse({ diff --git a/packages/oid4vci/src/credential-request/__tests__/parse-credential-request.test.ts b/packages/oid4vci/src/credential-request/__tests__/parse-credential-request.test.ts index 5ad1995..0566fe6 100644 --- a/packages/oid4vci/src/credential-request/__tests__/parse-credential-request.test.ts +++ b/packages/oid4vci/src/credential-request/__tests__/parse-credential-request.test.ts @@ -11,13 +11,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'vc+sd-jwt', @@ -29,7 +29,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -44,13 +44,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'mso_mdoc', @@ -62,7 +62,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -85,7 +85,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -103,7 +103,7 @@ describe('Parse Credential Request', () => { }, }, proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialRequest: { format: 'ldp_vc', @@ -119,7 +119,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -142,13 +142,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'jwt_vc_json-ld', @@ -176,7 +176,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -198,13 +198,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'jwt_vc_json', @@ -230,7 +230,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -244,13 +244,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialIdentifier: 'some-identifier', credentialRequest: { @@ -258,7 +258,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -297,20 +297,20 @@ describe('Parse Credential Request', () => { credential_identifier: 'some-identifier', extra_prop: 'should-stay', proofs: { - jwt: ['one', 'two'], + jwt: ['ey.ey.S', 'ey.ey.S'], }, }, }) ).toStrictEqual({ proofs: { - jwt: ['one', 'two'], + jwt: ['ey.ey.S', 'ey.ey.S'], }, credentialIdentifier: 'some-identifier', credentialRequest: { credential_identifier: 'some-identifier', extra_prop: 'should-stay', proofs: { - jwt: ['one', 'two'], + jwt: ['ey.ey.S', 'ey.ey.S'], }, }, }) @@ -348,13 +348,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialIdentifier: 'some-identifier', credentialRequest: { @@ -362,7 +362,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -375,19 +375,19 @@ describe('Parse Credential Request', () => { format: 'a-new-format', some_random_prop: 'should-be-allowed', proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialRequest: { format: 'a-new-format', some_random_prop: 'should-be-allowed', proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, }, }) @@ -407,13 +407,13 @@ describe('Parse Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'jwt_vc_json', @@ -439,7 +439,7 @@ describe('Parse Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -462,7 +462,7 @@ describe('Parse Credential Request', () => { some_other_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -480,7 +480,7 @@ describe('Parse Credential Request', () => { }, }, proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialRequest: { format: 'jwt_vc_json-ld', @@ -496,7 +496,7 @@ describe('Parse Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -519,13 +519,13 @@ describe('Parse Credential Request', () => { some_other_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'ldp_vc', @@ -553,7 +553,7 @@ describe('Parse Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) diff --git a/packages/oid4vci/src/credential-request/__tests__/v-credential-request.test.ts b/packages/oid4vci/src/credential-request/__tests__/v-credential-request.test.ts index 4e976c7..d4ca407 100644 --- a/packages/oid4vci/src/credential-request/__tests__/v-credential-request.test.ts +++ b/packages/oid4vci/src/credential-request/__tests__/v-credential-request.test.ts @@ -11,10 +11,10 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, proofs: { - jwt: ['one'], + jwt: ['ey.ey.S'], }, }) @@ -38,7 +38,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -63,7 +63,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -75,7 +75,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -90,7 +90,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -102,7 +102,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -125,7 +125,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -145,7 +145,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -168,7 +168,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -188,7 +188,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -210,7 +210,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -229,7 +229,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -242,7 +242,7 @@ describe('Credential Request', () => { credential_identifier: 'some-identifier', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -252,7 +252,7 @@ describe('Credential Request', () => { credential_identifier: 'some-identifier', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -266,7 +266,7 @@ describe('Credential Request', () => { some_random_prop: 'should-be-allowed', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -277,7 +277,7 @@ describe('Credential Request', () => { some_random_prop: 'should-be-allowed', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -297,7 +297,7 @@ describe('Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -316,7 +316,7 @@ describe('Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -339,7 +339,7 @@ describe('Credential Request', () => { some_other_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -359,7 +359,7 @@ describe('Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -382,7 +382,7 @@ describe('Credential Request', () => { some_other_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -402,7 +402,7 @@ describe('Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, diff --git a/packages/oid4vci/src/credential-request/parse-credential-request.ts b/packages/oid4vci/src/credential-request/parse-credential-request.ts index 8074925..24d72e0 100644 --- a/packages/oid4vci/src/credential-request/parse-credential-request.ts +++ b/packages/oid4vci/src/credential-request/parse-credential-request.ts @@ -1,6 +1,7 @@ import { parseWithErrorHandling } from '@animo-id/oauth2-utils' import * as v from 'valibot' import type { CredentialFormatIdentifier } from '../formats/credential' +import { attestationProofTypeIdentifier } from '../formats/proof-type/attestation/v-attestation-proof-type' import { jwtProofTypeIdentifier } from '../formats/proof-type/jwt/v-jwt-proof-type' import { type CredentialRequest, @@ -28,7 +29,7 @@ export interface ParseCredentialRequestReturn { format?: CredentialRequestFormatSpecific /** - * If the reuest contains `proof` or `proofs` with a `proof_type` that is known to this + * If the request contains `proof` or `proofs` with a `proof_type` that is known to this * library it will have the proof type specific data defined here. Will not be defined * if the `proof_type` is not known or no `proof` or `proofs` were included. * @@ -72,6 +73,8 @@ export function parseCredentialRequest(options: ParseCredentialRequestOptions): const knownProof = v.safeParse(v.union(allCredentialRequestProofs), credentialRequest.proof) if (knownProof.success && knownProof.output.proof_type === jwtProofTypeIdentifier) { proofs = { [jwtProofTypeIdentifier]: [knownProof.output.jwt] } + } else if (knownProof.success && knownProof.output.proof_type === attestationProofTypeIdentifier) { + proofs = { [attestationProofTypeIdentifier]: [knownProof.output.attestation] } } if (credentialRequest.credential_identifier) { diff --git a/packages/oid4vci/src/credential-request/v-credential-request-common.ts b/packages/oid4vci/src/credential-request/v-credential-request-common.ts index c5fd97e..a7f40a3 100644 --- a/packages/oid4vci/src/credential-request/v-credential-request-common.ts +++ b/packages/oid4vci/src/credential-request/v-credential-request-common.ts @@ -1,14 +1,19 @@ import { vJwk } from '@animo-id/oauth2' import type { InferOutputUnion, Simplify } from '@animo-id/oauth2-utils' import * as v from 'valibot' -import type { ProofTypeIdentifier } from '../formats/proof-type' -import { vCredentialRequestProofJwt, vJwtProofTypeIdentifier } from '../formats/proof-type/jwt/v-jwt-proof-type' +import { + type ProofTypeIdentifier, + vAttestationProofTypeIdentifier, + vCredentialRequestProofAttestation, + vCredentialRequestProofJwt, + vJwtProofTypeIdentifier, +} from '../formats/proof-type' const vCredentialRequestProofCommon = v.looseObject({ proof_type: v.string(), }) -export const allCredentialRequestProofs = [vCredentialRequestProofJwt] as const +export const allCredentialRequestProofs = [vCredentialRequestProofJwt, vCredentialRequestProofAttestation] as const const allCredentialRequestProofsTypes = allCredentialRequestProofs.map((format) => format.entries.proof_type.literal) export const vCredentialRequestProof = v.intersect([ @@ -31,6 +36,9 @@ export const vCredentialRequestProof = v.intersect([ const vCredentialRequestProofsCommon = v.record(v.string(), v.array(v.unknown())) export const vCredentialRequestProofs = v.object({ [vJwtProofTypeIdentifier.literal]: v.optional(v.array(vCredentialRequestProofJwt.entries.jwt)), + [vAttestationProofTypeIdentifier.literal]: v.optional( + v.array(vCredentialRequestProofAttestation.entries.attestation) + ), }) type CredentialRequestProofCommon = v.InferOutput @@ -48,7 +56,15 @@ export type CredentialRequestProofs = v.InferOutput Object.values(proofs).length === 1, + `The 'proofs' object in a credential request should contain exactly one attribute` + ) + ) + ), credential_response_encryption: v.optional( v.looseObject({ @@ -62,10 +78,5 @@ export const vCredentialRequestCommon = v.pipe( v.check( ({ proof, proofs }) => !(proof !== undefined && proofs !== undefined), `Both 'proof' and 'proofs' are defined. Only one is allowed` - ), - // Only one proof type allowed per requet - v.check( - ({ proofs }) => (proofs === undefined ? true : Object.values(proofs).length === 1), - `The 'proofs' object in a credential request should contain exactly one attribute` ) ) diff --git a/packages/oid4vci/src/formats/proof-type/attestation/attestation-proof-type.ts b/packages/oid4vci/src/formats/proof-type/attestation/attestation-proof-type.ts new file mode 100644 index 0000000..c79b73f --- /dev/null +++ b/packages/oid4vci/src/formats/proof-type/attestation/attestation-proof-type.ts @@ -0,0 +1,39 @@ +import { + type CreateKeyAttestationJwtOptions, + type VerifyKeyAttestationJwtOptions, + createKeyAttestationJwt, + verifyKeyAttestationJwt, +} from '../../../key-attestation/key-attestation' + +export interface CreateCredentialRequestAttestationProofOptions extends Omit { + /** + * Nonce to use in the attestation. Should be derived from the c_nonce + * + * Required because the attestation is created for 'attestation' proof types + */ + nonce: string + + /** + * The date when the key attestation will expire. + */ + expiresAt: Date +} + +export async function createCredentialRequestAttestationProof( + options: CreateCredentialRequestAttestationProofOptions +): Promise { + return createKeyAttestationJwt({ + ...options, + use: 'proof_type.attestation', + }) +} + +export interface VerifyCredentialRequestAttestationProofOptions extends Omit {} +export async function verifyCredentialRequestAttestationProof(options: VerifyCredentialRequestAttestationProofOptions) { + const verificationResult = await verifyKeyAttestationJwt({ + ...options, + use: 'proof_type.attestation', + }) + + return verificationResult +} diff --git a/packages/oid4vci/src/formats/proof-type/attestation/v-attestation-proof-type.ts b/packages/oid4vci/src/formats/proof-type/attestation/v-attestation-proof-type.ts new file mode 100644 index 0000000..0827bf8 --- /dev/null +++ b/packages/oid4vci/src/formats/proof-type/attestation/v-attestation-proof-type.ts @@ -0,0 +1,25 @@ +import * as v from 'valibot' + +import { vCompactJwt } from '@animo-id/oauth2' +import { + type KeyAttestationJwtHeader, + vKeyAttestationJwtHeader, + vKeyAttestationJwtPayloadForUse, +} from '../../../key-attestation/v-key-attestation' + +export const vAttestationProofTypeIdentifier = v.literal('attestation') +export const attestationProofTypeIdentifier = vAttestationProofTypeIdentifier.literal +export type AttestationProofTypeIdentifier = v.InferOutput + +export const vCredentialRequestProofAttestation = v.object({ + proof_type: vAttestationProofTypeIdentifier, + attestation: vCompactJwt, +}) + +export const vCredentialRequestAttestationProofTypeHeader = vKeyAttestationJwtHeader +export type CredentialRequestAttestationProofTypeHeader = KeyAttestationJwtHeader + +export const vCredentialRequestAttestationProofTypePayload = vKeyAttestationJwtPayloadForUse('proof_type.attestation') +export type CredentialRequestAttestationProofTypePayload = v.InferOutput< + typeof vCredentialRequestAttestationProofTypePayload +> diff --git a/packages/oid4vci/src/formats/proof-type/index.ts b/packages/oid4vci/src/formats/proof-type/index.ts index 5415a82..4cbb189 100644 --- a/packages/oid4vci/src/formats/proof-type/index.ts +++ b/packages/oid4vci/src/formats/proof-type/index.ts @@ -1,5 +1,18 @@ +import type { AttestationProofTypeIdentifier } from './attestation/v-attestation-proof-type' import type { JwtProofTypeIdentifier } from './jwt/v-jwt-proof-type' -export { JwtProofTypeIdentifier } from './jwt/v-jwt-proof-type' +// jwt +export { + type JwtProofTypeIdentifier, + vCredentialRequestProofJwt, + vJwtProofTypeIdentifier, +} from './jwt/v-jwt-proof-type' -export type ProofTypeIdentifier = JwtProofTypeIdentifier +// attestation +export { + type AttestationProofTypeIdentifier, + vCredentialRequestProofAttestation, + vAttestationProofTypeIdentifier, +} from './attestation/v-attestation-proof-type' + +export type ProofTypeIdentifier = JwtProofTypeIdentifier | AttestationProofTypeIdentifier diff --git a/packages/oid4vci/src/formats/proof-type/jwt/jwt-proof-type.ts b/packages/oid4vci/src/formats/proof-type/jwt/jwt-proof-type.ts index 9412777..b6c71f0 100644 --- a/packages/oid4vci/src/formats/proof-type/jwt/jwt-proof-type.ts +++ b/packages/oid4vci/src/formats/proof-type/jwt/jwt-proof-type.ts @@ -1,4 +1,4 @@ -import { type JwtSigner, decodeJwt, jwtHeaderFromJwtSigner } from '@animo-id/oauth2' +import { type JwtSigner, decodeJwt, isJwkInSet, jwtHeaderFromJwtSigner } from '@animo-id/oauth2' import { type CredentialRequestJwtProofTypeHeader, type CredentialRequestJwtProofTypePayload, @@ -7,7 +7,10 @@ import { } from './v-jwt-proof-type' import { type CallbackContext, jwtSignerFromJwt, verifyJwt } from '@animo-id/oauth2' +import { dateToSeconds, parseWithErrorHandling } from '@animo-id/oauth2-utils' import { Oid4vciError } from '../../../error/Oid4vciError' +import { type VerifyKeyAttestationJwtReturn, verifyKeyAttestationJwt } from '../../../key-attestation/key-attestation' +import { vKeyAttestationJwtHeader, vKeyAttestationJwtPayload } from '../../../key-attestation/v-key-attestation' export interface CreateCredentialRequestJwtProofOptions { /** @@ -31,27 +34,56 @@ export interface CreateCredentialRequestJwtProofOptions { */ clientId?: string - signer: JwtSigner + /** + * Key attestation jwt that the proof should based on. In this case it is required that the `signer` uses + * a key from the `attested_keys` in the key attestation jwt payload. + */ + keyAttestationJwt?: string - callbacks: Pick + signer: JwtSigner + callbacks: Pick } export async function createCredentialRequestJwtProof( options: CreateCredentialRequestJwtProofOptions ): Promise { - const header: CredentialRequestJwtProofTypeHeader = { + const header = parseWithErrorHandling(vCredentialRequestJwtProofTypeHeader, { ...jwtHeaderFromJwtSigner(options.signer), + key_attestation: options.keyAttestationJwt, typ: 'openid4vci-proof+jwt', - } + } satisfies CredentialRequestJwtProofTypeHeader) - const payload = { + const payload = parseWithErrorHandling(vCredentialRequestJwtProofTypePayload, { nonce: options.nonce, aud: options.credentialIssuer, - iat: Math.floor((options.issuedAt ?? new Date()).getTime() / 1000), + iat: dateToSeconds(options.issuedAt), iss: options.clientId, - } satisfies CredentialRequestJwtProofTypePayload + } satisfies CredentialRequestJwtProofTypePayload) + + const { jwt, signerJwk } = await options.callbacks.signJwt(options.signer, { header, payload }) + + // Check the jwt is signed with an key from attested_keys in the key_attestation jwt + if (options.keyAttestationJwt) { + const decodedKeyAttestation = decodeJwt({ + jwt: options.keyAttestationJwt, + headerSchema: vKeyAttestationJwtHeader, + payloadSchema: vKeyAttestationJwtPayload, + }) + + const isSigedWithAttestedKey = await isJwkInSet({ + jwk: signerJwk, + jwks: decodedKeyAttestation.payload.attested_keys, + callbacks: options.callbacks, + }) + + if (!isSigedWithAttestedKey) { + throw new Oid4vciError( + `Credential request jwt proof is not signed with a key in the 'key_attestation' jwt payload 'attested_keys'` + ) + } + } - return await options.callbacks.signJwt(options.signer, { header, payload }) + return jwt } export interface VerifyCredentialRequestJwtProofOptions { @@ -86,9 +118,11 @@ export interface VerifyCredentialRequestJwtProofOptions { now?: Date /** - * Callbacks required for the jwt verification + * Callbacks required for the jwt verification. + * + * Will be used for the jwt proof, and optionally a `key_attestation` in the jwt proof header. */ - callbacks: Pick + callbacks: Pick } export async function verifyCredentialRequestJwtProof(options: VerifyCredentialRequestJwtProofOptions) { @@ -103,12 +137,11 @@ export async function verifyCredentialRequestJwtProof(options: VerifyCredentialR throw new Oid4vciError('Nonce used for credential request proof expired') } - const signer = jwtSignerFromJwt({ header, payload }) - await verifyJwt({ + const { signer } = await verifyJwt({ compact: options.jwt, header, payload, - signer, + signer: jwtSignerFromJwt({ header, payload }), verifyJwtCallback: options.callbacks.verifyJwt, errorMessage: 'Error verifiying credential request proof jwt', expectedNonce: options.expectedNonce, @@ -117,9 +150,32 @@ export async function verifyCredentialRequestJwtProof(options: VerifyCredentialR now: options.now, }) + let keyAttestationResult: VerifyKeyAttestationJwtReturn | undefined = undefined + // Check the jwt is signed with an key from attested_keys in the key_attestation jwt + if (header.key_attestation) { + keyAttestationResult = await verifyKeyAttestationJwt({ + callbacks: options.callbacks, + keyAttestationJwt: header.key_attestation, + use: 'proof_type.jwt', + }) + + const isSigedWithAttestedKey = await isJwkInSet({ + jwk: signer.publicJwk, + jwks: keyAttestationResult.payload.attested_keys, + callbacks: options.callbacks, + }) + + if (!isSigedWithAttestedKey) { + throw new Oid4vciError( + `Credential request jwt proof is not signed with a key in the 'key_attestation' jwt payload 'attested_keys'` + ) + } + } + return { header, payload, signer, + keyAttestation: keyAttestationResult, } } diff --git a/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts b/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts index 723fa00..82d931e 100644 --- a/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts +++ b/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts @@ -15,6 +15,7 @@ export const vCredentialRequestProofJwt = v.object({ export const vCredentialRequestJwtProofTypeHeader = v.pipe( v.looseObject({ ...vJwtHeader.entries, + key_attestation: v.optional(vCompactJwt), typ: v.literal('openid4vci-proof+jwt'), }), v.check( diff --git a/packages/oid4vci/src/key-attestation/key-attestation.ts b/packages/oid4vci/src/key-attestation/key-attestation.ts new file mode 100644 index 0000000..f2ce851 --- /dev/null +++ b/packages/oid4vci/src/key-attestation/key-attestation.ts @@ -0,0 +1,169 @@ +import { type Jwk, type JwtSigner, decodeJwt, jwtHeaderFromJwtSigner } from '@animo-id/oauth2' + +import { type CallbackContext, jwtSignerFromJwt, verifyJwt } from '@animo-id/oauth2' +import { type StringWithAutoCompletion, dateToSeconds, parseWithErrorHandling } from '@animo-id/oauth2-utils' +import { Oid4vciError } from '../error/Oid4vciError' +import { + type Iso18045, + type KeyAttestationJwtHeader, + type KeyAttestationJwtPayload, + type KeyAttestationJwtUse, + vKeyAttestationJwtHeader, + vKeyAttestationJwtPayloadForUse, +} from './v-key-attestation' + +export interface CreateKeyAttestationJwtOptions { + /** + * Nonce to use in the key attestation. + * + * MUST be present if the attestation is used with the attestation proof + */ + nonce?: string + + /** + * The date when the key attestation was issued. If not provided the current time will be used. + */ + issuedAt?: Date + + /** + * The date when the key attestation will expire. + * + * MUST be present if the attestation is used with the JWT proof + */ + expiresAt?: Date + + /** + * The keys that the attestation jwt attests. + */ + attestedKeys: Jwk[] + + /** + * Optional attack potential resistance of attested keys and key storage + */ + keyStorage?: StringWithAutoCompletion[] + + /** + * Optional attack potential resistance of user authentication methods + */ + userAuthentication?: StringWithAutoCompletion[] + + /** + * Optional url linking to the certification of the key storage component. + */ + certification?: string + + /** + * The intended use of the key attestation. Based on this additional validation + * is performed. + * + * - `proof_type.jwt` -> `exp` MUST be set + * - `proof_type.attestation` -> `nonce` MUST be set + */ + use?: KeyAttestationJwtUse + + /** + * Signer of the key attestation jwt + */ + signer: JwtSigner + + /** + * Callbacks used for creating the key attestation jwt + */ + callbacks: Pick + + /** + * Additional payload to include in the key attestation jwt payload. Will be applied after + * any default claims that are included, so add claims with caution. + */ + additionalPayload?: Record +} + +export async function createKeyAttestationJwt(options: CreateKeyAttestationJwtOptions): Promise { + const header = parseWithErrorHandling(vKeyAttestationJwtHeader, { + ...jwtHeaderFromJwtSigner(options.signer), + typ: 'keyattestation+jwt', + } satisfies KeyAttestationJwtHeader) + + const payload = parseWithErrorHandling(vKeyAttestationJwtPayloadForUse(options.use), { + iat: dateToSeconds(options.issuedAt), + exp: options.expiresAt ? dateToSeconds(options.expiresAt) : undefined, + nonce: options.nonce, + attested_keys: options.attestedKeys, + user_authentication: options.userAuthentication, + key_storage: options.keyStorage, + certification: options.certification, + ...options.additionalPayload, + } satisfies KeyAttestationJwtPayload) + + const { jwt } = await options.callbacks.signJwt(options.signer, { header, payload }) + return jwt +} + +export interface VerifyKeyAttestationJwtOptions { + /** + * The compact key attestation jwt + */ + keyAttestationJwt: string + + /** + * Expected nonce. If the key attestation is used directly as proof this should be provided. + */ + expectedNonce?: string + + /** + * Date at which the nonce will expire + */ + nonceExpiresAt?: Date + + /** + * The intended use of the key attestation. Based on this additional validation + * is performed. + * + * - `proof_type.jwt` -> `exp` MUST be set + * - `proof_type.attestation` -> `nonce` MUST be set + */ + use?: KeyAttestationJwtUse + + /** + * Current time, if not provided a new date instance will be created + */ + now?: Date + + /** + * Callbacks required for the key attestation jwt verification + */ + callbacks: Pick +} + +export type VerifyKeyAttestationJwtReturn = Awaited> +export async function verifyKeyAttestationJwt(options: VerifyKeyAttestationJwtOptions) { + const { header, payload } = decodeJwt({ + jwt: options.keyAttestationJwt, + headerSchema: vKeyAttestationJwtHeader, + payloadSchema: vKeyAttestationJwtPayloadForUse(options.use), + }) + + // TODO: if you use stateless nonce, it doesn't make sense to verify the nonce here + // We should just return the nonce after verification so it can be checked (or actually, it should be checked upfront) + const now = options.now?.getTime() ?? Date.now() + if (options.nonceExpiresAt && now > options.nonceExpiresAt.getTime()) { + throw new Oid4vciError('Nonce used for key attestation jwt expired') + } + + const { signer } = await verifyJwt({ + compact: options.keyAttestationJwt, + header, + payload, + signer: jwtSignerFromJwt({ header, payload }), + verifyJwtCallback: options.callbacks.verifyJwt, + errorMessage: 'Error verifiying key attestation jwt', + expectedNonce: options.expectedNonce, + now: options.now, + }) + + return { + header, + payload, + signer, + } +} diff --git a/packages/oid4vci/src/key-attestation/v-key-attestation.ts b/packages/oid4vci/src/key-attestation/v-key-attestation.ts new file mode 100644 index 0000000..13f8d2e --- /dev/null +++ b/packages/oid4vci/src/key-attestation/v-key-attestation.ts @@ -0,0 +1,55 @@ +import * as v from 'valibot' + +import { vJwk, vJwtHeader, vJwtPayload } from '@animo-id/oauth2' +import { vInteger } from '@animo-id/oauth2-utils' + +export type KeyAttestationJwtUse = 'proof_type.jwt' | 'proof_type.attestation' + +export const vKeyAttestationJwtHeader = v.pipe( + v.looseObject({ + ...vJwtHeader.entries, + typ: v.literal('keyattestation+jwt'), + }), + v.check( + ({ kid, jwk }) => jwk === undefined || kid === undefined, + `Both 'jwk' and 'kid' are defined. Only one is allowed` + ), + v.check(({ trust_chain, kid }) => !trust_chain || !kid, `When 'trust_chain' is provided, 'kid' is required`) +) +export type KeyAttestationJwtHeader = v.InferOutput + +export const vIso18045 = v.picklist([ + 'iso_18045_high', + 'iso_18045_moderate', + 'iso_18045_enhanced-basic', + 'iso_18045_basic', +]) + +export type Iso18045 = v.InferOutput +export const vIso18045OrStringArray = v.array(v.union([vIso18045, v.string()])) + +export const vKeyAttestationJwtPayload = v.looseObject({ + ...vJwtPayload.entries, + iat: vInteger, + + attested_keys: v.array(vJwk), + key_storage: v.optional(vIso18045OrStringArray), + user_authentication: v.optional(vIso18045OrStringArray), + certification: v.optional(v.string()), +}) + +export const vKeyAttestationJwtPayloadForUse = (use?: Use) => + v.looseObject({ + ...vKeyAttestationJwtPayload.entries, + + // REQUIRED when used as proof_type.attesation directly + nonce: + use === 'proof_type.attestation' + ? v.string(`Nonce must be defined when key attestation is used as 'proof_type.attestation' directly`) + : v.optional(v.string()), + + // REQUIRED when used within header of proof_type.jwt + exp: use === 'proof_type.jwt' ? vInteger : v.optional(vInteger), + }) + +export type KeyAttestationJwtPayload = v.InferOutput diff --git a/packages/oid4vci/src/metadata/credential-issuer/v-credential-configuration-supported-common.ts b/packages/oid4vci/src/metadata/credential-issuer/v-credential-configuration-supported-common.ts index 4ff9525..83bcac6 100644 --- a/packages/oid4vci/src/metadata/credential-issuer/v-credential-configuration-supported-common.ts +++ b/packages/oid4vci/src/metadata/credential-issuer/v-credential-configuration-supported-common.ts @@ -1,4 +1,5 @@ import * as v from 'valibot' +import { vIso18045OrStringArray } from '../../key-attestation/v-key-attestation' export const vCredentialConfigurationSupportedClaims = v.looseObject({ mandatory: v.optional(v.boolean()), @@ -18,9 +19,15 @@ export const vCredentialConfigurationSupportedCommon = v.looseObject({ credential_signing_alg_values_supported: v.optional(v.array(v.string())), proof_types_supported: v.optional( v.record( - v.union([v.literal('jwt'), v.string()]), + v.union([v.literal('jwt'), v.literal('attestation'), v.string()]), v.object({ proof_signing_alg_values_supported: v.array(v.string()), + key_attestations_required: v.optional( + v.looseObject({ + key_storage: v.optional(vIso18045OrStringArray), + user_authentication: v.optional(vIso18045OrStringArray), + }) + ), }) ) ), diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8e006f8..f808284 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -22,7 +22,7 @@ export { valibotRecursiveFlattenIssues, } from './parse' export { joinUriParts } from './path' -export type { Optional, Simplify, StringWithAutoCompletion } from './type' +export type { Optional, Simplify, StringWithAutoCompletion, OrPromise } from './type' export { getQueryParams, objectToQueryParams } from './url' export { type ValibotFetcher, createValibotFetcher, defaultFetcher } from './valibot-fetcher' export { type HttpMethod, vHttpMethod, vHttpsUrl, vInteger } from './validation' diff --git a/packages/utils/src/type.ts b/packages/utils/src/type.ts index 064ee08..042a487 100644 --- a/packages/utils/src/type.ts +++ b/packages/utils/src/type.ts @@ -1,3 +1,4 @@ export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {} export type Optional = Omit & Partial> -export type StringWithAutoCompletion = string & {} +export type StringWithAutoCompletion = T | (string & {}) +export type OrPromise = T | Promise