diff --git a/package.json b/package.json index bea8a5a..b5f555d 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "typescript": "~5.8.3" }, "dependencies": { + "dcql": "^0.2.22", "zod": "^3.25.42" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ace96ab..8c35f3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + dcql: + specifier: ^0.2.22 + version: 0.2.22(typescript@5.8.3) zod: specifier: ^3.25.42 version: 3.25.42 @@ -1389,6 +1392,7 @@ packages: '@sphereon/kmp-mdoc-core@0.2.0-SNAPSHOT.26': resolution: {integrity: sha512-QXJ6R8ENiZV2rPMbn06cw5JKwqUYN1kzVRbYfONqE1PEXx1noQ4md7uxr2zSczi0ubKkNcbyYDNtIMTZIhGzmQ==} + bundledDependencies: [] '@sphereon/pex-models@2.3.2': resolution: {integrity: sha512-foFxfLkRwcn/MOp/eht46Q7wsvpQGlO7aowowIIb5Tz9u97kYZ2kz6K2h2ODxWuv5CRA7Q0MY8XUBGE2lfOhOQ==} diff --git a/src/disclosure-policy/validateAllowlistPolicy.ts b/src/disclosure-policy/validateAllowlistPolicy.ts new file mode 100644 index 0000000..57a0c7c --- /dev/null +++ b/src/disclosure-policy/validateAllowlistPolicy.ts @@ -0,0 +1,10 @@ +import type { X509Certificate } from '@credo-ts/core' + +export type AllowListPolicy = { + allowlist: Array +} + +export const validateAllowListPolicy = ( + allowListPolicy: AllowListPolicy, + relyingPartyAccessCertificate: X509Certificate +) => allowListPolicy.allowlist.includes(relyingPartyAccessCertificate.subject) diff --git a/src/disclosure-policy/validateAttributeBasedAccessControlPolicy.ts b/src/disclosure-policy/validateAttributeBasedAccessControlPolicy.ts new file mode 100644 index 0000000..f5e056d --- /dev/null +++ b/src/disclosure-policy/validateAttributeBasedAccessControlPolicy.ts @@ -0,0 +1,55 @@ +import type { AgentContext, DcqlQuery, X509Certificate } from '@credo-ts/core' +import { SdJwtVcService } from '@credo-ts/core' +import { type DcqlCredential, DcqlQuery as Query } from 'dcql' +import { verifyAuthorizationAttestation } from './verifyAuthorizationAttestation' + +export type AttributeBasedAccessControlPolicy = { + attribute_based_access_control: DcqlQuery +} + +type ValidateAttributeBasedAccessControlPolicyOptions = { + attributeBasedAccessControlPolicy: AttributeBasedAccessControlPolicy + authorizationAttestations: Array + accessCertificate: X509Certificate + trustedCertificates?: Array + allowUntrustedSigned?: boolean +} + +export const validateAttributeBasedAccessControlPolicy = async ( + agentContext: AgentContext, + { + accessCertificate, + attributeBasedAccessControlPolicy, + trustedCertificates, + allowUntrustedSigned, + authorizationAttestations, + }: ValidateAttributeBasedAccessControlPolicyOptions +) => { + try { + const credentials: DcqlCredential[] = [] + const sdJwtService = agentContext.dependencyManager.resolve(SdJwtVcService) + for (const authorizationAttestation of authorizationAttestations) { + await verifyAuthorizationAttestation(agentContext, { + authorizationAttestation, + accessCertificate, + allowUntrustedSigned, + trustedCertificates, + }) + const sdJwtCredential = sdJwtService.fromCompact(authorizationAttestation) + credentials.push({ + credential_format: 'dc+sd-jwt', + vct: sdJwtCredential.prettyClaims.vct, + claims: sdJwtCredential.prettyClaims, + }) + } + + const queryResult = Query.query( + Query.parse(attributeBasedAccessControlPolicy.attribute_based_access_control), + credentials + ) + + return queryResult.canBeSatisfied + } catch { + return false + } +} diff --git a/src/disclosure-policy/validateRootOfTrustPolicy.ts b/src/disclosure-policy/validateRootOfTrustPolicy.ts new file mode 100644 index 0000000..c2f481d --- /dev/null +++ b/src/disclosure-policy/validateRootOfTrustPolicy.ts @@ -0,0 +1,11 @@ +import type { X509Certificate } from '@credo-ts/core' + +// TODO: it is unclear from the spec what is inluded in this type +export type RootOfTrustPolicy = { + rootOfTrust: string +} + +// TODO: I am not sure how to validate this, based on the description +// +// rootOfTrust: the certificate of a RP must be derived from a list of specific root certificates. In the Implementing Acts it's called Specific root of trust. The value has to be the distinguished name of the root certificate. +export const validateRootOfTrustPolicy = (rootOfTrustPolicy: RootOfTrustPolicy) => true diff --git a/src/disclosure-policy/verifyAuthorizationAttestation.ts b/src/disclosure-policy/verifyAuthorizationAttestation.ts new file mode 100644 index 0000000..34d02d2 --- /dev/null +++ b/src/disclosure-policy/verifyAuthorizationAttestation.ts @@ -0,0 +1,103 @@ +import { type AgentContext, JwsService, Jwt, SdJwtVcService, type X509Certificate } from '@credo-ts/core' +import { z } from 'zod' +import { isRegistrationCertificate } from '../registration-certificate/verifyRegistrationCertificate' + +// TODO: support multiple authorization attestation formats. +// Currently, only sd-jwt is defined +const authorizationAttestationHeaderSchema = z.object({ + alg: z.string(), + typ: z.literal('dc+sd-jwt'), + x5c: z.array(z.string()), +}) + +// TODO: +// - Should we support more hashing confirmation claim values? +const authorizationAttestationPayloadSchema = z.object({ + iss: z.string(), + sub: z.string(), + status: z.object({ + status_list: z.object({ + idx: z.number(), + uri: z.string().url(), + }), + }), + iat: z.number(), + exp: z.number().optional(), + cnf: z + .object({ + 'x5t#S256': z.string(), + }) + .optional(), +}) + +/** + * + * According to: https://bmi.usercontent.opencode.de/eudi-wallet/eidas-2.0-architekturkonzept/flows/OID4VC-with-WRP-attestations/#verifier-attestations + * + * "Each credential, that is not a Registration Certificate, is treated as an Authorization Attestation. For possible formats, see the examples section like for SD-JWT-VC." + * + */ +export const isAuthorizationAttestation = (format: string, jwt: string) => { + if (format !== 'eudi_registration_certifiate') return false + + return !isRegistrationCertificate(format, jwt) +} + +export type VerifyAuthorizationAttestationOptions = { + authorizationAttestation: string + accessCertificate: X509Certificate + trustedCertificates?: Array + allowUntrustedSigned?: boolean +} + +export const verifyAuthorizationAttestation = async ( + agentContext: AgentContext, + { + authorizationAttestation, + accessCertificate, + allowUntrustedSigned, + trustedCertificates, + }: VerifyAuthorizationAttestationOptions +) => { + const jwsService = agentContext.dependencyManager.resolve(JwsService) + const sdJwtService = agentContext.dependencyManager.resolve(SdJwtVcService) + + let isValidButUntrusted = false + let isValidAndTrusted = false + + const jwt = Jwt.fromSerializedJwt(authorizationAttestation) + + try { + const { isValid } = await jwsService.verifyJws(agentContext, { + jws: authorizationAttestation, + trustedCertificates, + }) + isValidAndTrusted = isValid + } catch { + if (allowUntrustedSigned) { + const { isValid } = await jwsService.verifyJws(agentContext, { + jws: authorizationAttestation, + trustedCertificates: jwt.header.x5c ?? [], + }) + isValidButUntrusted = isValid + } + } + + authorizationAttestationHeaderSchema.parse(jwt.header) + const payload = authorizationAttestationPayloadSchema.parse(jwt.payload) + + // Validate the identifier of the issuer. + // It should be the same that issues the Authorization Certificate as that issues this attestation + // TODO: what is the Authorization Certificate? Is it the signer of the auth request? + + // TODO: confirm that the `signingCertificate.subject` is the DN of the RP + if (payload.sub !== accessCertificate.subject) { + return false + } + + const { isValid } = await sdJwtService.verify(agentContext, { + compactSdJwtVc: authorizationAttestation, + }) + + return isValid +} diff --git a/src/disclosure-policy/verifyDisclosurePoliciesForOpenid4VpAuthorizationRequest.ts b/src/disclosure-policy/verifyDisclosurePoliciesForOpenid4VpAuthorizationRequest.ts new file mode 100644 index 0000000..64f1c91 --- /dev/null +++ b/src/disclosure-policy/verifyDisclosurePoliciesForOpenid4VpAuthorizationRequest.ts @@ -0,0 +1,111 @@ +import { type AgentContext, type DcqlCredentialsForRequest, X509Certificate } from '@credo-ts/core' +import type { OpenId4VpAuthorizationRequestPayload, OpenId4VpResolvedAuthorizationRequest } from '@credo-ts/openid4vc' +import { type AllowListPolicy, validateAllowListPolicy } from './validateAllowlistPolicy' +import { + type AttributeBasedAccessControlPolicy, + validateAttributeBasedAccessControlPolicy, +} from './validateAttributeBasedAccessControlPolicy' +import { type RootOfTrustPolicy, validateRootOfTrustPolicy } from './validateRootOfTrustPolicy' +import { isAuthorizationAttestation } from './verifyAuthorizationAttestation' + +export type VerifyDisclosurePoliciesForOpenId4VpAuthorizationRequestReturnContext = { + isValid: boolean + isSignedWithX509: boolean + disclosurePolicies: { + [credentialId: string]: { + isAllowListPolicyValid?: boolean + isRootOfTrustPolicyValid?: boolean + isAttributeBasedAccessControlValid?: boolean + } + } +} + +export type DisclosurePolicy = AllowListPolicy | RootOfTrustPolicy | AttributeBasedAccessControlPolicy + +export type VerifyDisclosurePoliciesForOpenId4VpAuthorizationRequestOptions = { + resolvedAuthorizationRequest: OpenId4VpResolvedAuthorizationRequest + matchedCredentials: DcqlCredentialsForRequest + trustedCertificates?: Array + allowUntrustedSigned?: boolean +} + +/** + * + * Checks all the matched credentials for additional disclosure policies as set by the issuer + * + * Make sure the disclosure policies are set manually on the metadata of the record, under the `eudi::disclosurePolicy` key + * + */ +export const verifyAuthorizationForOpenid4VpAuthorizationRequest = async ( + agentContext: AgentContext, + { + matchedCredentials, + resolvedAuthorizationRequest, + trustedCertificates, + allowUntrustedSigned, + }: VerifyDisclosurePoliciesForOpenId4VpAuthorizationRequestOptions +): Promise => { + const err: VerifyDisclosurePoliciesForOpenId4VpAuthorizationRequestReturnContext = { + isValid: true, + isSignedWithX509: resolvedAuthorizationRequest.signedAuthorizationRequest?.signer.method === 'x5c', + disclosurePolicies: {}, + } + + if (resolvedAuthorizationRequest.signedAuthorizationRequest?.signer.method !== 'x5c') { + return { isValid: false, isSignedWithX509: false, disclosurePolicies: {} } + } + + const relyingPartyAccessCertificate = X509Certificate.fromEncodedCertificate( + resolvedAuthorizationRequest.signedAuthorizationRequest.signer.x5c[0] + ) + + for (const [credentialId, matchedCredential] of Object.entries(matchedCredentials)) { + const disclosurePolicy = matchedCredential.credentialRecord.metadata.get('eudi::disclosurePolicy') + + if (!disclosurePolicy) { + continue + } + + err.disclosurePolicies[credentialId] = { + isAllowListPolicyValid: + 'allowList' in disclosurePolicy + ? validateAllowListPolicy(disclosurePolicy as AllowListPolicy, relyingPartyAccessCertificate) + : undefined, + isRootOfTrustPolicyValid: + 'rootOfTrust' in disclosurePolicy + ? validateRootOfTrustPolicy(disclosurePolicy as RootOfTrustPolicy) + : undefined, + isAttributeBasedAccessControlValid: + 'attribute_based_access_control' in disclosurePolicy + ? await validateAttributeBasedAccessControlPolicy(agentContext, { + attributeBasedAccessControlPolicy: disclosurePolicy as AttributeBasedAccessControlPolicy, + accessCertificate: relyingPartyAccessCertificate, + trustedCertificates, + allowUntrustedSigned, + authorizationAttestations: getAuthorizationAttestations( + resolvedAuthorizationRequest.authorizationRequestPayload + ), + }) + : undefined, + } + } + + // If there is any error at all, set `isValid` to false + err.isValid = Object.values(err) + .map((value) => { + if (typeof value === 'boolean') return value + if (typeof value === 'object') + return Object.values(value).some( + (v) => v.isAllowListPolicyValid || v.isRootOfTrustPolicyValid || v.isAttributeBasedAccessControlValid + ) + }) + .every((value) => value !== false) + + return err +} + +// TODO: credentialId in the verifier_attestation is ignored +const getAuthorizationAttestations = (request: OpenId4VpAuthorizationRequestPayload): Array => + request.verifier_attestations + ?.filter((va) => typeof va.data === 'string' && isAuthorizationAttestation(va.format, va.data)) + .map((va) => va.data as string) ?? [] diff --git a/src/index.ts b/src/index.ts index 263a48b..b4a3ae0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ -export { verifyOpenid4VpAuthorizationRequest } from './verifyOpenid4VpAuthorizationRequest' +export * from './registration-certificate/verifyRegistrationCertificateInOpenid4VpAuthorizationRequest' +export * from './disclosure-policy/verifyDisclosurePoliciesForOpenid4VpAuthorizationRequest' diff --git a/src/isDcqlQueryEqualOrSubset.ts b/src/registration-certificate/isDcqlQueryEqualOrSubset.ts similarity index 86% rename from src/isDcqlQueryEqualOrSubset.ts rename to src/registration-certificate/isDcqlQueryEqualOrSubset.ts index 5604ed6..8aa13b6 100644 --- a/src/isDcqlQueryEqualOrSubset.ts +++ b/src/registration-certificate/isDcqlQueryEqualOrSubset.ts @@ -1,23 +1,29 @@ import { type DcqlQuery, equalsIgnoreOrder, equalsWithOrder } from '@credo-ts/core' -export function isDcqlQueryEqualOrSubset(arq: DcqlQuery, rcq: DcqlQuery): boolean { - if (rcq.credential_sets) { +/** + * + * Check whether query `lhs` is equal or a subset of `rhs` + * + */ +export function isDcqlQueryEqualOrSubset(lhs: DcqlQuery, rhs: DcqlQuery): boolean { + // `credential_sets` are currently not supported + if (rhs.credential_sets) { return false } - if (rcq.credentials.some((c) => c.id)) { + if (rhs.credentials.some((c) => c.id)) { return false } // only sd-jwt and mdoc are supported - if (arq.credentials.some((c) => c.format !== 'mso_mdoc' && c.format !== 'vc+sd-jwt' && c.format !== 'dc+sd-jwt')) { + if (lhs.credentials.some((c) => c.format !== 'mso_mdoc' && c.format !== 'vc+sd-jwt' && c.format !== 'dc+sd-jwt')) { return false } - credentialQueryLoop: for (const credentialQuery of arq.credentials) { - const matchingRcqCredentialQueriesBasedOnFormat = rcq.credentials.filter((c) => c.format === credentialQuery.format) + credentialQueryLoop: for (const credentialQuery of lhs.credentials) { + const matchingRhsCredentialQueriesBasedOnFormat = rhs.credentials.filter((c) => c.format === credentialQuery.format) - if (matchingRcqCredentialQueriesBasedOnFormat.length === 0) return false + if (matchingRhsCredentialQueriesBasedOnFormat.length === 0) return false switch (credentialQuery.format) { case 'mso_mdoc': { @@ -25,7 +31,7 @@ export function isDcqlQueryEqualOrSubset(arq: DcqlQuery, rcq: DcqlQuery): boolea if (!doctypeValue) return false if (typeof credentialQuery.meta?.doctype_value !== 'string') return false - const foundMatchingRequests = matchingRcqCredentialQueriesBasedOnFormat.filter( + const foundMatchingRequests = matchingRhsCredentialQueriesBasedOnFormat.filter( (c): c is typeof c & { format: 'mso_mdoc' } => !!(c.format === 'mso_mdoc' && c.meta && c.meta.doctype_value === doctypeValue) ) @@ -68,7 +74,7 @@ export function isDcqlQueryEqualOrSubset(arq: DcqlQuery, rcq: DcqlQuery): boolea if (!vctValues) return false if (credentialQuery.meta?.vct_values?.length === 0) return false - const foundMatchingRequests = matchingRcqCredentialQueriesBasedOnFormat.filter( + const foundMatchingRequests = matchingRhsCredentialQueriesBasedOnFormat.filter( (c): c is typeof c & ({ format: 'dc+sd-jwt' } | { format: 'vc+sd-jwt' }) => !!( (c.format === 'dc+sd-jwt' || c.format === 'vc+sd-jwt') && diff --git a/src/registration-certificate/verifyRegistrationCertificate.ts b/src/registration-certificate/verifyRegistrationCertificate.ts new file mode 100644 index 0000000..138fbd7 --- /dev/null +++ b/src/registration-certificate/verifyRegistrationCertificate.ts @@ -0,0 +1,207 @@ +import { type AgentContext, CredoError, type DcqlQuery, JwsService, Jwt, X509Certificate } from '@credo-ts/core' +import type { OpenId4VpResolvedAuthorizationRequest } from '@credo-ts/openid4vc' +import { z } from 'zod' +import { isDcqlQueryEqualOrSubset } from './isDcqlQueryEqualOrSubset' + +type VerifyRegistrationCertificateOptions = { + registrationCertificate: string + resolvedAuthorizationRequest: OpenId4VpResolvedAuthorizationRequest + trustedCertificates?: Array + allowUntrustedSigned?: boolean +} + +const registrationCertificateHeaderSchema = z + .object({ + typ: z.literal('rc-rp+jwt'), + alg: z.string(), + // sprin-d did not define this + x5u: z.string().url().optional(), + // sprin-d did not define this + 'x5t#s256': z.string().optional(), + }) + .passthrough() + +// TODO: does not support intermediaries +const registrationCertificatePayloadSchema = z + .object({ + credentials: z.array( + z.object({ + format: z.string(), + multiple: z.boolean().default(false), + meta: z + .object({ + vct_values: z.array(z.string()).optional(), + doctype_value: z.string().optional(), + }) + .optional(), + trusted_authorities: z + .array(z.object({ type: z.string(), values: z.array(z.string()) })) + .nonempty() + .optional(), + require_cryptographic_holder_binding: z.boolean().default(true), + claims: z + .array( + z.object({ + id: z.string().optional(), + path: z.array(z.string()).nonempty().nonempty(), + values: z.array(z.number().or(z.boolean())).optional(), + }) + ) + .nonempty() + .optional(), + claim_sets: z.array(z.array(z.string())).nonempty().optional(), + }) + ), + contact: z.object({ + website: z.string().url(), + 'e-mail': z.string().email(), + phone: z.string(), + }), + sub: z.string(), + // Should be service + services: z.array(z.object({ lang: z.string(), name: z.string() })), + public_body: z.boolean().default(false), + entitlements: z.array(z.any()), + provided_attestations: z + .array( + z.object({ + format: z.string(), + meta: z.any(), + }) + ) + .optional(), + privacy_policy: z.string().url(), + iat: z.number().optional(), + exp: z.number().optional(), + purpose: z + .array( + z.object({ + locale: z.string().optional(), + lang: z.string().optional(), + name: z.string(), + }) + ) + .optional(), + status: z.any(), + }) + .passthrough() + +export const isRegistrationCertificate = (format: string, jwt: string) => { + if (format !== 'jwt') return false + + try { + const { + header: { typ }, + } = Jwt.fromSerializedJwt(jwt) + return typ === 'rc-rp+jwt' + } catch { + return false + } +} + +export type VerifyIfRegistrationCertificateReturnContext = { + isValid: boolean + isSignedWithX509?: boolean + isAccessCertificateSubjectEqualToRegistrationCertificate?: boolean + isTimestampValid?: boolean + isJwsValid?: boolean + isRegistrationCertificateQueryEqualOrSubsetOfAuthorizationRequestQuery?: boolean + isDcqlUsed?: boolean +} + +/** + * + * + * If it has a header of `rc-rp+jwt` it is validated as a registration certificate according to: + * https://bmi.usercontent.opencode.de/eudi-wallet/eidas-2.0-architekturkonzept/flows/Wallet-Relying-Party-Authentication/#registration-certificate + * + */ +export const verifyIfRegistrationCertificate = async ( + agentContext: AgentContext, + { + registrationCertificate, + trustedCertificates, + allowUntrustedSigned, + resolvedAuthorizationRequest: { signedAuthorizationRequest, authorizationRequestPayload, dcql }, + }: VerifyRegistrationCertificateOptions +): Promise => { + if ((!trustedCertificates || trustedCertificates.length === 0) && !allowUntrustedSigned) { + throw new Error('Either provide trusted certificates, or allow for untrusted signers') + } + + const returnContext: VerifyIfRegistrationCertificateReturnContext = { + isValid: true, + } + + if ( + !dcql || + authorizationRequestPayload.presentation_definition || + authorizationRequestPayload.presentation_definition_uri + ) { + return { + isValid: false, + isDcqlUsed: false, + } + } + + if (signedAuthorizationRequest?.signer.method !== 'x5c') { + return { + isValid: false, + isSignedWithX509: false, + } + } + + const jwsService = agentContext.dependencyManager.resolve(JwsService) + + const jwt = Jwt.fromSerializedJwt(registrationCertificate) + + const verifySignature = async (certs: Array) => { + const { isValid } = await jwsService.verifyJws(agentContext, { + jws: registrationCertificate, + trustedCertificates: certs, + }) + return isValid + } + + try { + let isValid = true + if (allowUntrustedSigned) { + isValid = await verifySignature([...(trustedCertificates ?? []), ...(jwt.header.x5c ?? [])]) + } else { + isValid = await verifySignature(trustedCertificates ?? []) + } + if (!isValid) { + returnContext.isValid = false + returnContext.isJwsValid = false + } + } catch { + returnContext.isValid = false + returnContext.isJwsValid = false + } + + registrationCertificateHeaderSchema.parse(jwt.header) + const parsedPayload = registrationCertificatePayloadSchema.parse(jwt.payload.toJson()) + + const [rpCertEncoded] = signedAuthorizationRequest.signer.x5c + const rpCert = X509Certificate.fromEncodedCertificate(rpCertEncoded) + + if (rpCert.subject !== parsedPayload.sub) { + returnContext.isAccessCertificateSubjectEqualToRegistrationCertificate = false + returnContext.isValid = false + } + + if (parsedPayload.iat && new Date().getTime() / 1000 <= parsedPayload.iat) { + returnContext.isTimestampValid = false + returnContext.isValid = false + } + + // TODO: check the status of the registration certificate + + const isValidDcqlQuery = isDcqlQueryEqualOrSubset(dcql.queryResult, parsedPayload as unknown as DcqlQuery) + if (!isValidDcqlQuery) { + returnContext.isValid = false + returnContext.isRegistrationCertificateQueryEqualOrSubsetOfAuthorizationRequestQuery = false + } + + return returnContext +} diff --git a/src/registration-certificate/verifyRegistrationCertificateInOpenid4VpAuthorizationRequest.ts b/src/registration-certificate/verifyRegistrationCertificateInOpenid4VpAuthorizationRequest.ts new file mode 100644 index 0000000..2f404f3 --- /dev/null +++ b/src/registration-certificate/verifyRegistrationCertificateInOpenid4VpAuthorizationRequest.ts @@ -0,0 +1,38 @@ +import type { AgentContext } from '@credo-ts/core' +import type { OpenId4VpResolvedAuthorizationRequest } from '@credo-ts/openid4vc' +import { isRegistrationCertificate, verifyIfRegistrationCertificate } from './verifyRegistrationCertificate' + +type VerifyAuthorizationRequestOptions = { + resolvedAuthorizationRequest: OpenId4VpResolvedAuthorizationRequest + trustedCertificates?: Array + allowUntrustedSigned?: boolean +} + +/** + * + * Verify the Registration certificate if it is included in the authorization request + * If it is not included, `undefined` will be returned and the caller should handle accordingly + * + */ +export const verifyRegistrationCertificateInOpenid4VpAuthorizationRequest = async ( + agentContext: AgentContext, + { resolvedAuthorizationRequest, trustedCertificates, allowUntrustedSigned }: VerifyAuthorizationRequestOptions +) => { + let registrationCertificateResult: Awaited> | undefined + if (!resolvedAuthorizationRequest.authorizationRequestPayload.verifier_attestations) return + for (const va of resolvedAuthorizationRequest.authorizationRequestPayload.verifier_attestations) { + if (typeof va.data !== 'string') { + throw new Error('Authorization Attestations of string are currently only supported') + } + + if (isRegistrationCertificate(va.format, va.data)) { + registrationCertificateResult = await verifyIfRegistrationCertificate(agentContext, { + registrationCertificate: va.data, + resolvedAuthorizationRequest, + allowUntrustedSigned, + trustedCertificates, + }) + } + } + return registrationCertificateResult +} diff --git a/src/verifyOpenid4VpAuthorizationRequest.ts b/src/verifyOpenid4VpAuthorizationRequest.ts deleted file mode 100644 index 4a8a5d8..0000000 --- a/src/verifyOpenid4VpAuthorizationRequest.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { type AgentContext, type DcqlQuery, JwsService, Jwt, X509Certificate } from '@credo-ts/core' -import type { OpenId4VpResolvedAuthorizationRequest } from '@credo-ts/openid4vc' -import z from 'zod' -import { isDcqlQueryEqualOrSubset } from './isDcqlQueryEqualOrSubset' - -export type VerifyAuthorizationRequestOptions = { - resolvedAuthorizationRequest: OpenId4VpResolvedAuthorizationRequest - trustedCertificates?: Array - allowUntrustedSigned?: boolean -} - -export const verifyOpenid4VpAuthorizationRequest = async ( - agentContext: AgentContext, - { - resolvedAuthorizationRequest: { authorizationRequestPayload, signedAuthorizationRequest, dcql }, - trustedCertificates, - allowUntrustedSigned, - }: VerifyAuthorizationRequestOptions -) => { - const results = [] - if (!authorizationRequestPayload.verifier_attestations) return - for (const va of authorizationRequestPayload.verifier_attestations) { - // Here we verify it as a registration certificate according to - // https://bmi.usercontent.opencode.de/eudi-wallet/eidas-2.0-architekturkonzept/flows/Wallet-Relying-Party-Authentication/#registration-certificate - if (va.format === 'jwt') { - if (typeof va.data !== 'string') { - throw new Error('Only inline JWTs are supported') - } - - const jwsService = agentContext.dependencyManager.resolve(JwsService) - - let isValidButUntrusted = false - let isValidAndTrusted = false - - const jwt = Jwt.fromSerializedJwt(va.data) - - try { - const { isValid } = await jwsService.verifyJws(agentContext, { - jws: va.data, - trustedCertificates, - }) - isValidAndTrusted = isValid - } catch { - if (allowUntrustedSigned) { - const { isValid } = await jwsService.verifyJws(agentContext, { - jws: va.data, - trustedCertificates: jwt.header.x5c ?? [], - }) - isValidButUntrusted = isValid - } - } - - if (jwt.header.typ !== 'rc-rp+jwt') { - throw new Error(`only 'rc-rp+jwt' is supported as header typ. Request included: ${jwt.header.typ}`) - } - - if (!signedAuthorizationRequest) { - throw new Error('Request must be signed for the registration certificate') - } - - if (signedAuthorizationRequest.signer.method !== 'x5c') { - throw new Error('x5c is only supported for registration certificate') - } - - const registrationCertificateHeaderSchema = z - .object({ - typ: z.literal('rc-rp+jwt'), - alg: z.string(), - // sprin-d did not define this - x5u: z.string().url().optional(), - // sprin-d did not define this - 'x5t#s256': z.string().optional(), - }) - .passthrough() - - // TODO: does not support intermediaries - const registrationCertificatePayloadSchema = z - .object({ - credentials: z.array( - z.object({ - format: z.string(), - multiple: z.boolean().default(false), - meta: z - .object({ - vct_values: z.array(z.string()).optional(), - doctype_value: z.string().optional(), - }) - .optional(), - trusted_authorities: z - .array(z.object({ type: z.string(), values: z.array(z.string()) })) - .nonempty() - .optional(), - require_cryptographic_holder_binding: z.boolean().default(true), - claims: z - .array( - z.object({ - id: z.string().optional(), - path: z.array(z.string()).nonempty().nonempty(), - values: z.array(z.number().or(z.boolean())).optional(), - }) - ) - .nonempty() - .optional(), - claim_sets: z.array(z.array(z.string())).nonempty().optional(), - }) - ), - contact: z.object({ - website: z.string().url(), - 'e-mail': z.string().email(), - phone: z.string(), - }), - sub: z.string(), - // Should be service - services: z.array(z.object({ lang: z.string(), name: z.string() })), - public_body: z.boolean().default(false), - entitlements: z.array(z.any()), - provided_attestations: z - .array( - z.object({ - format: z.string(), - meta: z.any(), - }) - ) - .optional(), - privacy_policy: z.string().url(), - iat: z.number().optional(), - exp: z.number().optional(), - purpose: z - .array( - z.object({ - locale: z.string().optional(), - lang: z.string().optional(), - name: z.string(), - }) - ) - .optional(), - status: z.any(), - }) - .passthrough() - - registrationCertificateHeaderSchema.parse(jwt.header) - const parsedPayload = registrationCertificatePayloadSchema.parse(jwt.payload.toJson()) - - const [rpCertEncoded] = signedAuthorizationRequest.signer.x5c - const rpCert = X509Certificate.fromEncodedCertificate(rpCertEncoded) - - if (rpCert.subject !== parsedPayload.sub) { - throw new Error( - `Subject in the certificate of the auth request: '${rpCert.subject}' is not equal to the subject of the registration certificate: '${parsedPayload.sub}'` - ) - } - - if (parsedPayload.iat && new Date().getTime() / 1000 <= parsedPayload.iat) { - throw new Error('Issued at timestamp of the registration certificate is in the future') - } - - // TODO: check the status of the registration certificate - - if (!dcql) { - throw new Error('DCQL must be used when working registration certificates') - } - - if ( - authorizationRequestPayload.presentation_definition || - authorizationRequestPayload.presentation_definition_uri - ) { - throw new Error('Presentation Exchange is not supported for the registration certificate') - } - - const isValidDcqlQuery = isDcqlQueryEqualOrSubset(dcql.queryResult, parsedPayload as unknown as DcqlQuery) - - if (!isValidDcqlQuery) { - throw new Error( - 'DCQL query in the authorization request is not equal or a valid subset of the DCQl query provided in the registration certificate' - ) - } - - results.push({ isValidButUntrusted, isValidAndTrusted, x509RegistrationCertificate: rpCert }) - } else { - throw new Error(`only format of 'jwt' is supported`) - } - } - return results -} diff --git a/tests/verifyOpenid4VpAuthorizationRequest.test.ts b/tests/verifyOpenid4VpAuthorizationRequest.test.ts index 843837e..c720a69 100644 --- a/tests/verifyOpenid4VpAuthorizationRequest.test.ts +++ b/tests/verifyOpenid4VpAuthorizationRequest.test.ts @@ -1,11 +1,11 @@ -import { doesNotReject, equal, ok, rejects } from 'node:assert' +import { equal, ok, rejects, strictEqual } from 'node:assert' import { after, before, beforeEach, suite, test } from 'node:test' import { AskarModule } from '@credo-ts/askar' import { Agent } from '@credo-ts/core' import { agentDependencies } from '@credo-ts/node' import { OpenId4VcHolderModule } from '@credo-ts/openid4vc' import { askar } from '@openwallet-foundation/askar-nodejs' -import { verifyOpenid4VpAuthorizationRequest } from '../src' +import { verifyRegistrationCertificateInOpenid4VpAuthorizationRequest } from '../src' const trustedCertificates = [ `-----BEGIN CERTIFICATE----- @@ -56,13 +56,12 @@ suite('verify openid4vp authorization request', () => { trustedCertificates, }) - const result = await verifyOpenid4VpAuthorizationRequest(agent.context, { + const result = await verifyRegistrationCertificateInOpenid4VpAuthorizationRequest(agent.context, { resolvedAuthorizationRequest: request, trustedCertificates, }) - equal(result?.[0].isValidAndTrusted, true) - equal(result?.[0].isValidButUntrusted, false) + equal(result?.isValid, true) }) test('Successfully verify: draft-24, valid request, dcql, allow all certificates', async () => { @@ -73,13 +72,12 @@ suite('verify openid4vp authorization request', () => { trustedCertificates, }) - const result = await verifyOpenid4VpAuthorizationRequest(agent.context, { + const result = await verifyRegistrationCertificateInOpenid4VpAuthorizationRequest(agent.context, { resolvedAuthorizationRequest: request, allowUntrustedSigned: true, }) - equal(result?.[0].isValidAndTrusted, false) - equal(result?.[0].isValidButUntrusted, true) + equal(result?.isValid, true) }) test('Fail verify: draft-24, valid request, pex', async () => { @@ -91,7 +89,7 @@ suite('verify openid4vp authorization request', () => { }) await rejects( - verifyOpenid4VpAuthorizationRequest(agent.context, { + verifyRegistrationCertificateInOpenid4VpAuthorizationRequest(agent.context, { resolvedAuthorizationRequest: request, trustedCertificates, }) @@ -106,12 +104,13 @@ suite('verify openid4vp authorization request', () => { trustedCertificates, }) - await rejects( - verifyOpenid4VpAuthorizationRequest(agent.context, { - resolvedAuthorizationRequest: request, - trustedCertificates, - }) - ) + const result = await verifyRegistrationCertificateInOpenid4VpAuthorizationRequest(agent.context, { + resolvedAuthorizationRequest: request, + trustedCertificates, + }) + + strictEqual(result?.isValid, false) + equal(result?.isRegistrationCertificateQueryEqualOrSubsetOfAuthorizationRequestQuery, false) }) }) })