-
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?
Changes from all commits
85c0155
ff50735
193202a
faf6ee1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,6 +52,7 @@ | |
| "typescript": "~5.8.3" | ||
| }, | ||
| "dependencies": { | ||
| "dcql": "^0.2.22", | ||
| "zod": "^3.25.42" | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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) | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| } | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( So probaby the |
||
| 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({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) ?? [] |
| 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' |
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.