-
Notifications
You must be signed in to change notification settings - Fork 0
feat: auth + eudi disclosure policies #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
berendsliedrecht
commented
Jun 16, 2025
- feat: Relying Party Authorization
- feat: authz disclosure policy verification
Signed-off-by: Berend Sliedrecht <[email protected]>
Signed-off-by: Berend Sliedrecht <[email protected]>
| }) | ||
| } | ||
|
|
||
| const result = runDcqlQuery(attributeBasedAccessControlPolicy.attribute_based_access_control as Query.Output, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i think we should use the approach as used by Credo: https://github.com/openwallet-foundation/credo-ts/blob/1ac3c1d258fbef3fea655072bdf61d73960733c6/packages/core/src/modules/dcql/DcqlService.ts#L153
And actually parse the query first instead of casting it as .Output
| credentials.push({ | ||
| credential_format: 'vc+sd-jwt', | ||
| vct: sdJwtCredential.prettyClaims.vct, | ||
| claims: sdJwtCredential.prettyClaims, | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think just using dc+sd-jwt is fine here, since the DCQL queries can only contain dc+sd-jwt right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I got this from Credo.
But I see now in the changelog of openid4vp they renamed vc+... to dc+....
Removed it.
| const credentials: DcqlCredential[] = [] | ||
| const sdJwtService = agentContext.dependencyManager.resolve(SdJwtVcService) | ||
| for (const authorizationAttestation of authorizationAttestations) { | ||
| const sdJwtCredential = sdJwtService.fromCompact(authorizationAttestation) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you filter the attestations beforehand, so we always know this will be an Sd-JWT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is a check, whether it is not a registration certificate. I assumed this method would handle it, but I can add a layer around it.
| export const validateAllowListPolicy = ( | ||
| allowListPolicy: AllowListPolicy, | ||
| relyingPartyAccessCertificate: X509Certificate | ||
| ) => allowListPolicy.allowlist.includes(relyingPartyAccessCertificate.subject) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| presentation: false, | ||
| }) | ||
|
|
||
| return result.canBeSatisfied |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here, prefer objects which allow for extension
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same answer as above.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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
| if (!signedAuthorizationRequest) { | ||
| throw new Error('Authorization request must be signed for the authorization attestation') | ||
| } | ||
|
|
||
| if (signedAuthorizationRequest.signer.method !== 'x5c') { | ||
| throw new Error( | ||
| 'x5c is only supported for key derivation on the authorization request containing a authorization attestation' | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We discussed returning metadata over throwing errors. Any reason to still throw errors here. If we're doing this we at least need to start throwing catchable errors so you can handle the errors correctly
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure what makes this error uncatchable, but I changed it to a context object.
|
|
||
| // TODO: confirm that the `signingCertificate.subject` is the DN of the RP | ||
| if (payload.sub !== accessCertificate.subject) { | ||
| throw new Error('The Subject of the Authorization Attestation should equal the distinguished of the Relying Party') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| throw new Error('The Subject of the Authorization Attestation should equal the distinguished of the Relying Party') | |
| throw new Error('The Subject of the Authorization Attestation should equal the distinguished name of the Relying Party') |
| }) | ||
|
|
||
| if (!isValid) { | ||
| throw new Error( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it would be good to have a cause here, to not swallow the error.
| * | ||
| * 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 `__disclosurePolicy` key |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we include something that identifies it as eudi in the keyname
|
|
||
| export type AuthzPolicy = AllowListPolicy | RootOfTrustPolicy | AttributeBasedAccessControlPolicy | ||
|
|
||
| type VerifyAuthorizationForOpenid4VpAuthorizationRequestOptions = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| type VerifyAuthorizationForOpenid4VpAuthorizationRequestOptions = { | |
| export type VerifyAuthorizationForOpenid4VpAuthorizationRequestOptions = { |
| import { type RootOfTrustPolicy, validateRootOfTrustPolicy } from './validateRootOfTrustPolicy' | ||
| import { isAuthorizationAttestation } from './verifyAuthorizationAttestation' | ||
|
|
||
| export type AuthzPolicy = AllowListPolicy | RootOfTrustPolicy | AttributeBasedAccessControlPolicy |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We generally don't use abbreviations
| export type AuthzPolicy = AllowListPolicy | RootOfTrustPolicy | AttributeBasedAccessControlPolicy | |
| export type AuthorizationPolicy = AllowListPolicy | RootOfTrustPolicy | AttributeBasedAccessControlPolicy |
| 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(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any way that we could reuse the DCQL query type, or do we really need to redefine this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe, if they are both in zod otherwise I think it would add unnecessary complexity.
| if (typeof va.data !== 'string') { | ||
| throw new Error('Only inline data supported') | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
data can also be an object, in which case the error is a bit confusing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Took a quick look, still need to do a thorough review. I'm missing some of the returning context instead of throwing errors that we discussed.
|
I wanted to handle the errors with context in a different pr, as it also includes the registration certificate verification, but I can also do it here. |
|
Added a return context to the disclosurePolicy validation: export type VerifyDisclosurePoliciesForOpenId4VpAuthorizationRequestReturnContext = {
isValid: boolean
isSignedWithX509: boolean
disclosurePolicies: {
[credentialId: string]: {
isAllowListPolicyValid?: boolean
isRootOfTrustPolicyValid?: boolean
isAttributeBasedAccessControlValid?: boolean
}
}
}I think this provides enough context for a wallet to display information to the user about the request, lmk if something is missing. |
Signed-off-by: Berend Sliedrecht <[email protected]>
Signed-off-by: Berend Sliedrecht <[email protected]>
| strictEqual(result?.isValid, false) | ||
| equal(result?.isRegistrationCertificateQueryEqualOrSubsetOfAuthorizationRequestQuery, false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| strictEqual(result?.isValid, false) | |
| equal(result?.isRegistrationCertificateQueryEqualOrSubsetOfAuthorizationRequestQuery, false) | |
| strictEqual(result.isValid, false) | |
| equal(result.isRegistrationCertificateQueryEqualOrSubsetOfAuthorizationRequestQuery, false) |
|
|
||
| await rejects( | ||
| verifyOpenid4VpAuthorizationRequest(agent.context, { | ||
| verifyRegistrationCertificateInOpenid4VpAuthorizationRequest(agent.context, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When does it throw vs return return invalid?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left some comments. Overall looks good, but we need a bit more consistency in when we throw errors vs return isValid: false
|
|
||
| equal(result?.[0].isValidAndTrusted, false) | ||
| equal(result?.[0].isValidButUntrusted, true) | ||
| equal(result?.isValid, true) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| equal(result?.isValid, true) | |
| equal(result.isValid, true) |
| 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') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we create a specific error for this library? So we can catch error as being an EUDI verification error?
| registrationCertificateHeaderSchema.parse(jwt.header) | ||
| const parsedPayload = registrationCertificatePayloadSchema.parse(jwt.payload.toJson()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel this should also be part of the returned context, (and we should add an human error message there).
It's very random now:
- JWS signature verification fails -> return
isValid: false - registration certificate header schema parsing fails -> throw error
| const rpCert = X509Certificate.fromEncodedCertificate(rpCertEncoded) | ||
|
|
||
| if (rpCert.subject !== parsedPayload.sub) { | ||
| returnContext.isAccessCertificateSubjectEqualToRegistrationCertificate = false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should also set these to true if they did succeed, so you know which checks were performed.
| const isValidDcqlQuery = isDcqlQueryEqualOrSubset(dcql.queryResult, parsedPayload as unknown as DcqlQuery) | ||
| if (!isValidDcqlQuery) { | ||
| returnContext.isValid = false | ||
| returnContext.isRegistrationCertificateQueryEqualOrSubsetOfAuthorizationRequestQuery = false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't this with a JSDoc comment be sufficient?
| returnContext.isRegistrationCertificateQueryEqualOrSubsetOfAuthorizationRequestQuery = false | |
| returnContext.isAuthorizationRequestQueryValid = false |
Comment could be like:
/**
* Returns whether the authorization request query is equal or a subset of the registration certificate query
*/
|
|
||
| // TODO: | ||
| // - Should we support more hashing confirmation claim values? | ||
| const authorizationAttestationPayloadSchema = z.object({ |
There was a problem hiding this comment.
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
| // 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 |
There was a problem hiding this comment.
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
| const queryResult = Query.query( | ||
| Query.parse(attributeBasedAccessControlPolicy.attribute_based_access_control), | ||
| credentials | ||
| ) |
There was a problem hiding this comment.
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?