diff --git a/.gitignore b/.gitignore index 31c8671..5334e31 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .vscode/* .idea/* +.tsimp/* *.iml .nyc_output build diff --git a/generator/schemaGenerator.ts b/generator/schemaGenerator.ts index 27ba28a..2e65810 100644 --- a/generator/schemaGenerator.ts +++ b/generator/schemaGenerator.ts @@ -165,12 +165,12 @@ const authorizationRequestPayloadVD11 = { skipTypeCheck: true }; -const authorizationRequestPayloadVD12OID4VPD18 = { +const authorizationRequestPayloadVD13OID4VPD20 = { path: '../src/types/SIOP.types.ts', tsconfig: 'tsconfig.json', - type: 'AuthorizationRequestPayloadVD12OID4VPD18', // Or if you want to generate schema for that one type only - schemaId: 'AuthorizationRequestPayloadVD12OID4VPD18Schema', - outputPath: 'src/schemas/AuthorizationRequestPayloadVD12OID4VPD18.schema.ts', + type: 'AuthorizationRequestPayloadVD13OID4VPD20', // Or if you want to generate schema for that one type only + schemaId: 'AuthorizationRequestPayloadVD13OID4VPD20Schema', + outputPath: 'src/schemas/AuthorizationRequestPayloadVD13OID4VPD20.schema.ts', // outputConstName: 'AuthorizationRequestPayloadSchemaVD11', skipTypeCheck: true }; @@ -178,7 +178,7 @@ const authorizationRequestPayloadVD12OID4VPD18 = { let schemas: Schema[] = [ writeSchema(authorizationRequestPayloadVID1), writeSchema(authorizationRequestPayloadVD11), - writeSchema(authorizationRequestPayloadVD12OID4VPD18), + writeSchema(authorizationRequestPayloadVD13OID4VPD20), // writeSchema(requestOptsConf), writeSchema(responseOptsConf), writeSchema(rPRegistrationMetadataPayload), diff --git a/package.json b/package.json index 66a391b..c3ac9fe 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "events": "^3.3.0", "language-tags": "^1.0.9", "multiformats": "^12.1.3", + "node-forge": "^1.3.1", "qs": "^6.11.2", "sha.js": "^2.4.11", "uint8arrays": "^3.1.1", @@ -64,6 +65,7 @@ "@transmute/ed25519-signature-2018": "^0.7.0-unstable.82", "@types/jest": "^29.5.11", "@types/language-tags": "^1.0.4", + "@types/node-forge": "^1.3.11", "@types/qs": "^6.9.11", "@types/sha.js": "^2.4.4", "@types/uuid": "^9.0.7", diff --git a/src/authorization-request/AuthorizationRequest.ts b/src/authorization-request/AuthorizationRequest.ts index 4e9b80c..d789f78 100644 --- a/src/authorization-request/AuthorizationRequest.ts +++ b/src/authorization-request/AuthorizationRequest.ts @@ -1,4 +1,7 @@ import { JWTVerifyOptions } from 'did-jwt'; +import { decodeJWT } from 'did-jwt'; +import { JWTDecoded } from 'did-jwt/lib/JWT'; +import forge from 'node-forge'; import { PresentationDefinitionWithLocation } from '../authorization-response'; import { PresentationExchange } from '../authorization-response/PresentationExchange'; @@ -186,11 +189,15 @@ export class AuthorizationRequest { throw new Error(`${SIOPErrors.INVALID_REQUEST}, redirect_uri or response_uri is needed`); } + if (mergedPayload.client_id_scheme === 'verifier_attestation') { + verifiedJwt = await AuthorizationRequest.verifyAttestationJWT(jwt, mergedPayload.client_id); + } else if (mergedPayload.client_id_scheme === 'x509_san_dns') { + await this.checkX509SanDNSScheme(jwt, mergedPayload.client_id); + } else if (mergedPayload.client_id_scheme === 'x509_san_uri') { + throw new Error(SIOPErrors.VERIFICATION_X509_SAN_URI_SCHEME_NOT_IMPLEMENTED_ERROR); + } await checkWellknownDIDFromRequest(mergedPayload, opts); - // TODO: we need to verify somewhere that if response_mode is direct_post, that the response_uri may be present, - // BUT not both redirect_uri and response_uri. What is the best place to do this? - const presentationDefinitions = await PresentationExchange.findValidPresentationDefinitions(mergedPayload, await this.getSupportedVersion()); return { ...verifiedJwt, @@ -248,6 +255,97 @@ export class AuthorizationRequest { }; } + /** + * Verifies a JWT according to the 'verifier_attestation' client_id_scheme, where the JWT must be + * signed with a private key corresponding to the public key specified within the JWT itself. This method + * ensures that the JWT's 'sub' claim matches the provided clientId, and it extracts and validates the + * public key from the JWT's 'cnf' (confirmation) claim, which must contain a JWK. + * + * An example of such request would be: + * GET /authorize? + * response_type=vp_token + * &client_id=https%3A%2F%2Fverifier.example.org + * &client_id_scheme=verifier_attestation + * &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb + * &presentation_definition=... + * &nonce=n-0S6_WzA2Mj + * &jwt=eyJ...abc + * + * @param jwt The JSON Web Token string to be verified. It is expected that this JWT is formatted correctly + * and includes a 'cnf' claim with a JWK representing the public key used for signing the JWT. + * @param clientId The client identifier expected to match the 'sub' claim in the JWT. This is used to + * validate that the JWT is intended for the correct recipient/client. + */ + private static async verifyAttestationJWT(jwt: string, clientId: string): Promise { + if (!jwt) { + throw new Error(SIOPErrors.NO_JWT); + } + const payload = decodeJWT(jwt); + AuthorizationRequest.checkPayloadClaims(payload, ['iss', 'sub', 'exp', 'cnf']); + const sub = payload['sub']; + const cnf = payload['cnf']; + + if (sub !== clientId || !cnf || typeof cnf !== 'object' || !cnf['jwk'] || typeof cnf['jwk'] !== 'object') { + throw new Error(SIOPErrors.VERIFICATION_VERIFIER_ATTESTATION_SCHEME_ERROR); + } + + return { + jwt, + payload: payload.payload, + issuer: payload['iss'], + jwk: cnf['jwk'], + }; + } + + /** + * verifying JWTs against X.509 certificates focusing on DNS SAN compliance, which is crucial for environments where certificate-based security is pivotal. + * + * An example of such request would be: + * GET /authorize? + * response_type=vp_token + * &client_id=client.example.org + * &client_id_scheme=x509_san_dns + * &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb + * &presentation_definition=... + * &nonce=n-0S6_WzA2Mj + * + * @param jwt The encoded JWT from which the certificate needs to be extracted. + * @param clientId The DNS name to match against the certificate's SANs. + */ + private async checkX509SanDNSScheme(jwt: string, clientId: string): Promise { + const jwtDecoded: JWTDecoded = decodeJWT(jwt); + const x5c = jwtDecoded.header['x5c']; + + if (x5c == null || !Array.isArray(x5c) || x5c.length === 0) { + throw new Error(SIOPErrors.VERIFICATION_X509_SAN_DNS_SCHEME_ERROR); + } + + const certificate = x5c[0]; + if (!certificate) { + throw new Error(SIOPErrors.VERIFICATION_X509_SAN_DNS_SCHEME_NO_CERTIFICATE_ERROR); + } + + const der = forge.util.decode64(certificate); + const asn1 = forge.asn1.fromDer(der); + const cert = forge.pki.certificateFromAsn1(asn1); + + const subjectAltNames = cert.getExtension('subjectAltName'); + if (!subjectAltNames || !Array.isArray(subjectAltNames['altNames'])) { + throw new Error(SIOPErrors.VERIFICATION_X509_SAN_DNS_ALT_NAMES_ERROR); + } + if (!subjectAltNames || !subjectAltNames['altNames'].some((name: any) => name.value === clientId)) { + throw new Error(SIOPErrors.VERIFICATION_X509_SAN_DNS_SCHEME_DNS_NAME_MATCH); + } + } + + private static checkPayloadClaims(payload: JWTDecoded, requiredClaims: string[]): void { + requiredClaims.forEach((claim) => { + if (payload[claim] === undefined) { + throw new Error(`Payload is missing ${claim}`); + } + }); + } + public async containsResponseType(singleType: ResponseType | string): Promise { const responseType: string = await this.getMergedProperty('response_type'); return responseType?.includes(singleType) === true; diff --git a/src/helpers/SIOPSpecVersion.ts b/src/helpers/SIOPSpecVersion.ts index 2ae0908..c8f3e2d 100644 --- a/src/helpers/SIOPSpecVersion.ts +++ b/src/helpers/SIOPSpecVersion.ts @@ -1,5 +1,5 @@ import { AuthorizationRequestPayloadVD11Schema, AuthorizationRequestPayloadVID1Schema } from '../schemas'; -import { AuthorizationRequestPayloadVD12OID4VPD18Schema } from '../schemas/validation/schemaValidation'; +import { AuthorizationRequestPayloadVD13OID4VPD20Schema } from '../schemas/validation/schemaValidation'; import { AuthorizationRequestPayload, ResponseMode, SupportedVersion } from '../types'; import errors from '../types/Errors'; @@ -34,15 +34,15 @@ export const authorizationRequestVersionDiscovery = (authorizationRequest: Autho const versions = []; const authorizationRequestCopy: AuthorizationRequestPayload = JSON.parse(JSON.stringify(authorizationRequest)); // todo: We could use v11 validation for v12 for now, as we do not differentiate in the schema at this point\ - const vd12Validation = AuthorizationRequestPayloadVD12OID4VPD18Schema(authorizationRequestCopy); - if (vd12Validation) { + const vd13Validation = AuthorizationRequestPayloadVD13OID4VPD20Schema(authorizationRequestCopy); + if (vd13Validation) { if ( !authorizationRequestCopy.registration_uri && !authorizationRequestCopy.registration && !(authorizationRequestCopy.claims && 'vp_token' in authorizationRequestCopy.claims) && authorizationRequestCopy.response_mode !== ResponseMode.POST // Post has been replaced by direct post ) { - versions.push(SupportedVersion.SIOPv2_D12_OID4VP_D18); + versions.push(SupportedVersion.SIOPv2_D13_OID4VP_D20); } } const vd11Validation = AuthorizationRequestPayloadVD11Schema(authorizationRequestCopy); diff --git a/src/id-token/Payload.ts b/src/id-token/Payload.ts index 1d2fcfe..628d2b3 100644 --- a/src/id-token/Payload.ts +++ b/src/id-token/Payload.ts @@ -35,7 +35,7 @@ export const createIDTokenPayload = async ( const rpSupportedVersions = authorizationRequestVersionDiscovery(payload); const maxRPVersion = rpSupportedVersions.reduce( (previous, current) => (current.valueOf() > previous.valueOf() ? current : previous), - SupportedVersion.SIOPv2_D12_OID4VP_D18, + SupportedVersion.SIOPv2_D13_OID4VP_D20, ); if (responseOpts.version && rpSupportedVersions.length > 0 && !rpSupportedVersions.includes(responseOpts.version)) { throw Error(`RP does not support spec version ${responseOpts.version}, supported versions: ${rpSupportedVersions.toString()}`); diff --git a/src/op/OP.ts b/src/op/OP.ts index c5a67dd..fc48012 100644 --- a/src/op/OP.ts +++ b/src/op/OP.ts @@ -245,7 +245,7 @@ export class OP { registration: { ...this._createResponseOptions?.registration, issuer }, responseURI, responseURIType: - this._createResponseOptions.responseURIType ?? (version < SupportedVersion.SIOPv2_D12_OID4VP_D18 && responseURI ? 'redirect_uri' : undefined), + this._createResponseOptions.responseURIType ?? (version < SupportedVersion.SIOPv2_D13_OID4VP_D20 && responseURI ? 'redirect_uri' : undefined), }; } diff --git a/src/schemas/AuthorizationRequestPayloadVD12OID4VPD18.schema.ts b/src/schemas/AuthorizationRequestPayloadVD13OID4VPD20.schema.ts similarity index 98% rename from src/schemas/AuthorizationRequestPayloadVD12OID4VPD18.schema.ts rename to src/schemas/AuthorizationRequestPayloadVD13OID4VPD20.schema.ts index 8a415a9..f8a9b6e 100644 --- a/src/schemas/AuthorizationRequestPayloadVD12OID4VPD18.schema.ts +++ b/src/schemas/AuthorizationRequestPayloadVD13OID4VPD20.schema.ts @@ -1,9 +1,9 @@ -export const AuthorizationRequestPayloadVD12OID4VPD18SchemaObj = { - "$id": "AuthorizationRequestPayloadVD12OID4VPD18Schema", +export const AuthorizationRequestPayloadVD13OID4VPD20SchemaObj = { + "$id": "AuthorizationRequestPayloadVD13OID4VPD20Schema", "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/AuthorizationRequestPayloadVD12OID4VPD18", + "$ref": "#/definitions/AuthorizationRequestPayloadVD13OID4VPD20", "definitions": { - "AuthorizationRequestPayloadVD12OID4VPD18": { + "AuthorizationRequestPayloadVD13OID4VPD20": { "type": "object", "properties": { "id_token_type": { @@ -1052,7 +1052,10 @@ export const AuthorizationRequestPayloadVD12OID4VPD18SchemaObj = { "pre-registered", "redirect_uri", "entity_id", - "did" + "did", + "verifier_attestation", + "x509_san_dns", + "x509_san_uri" ] } } diff --git a/src/types/Errors.ts b/src/types/Errors.ts index b9f23bc..7f5a295 100644 --- a/src/types/Errors.ts +++ b/src/types/Errors.ts @@ -66,6 +66,12 @@ enum SIOPErrors { NO_VERIFIABLE_PRESENTATION_NO_CREDENTIALS = 'Either no verifiable presentation or no credentials found in the verifiable presentation', VERIFICATION_METHOD_NOT_SUPPORTED = 'Verification method not supported', VERIFICATION_METHOD_NO_MATCH = "The verification method from the RP's DID Document does NOT match the kid of the SIOP Request", + VERIFICATION_VERIFIER_ATTESTATION_SCHEME_ERROR = 'Verification failed. verifier_attestation scheme error: Invalid payload data', + VERIFICATION_X509_SAN_DNS_SCHEME_DNS_NAME_MATCH = 'Verification of x509_san_dns scheme error: DNS name does not match', + VERIFICATION_X509_SAN_DNS_ALT_NAMES_ERROR = 'Verification of x509_san_dns scheme error: No SAN found or incorrect SAN format.', + VERIFICATION_X509_SAN_DNS_SCHEME_ERROR = 'Verification failed. x509_san_dns scheme error: Invalid x509 data', + VERIFICATION_X509_SAN_DNS_SCHEME_NO_CERTIFICATE_ERROR = 'Verification failed. x509_san_dns scheme error: No certificate found', + VERIFICATION_X509_SAN_URI_SCHEME_NOT_IMPLEMENTED_ERROR = 'Verification failed. x509_san_uri not implemented.', VERIFY_BAD_PARAMS = 'Verify bad parameters', VERIFIABLE_PRESENTATION_SIGNATURE_NOT_VALID = 'The signature of the verifiable presentation is not valid', VERIFIABLE_PRESENTATION_VERIFICATION_FUNCTION_MISSING = 'The verifiable presentation verification function is missing', diff --git a/src/types/JWT.types.ts b/src/types/JWT.types.ts index cd8a9e2..5055ecf 100644 --- a/src/types/JWT.types.ts +++ b/src/types/JWT.types.ts @@ -19,7 +19,7 @@ export interface JWTPayload { exp?: number; rexp?: number; jti?: string; - + cnf?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any [x: string]: any; } @@ -30,6 +30,7 @@ export interface VerifiedJWT { issuer: string; //The issuer (did) of the JWT signer?: VerificationMethod; // The matching verification method from the DID that was used to sign jwt: string; // The JWT + jwk?: string; } /** diff --git a/src/types/SIOP.types.ts b/src/types/SIOP.types.ts index 7acd702..ec26e25 100644 --- a/src/types/SIOP.types.ts +++ b/src/types/SIOP.types.ts @@ -77,7 +77,7 @@ export interface AuthorizationRequestPayloadVD11 presentation_definition_uri?: string; } -export interface AuthorizationRequestPayloadVD12OID4VPD18 +export interface AuthorizationRequestPayloadVD13OID4VPD20 extends AuthorizationRequestCommonPayload, RequestClientMetadataPayloadProperties, RequestIdTokenPayloadProperties { @@ -88,13 +88,13 @@ export interface AuthorizationRequestPayloadVD12OID4VPD18 response_uri?: string; // New since OID4VP18 OPTIONAL. The Response URI to which the Wallet MUST send the Authorization Response using an HTTPS POST request as defined by the Response Mode direct_post. The Response URI receives all Authorization Response parameters as defined by the respective Response Type. When the response_uri parameter is present, the redirect_uri Authorization Request parameter MUST NOT be present. If the redirect_uri Authorization Request parameter is present when the Response Mode is direct_post, the Wallet MUST return an invalid_request Authorization Response error. } -export type ClientIdScheme = 'pre-registered' | 'redirect_uri' | 'entity_id' | 'did'; +export type ClientIdScheme = 'pre-registered' | 'redirect_uri' | 'entity_id' | 'did' | 'verifier_attestation' | 'x509_san_dns' | 'x509_san_uri'; // https://openid.bitbucket.io/connect/openid-connect-self-issued-v2-1_0.html#section-10 export type AuthorizationRequestPayload = | AuthorizationRequestPayloadVID1 | AuthorizationRequestPayloadVD11 - | AuthorizationRequestPayloadVD12OID4VPD18; + | AuthorizationRequestPayloadVD13OID4VPD20; export type JWTVcPresentationProfileAuthenticationRequestPayload = RequestIdTokenPayloadProperties; @@ -772,7 +772,7 @@ export interface RevocationOpts { export enum SupportedVersion { SIOPv2_ID1 = 70, SIOPv2_D11 = 110, - SIOPv2_D12_OID4VP_D18 = 180, + SIOPv2_D13_OID4VP_D20 = 180, JWT_VC_PRESENTATION_PROFILE_v1 = 71, } diff --git a/test/e2e/mattr.launchpad.spec.ts b/test/e2e/mattr.launchpad.spec.ts index ded7ab6..348aa0e 100644 --- a/test/e2e/mattr.launchpad.spec.ts +++ b/test/e2e/mattr.launchpad.spec.ts @@ -227,7 +227,7 @@ describe('Mattr OID4VP v18 credential offer', () => { console.log(JSON.stringify(verification)); expect(verification).toBeDefined(); - expect(verification.versions).toEqual([SupportedVersion.SIOPv2_D12_OID4VP_D18]); + expect(verification.versions).toEqual([SupportedVersion.SIOPv2_D13_OID4VP_D20]); /** * pd value: {"id":"dae5d9b6-8145-4297-99b2-b8fcc5abb5ad","input_descriptors":[{"id":"OpenBadgeCredential","format":{"jwt_vc_json":{"alg":["EdDSA"]},"jwt_vc":{"alg":["EdDSA"]}},"constraints":{"fields":[{"path":["$.vc.type"],"filter":{"type":"array","items":{"type":"string"},"contains":{"const":"OpenBadgeCredential"}}}]}}]} diff --git a/test/interop/EBSI/EBSI.spec.ts b/test/interop/EBSI/EBSI.spec.ts index 890e001..37621dd 100644 --- a/test/interop/EBSI/EBSI.spec.ts +++ b/test/interop/EBSI/EBSI.spec.ts @@ -51,7 +51,7 @@ describe('EBSI', () => { wellknownDIDVerifyCallback: async (_args: IVerifyCallbackArgs): Promise => ({ verified: true }), }, correlationId: '1234', - supportedVersions: [SupportedVersion.SIOPv2_D12_OID4VP_D18], + supportedVersions: [SupportedVersion.SIOPv2_D13_OID4VP_D20], }; it( 'succeed from request opts when all params are set', diff --git a/yarn.lock b/yarn.lock index e449ccf..c27d02d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3113,6 +3113,13 @@ resolved "https://registry.yarnpkg.com/@types/language-tags/-/language-tags-1.0.4.tgz#c622209605b919c41cbf5a78c2fb58dbc3d6f029" integrity sha512-20PQbifv3v/djCT+KlXybv0KqO5ofoR1qD1wkinN59kfggTPVTWGmPFgL/1yWuDyRcsQP/POvkqK+fnl5nOwTg== +"@types/node-forge@^1.3.11": + version "1.3.11" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" + integrity sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ== + dependencies: + "@types/node" "*" + "@types/node@*": version "20.11.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.0.tgz#8e0b99e70c0c1ade1a86c4a282f7b7ef87c9552f" @@ -6530,6 +6537,11 @@ node-fetch@^3.2.10: fetch-blob "^3.1.4" formdata-polyfill "^4.0.10" +node-forge@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" + integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== + node-gyp-build@^4.2.0: version "4.8.0" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.0.tgz#3fee9c1731df4581a3f9ead74664369ff00d26dd"