Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"typescript": "~5.8.3"
},
"dependencies": {
"dcql": "^0.2.22",
"zod": "^3.25.42"
}
}
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/disclosure-policy/validateAllowlistPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { X509Certificate } from '@credo-ts/core'

export type AllowListPolicy = {
allowlist: Array<string>
}

export const validateAllowListPolicy = (
allowListPolicy: AllowListPolicy,
relyingPartyAccessCertificate: X509Certificate
) => allowListPolicy.allowlist.includes(relyingPartyAccessCertificate.subject)
Comment on lines +7 to +10
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer returning objects with keys for these kinds of things. I think there's a good chance we need to add more context/metadata in the future. Especially if we e.g. want to return the entry at some piont.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for this case, returning a boolean makes sense as it is just a validation API, not an extraction. Also, including the entry does not really make sense as the entry (the rpAccessCert is provided by the user).

Can change it for consistency, though.

55 changes: 55 additions & 0 deletions src/disclosure-policy/validateAttributeBasedAccessControlPolicy.ts
Original file line number Diff line number Diff line change
@@ -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<string>
accessCertificate: X509Certificate
trustedCertificates?: Array<string>
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
)
Comment on lines +46 to +49
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can throw if an invalid query is used. Do we catch that somewhere?


return queryResult.canBeSatisfied
} catch {
return false
}
}
11 changes: 11 additions & 0 deletions src/disclosure-policy/validateRootOfTrustPolicy.ts
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we just need to do a match on the distinguished name (issuer or subject) of the certificate?

So probaby the issuer field of the root/intermediate certificate in the authorization request must match one of the distinguides names from the list passed

103 changes: 103 additions & 0 deletions src/disclosure-policy/verifyAuthorizationAttestation.ts
Original file line number Diff line number Diff line change
@@ -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({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great if we don't have to redefine the JWT payload every time 🤔 . Fine for now, but might be interesting to build on top of a base defined in e.g. openid4vc libs

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<string>
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
}
}
Comment on lines +70 to +84
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to tweak the API in Credo to handle this better. Verifying twice is not a good approach


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
}
Original file line number Diff line number Diff line change
@@ -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<string>
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<VerifyDisclosurePoliciesForOpenId4VpAuthorizationRequestReturnContext> => {
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<DisclosurePolicy>('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<string> =>
request.verifier_attestations
?.filter((va) => typeof va.data === 'string' && isAuthorizationAttestation(va.format, va.data))
.map((va) => va.data as string) ?? []
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { verifyOpenid4VpAuthorizationRequest } from './verifyOpenid4VpAuthorizationRequest'
export * from './registration-certificate/verifyRegistrationCertificateInOpenid4VpAuthorizationRequest'
export * from './disclosure-policy/verifyDisclosurePoliciesForOpenid4VpAuthorizationRequest'
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
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': {
const doctypeValue = credentialQuery.meta?.doctype_value
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)
)
Expand Down Expand Up @@ -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') &&
Expand Down
Loading
Loading