|
| 1 | +import type { AgentContext } from '../../agent' |
| 2 | + |
| 3 | +import { DcqlCredentialRepresentation, DcqlMdocRepresentation, DcqlQuery, DcqlSdJwtVcRepresentation } from 'dcql' |
| 4 | +import { injectable } from 'tsyringe' |
| 5 | + |
| 6 | +import { JsonValue } from '../../types' |
| 7 | +import { Mdoc, MdocApi, MdocDeviceResponse, MdocOpenId4VpSessionTranscriptOptions, MdocRecord } from '../mdoc' |
| 8 | +import { IPresentationFrame, SdJwtVcApi, SdJwtVcRecord } from '../sd-jwt-vc' |
| 9 | +import { |
| 10 | + ClaimFormat, |
| 11 | + W3cCredentialRecord, |
| 12 | + W3cCredentialRepository, |
| 13 | + W3cJsonLdVerifiablePresentation, |
| 14 | + W3cJwtVerifiablePresentation, |
| 15 | +} from '../vc' |
| 16 | + |
| 17 | +import { DcqlError } from './DcqlError' |
| 18 | +import { DcqlQueryResult, DcqlCredentialsForRequest, DcqlPresentationRecord } from './models' |
| 19 | +import { dcqlGetPresentationsToCreate } from './utils' |
| 20 | + |
| 21 | +/** |
| 22 | + * @todo create a public api for using dif presentation exchange |
| 23 | + */ |
| 24 | +@injectable() |
| 25 | +export class DcqlService { |
| 26 | + /** |
| 27 | + * Queries the wallet for credentials that match the given presentation definition. This only does an initial query based on the |
| 28 | + * schema of the input descriptors. It does not do any further filtering based on the constraints in the input descriptors. |
| 29 | + */ |
| 30 | + private async queryCredentialForPresentationDefinition( |
| 31 | + agentContext: AgentContext, |
| 32 | + dcqlQuery: DcqlQuery |
| 33 | + ): Promise<Array<SdJwtVcRecord | W3cCredentialRecord | MdocRecord>> { |
| 34 | + const w3cCredentialRepository = agentContext.dependencyManager.resolve(W3cCredentialRepository) |
| 35 | + |
| 36 | + const formats = new Set(dcqlQuery.credentials.map((c) => c.format)) |
| 37 | + for (const format of formats) { |
| 38 | + if (format !== 'vc+sd-jwt' && format !== 'jwt_vc_json' && format !== 'jwt_vc_json-ld' && format !== 'mso_mdoc') { |
| 39 | + throw new DcqlError(`Unsupported credential format ${format}.`) |
| 40 | + } |
| 41 | + } |
| 42 | + |
| 43 | + const allRecords: Array<SdJwtVcRecord | W3cCredentialRecord | MdocRecord> = [] |
| 44 | + |
| 45 | + // query the wallet ourselves first to avoid the need to query the pex library for all |
| 46 | + // credentials for every proof request |
| 47 | + const w3cCredentialRecords = |
| 48 | + formats.has('jwt_vc_json') || formats.has('jwt_vc_json-ld') |
| 49 | + ? await w3cCredentialRepository.getAll(agentContext) |
| 50 | + : [] |
| 51 | + allRecords.push(...w3cCredentialRecords) |
| 52 | + |
| 53 | + const sdJwtVcApi = this.getSdJwtVcApi(agentContext) |
| 54 | + const sdJwtVcRecords = formats.has('vc+sd-jwt') ? await sdJwtVcApi.getAll() : [] |
| 55 | + allRecords.push(...sdJwtVcRecords) |
| 56 | + |
| 57 | + const mdocApi = this.getMdocApi(agentContext) |
| 58 | + const mdocRecords = formats.has('mso_mdoc') ? await mdocApi.getAll() : [] |
| 59 | + allRecords.push(...mdocRecords) |
| 60 | + |
| 61 | + return allRecords |
| 62 | + } |
| 63 | + |
| 64 | + public async getCredentialsForRequest(agentContext: AgentContext, dcqlQuery: DcqlQuery): Promise<DcqlQueryResult> { |
| 65 | + const credentialRecords = await this.queryCredentialForPresentationDefinition(agentContext, dcqlQuery) |
| 66 | + |
| 67 | + const mappedCredentials: DcqlCredentialRepresentation[] = credentialRecords.map((record) => { |
| 68 | + if (record.type === 'MdocRecord') { |
| 69 | + return { |
| 70 | + docType: record.getTags().docType, |
| 71 | + namespaces: Mdoc.fromBase64Url(record.base64Url).issuerSignedNamespaces, |
| 72 | + } satisfies DcqlMdocRepresentation |
| 73 | + } else if (record.type === 'SdJwtVcRecord') { |
| 74 | + return { |
| 75 | + vct: record.getTags().vct, |
| 76 | + claims: this.getSdJwtVcApi(agentContext).fromCompact(record.compactSdJwtVc) |
| 77 | + .prettyClaims as DcqlSdJwtVcRepresentation.Claims, |
| 78 | + } satisfies DcqlSdJwtVcRepresentation |
| 79 | + } else { |
| 80 | + // TODO: |
| 81 | + throw new DcqlError('W3C credentials are not supported yet') |
| 82 | + } |
| 83 | + }) |
| 84 | + |
| 85 | + const queryResult = DcqlQuery.query(dcqlQuery, mappedCredentials) |
| 86 | + const matchesWithRecord = Object.fromEntries( |
| 87 | + Object.entries(queryResult.credential_matches).map(([credential_query_id, result]) => { |
| 88 | + return [credential_query_id, { ...result, record: credentialRecords[result.credential_index] }] |
| 89 | + }) |
| 90 | + ) |
| 91 | + |
| 92 | + return { |
| 93 | + ...queryResult, |
| 94 | + credential_matches: matchesWithRecord, |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + /** |
| 99 | + * Selects the credentials to use based on the output from `getCredentialsForRequest` |
| 100 | + * Use this method if you don't want to manually select the credentials yourself. |
| 101 | + */ |
| 102 | + public selectCredentialsForRequest(dcqlQueryResult: DcqlQueryResult): DcqlCredentialsForRequest { |
| 103 | + if (!dcqlQueryResult.canBeSatisfied) { |
| 104 | + throw new DcqlError( |
| 105 | + 'Cannot select the credentials for the dcql query presentation if the request cannot be satisfied' |
| 106 | + ) |
| 107 | + } |
| 108 | + |
| 109 | + const credentials: DcqlCredentialsForRequest = {} |
| 110 | + |
| 111 | + if (dcqlQueryResult.credential_sets) { |
| 112 | + for (const credentialSet of dcqlQueryResult.credential_sets) { |
| 113 | + // undefined defaults to true |
| 114 | + if (credentialSet.required === false) continue |
| 115 | + const firstFullFillableOption = credentialSet.options.find((option) => |
| 116 | + option.every((credential_id) => dcqlQueryResult.credential_matches[credential_id].success) |
| 117 | + ) |
| 118 | + |
| 119 | + if (!firstFullFillableOption) { |
| 120 | + throw new DcqlError('Invalid dcql query result. No option is fullfillable') |
| 121 | + } |
| 122 | + |
| 123 | + for (const credentialQueryId of firstFullFillableOption) { |
| 124 | + const credential = dcqlQueryResult.credential_matches[credentialQueryId] |
| 125 | + |
| 126 | + if (credential.success && credential.record.type === 'MdocRecord' && 'namespaces' in credential.output) { |
| 127 | + credentials[credentialQueryId] = { |
| 128 | + credentialRecord: credential.record, |
| 129 | + disclosedPayload: credential.output.namespaces, |
| 130 | + } |
| 131 | + } else if (credential.success && credential.record.type !== 'MdocRecord' && 'claims' in credential.output) { |
| 132 | + credentials[credentialQueryId] = { |
| 133 | + credentialRecord: credential.record, |
| 134 | + disclosedPayload: credential.output.claims, |
| 135 | + } |
| 136 | + } else { |
| 137 | + throw new DcqlError('Invalid dcql query result. Cannot auto-select credentials') |
| 138 | + } |
| 139 | + } |
| 140 | + } |
| 141 | + } else { |
| 142 | + for (const credentialQuery of dcqlQueryResult.credentials) { |
| 143 | + const credential = dcqlQueryResult.credential_matches[credentialQuery.id] |
| 144 | + if (credential.success && credential.record.type === 'MdocRecord' && 'namespaces' in credential.output) { |
| 145 | + credentials[credentialQuery.id] = { |
| 146 | + credentialRecord: credential.record, |
| 147 | + disclosedPayload: credential.output.namespaces, |
| 148 | + } |
| 149 | + } else if (credential.success && credential.record.type !== 'MdocRecord' && 'claims' in credential.output) { |
| 150 | + credentials[credentialQuery.id] = { |
| 151 | + credentialRecord: credential.record, |
| 152 | + disclosedPayload: credential.output.claims, |
| 153 | + } |
| 154 | + } else { |
| 155 | + throw new DcqlError('Invalid dcql query result. Cannot auto-select credentials') |
| 156 | + } |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + return credentials |
| 161 | + } |
| 162 | + |
| 163 | + public validateDcqlQuery(dcqlQuery: DcqlQuery.Input | DcqlQuery) { |
| 164 | + return DcqlQuery.parse(dcqlQuery) |
| 165 | + } |
| 166 | + |
| 167 | + // TODO: this IS WRONG |
| 168 | + private createPresentationFrame(obj: Record<string, JsonValue>): IPresentationFrame { |
| 169 | + const frame: IPresentationFrame = {} |
| 170 | + |
| 171 | + for (const [key, value] of Object.entries(obj)) { |
| 172 | + if (typeof value === 'object' && value !== null) { |
| 173 | + frame[key] = true |
| 174 | + } else { |
| 175 | + frame[key] = !!value |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + return frame |
| 180 | + } |
| 181 | + |
| 182 | + public async createPresentationRecord( |
| 183 | + agentContext: AgentContext, |
| 184 | + options: { |
| 185 | + credentialQueryToCredential: DcqlCredentialsForRequest |
| 186 | + challenge: string |
| 187 | + domain?: string |
| 188 | + openid4vp?: Omit<MdocOpenId4VpSessionTranscriptOptions, 'verifierGeneratedNonce' | 'clientId'> |
| 189 | + } |
| 190 | + ): Promise<DcqlPresentationRecord> { |
| 191 | + const { domain, challenge, openid4vp } = options |
| 192 | + |
| 193 | + const presentationRecord: DcqlPresentationRecord = {} |
| 194 | + |
| 195 | + const presentationsToCreate = dcqlGetPresentationsToCreate(options.credentialQueryToCredential) |
| 196 | + for (const [credentialQueryId, presentationToCreate] of Object.entries(presentationsToCreate)) { |
| 197 | + if (presentationToCreate.claimFormat === ClaimFormat.MsoMdoc) { |
| 198 | + const mdocRecord = presentationToCreate.credentialRecord |
| 199 | + if (!openid4vp) { |
| 200 | + throw new DcqlError('Missing openid4vp options for creating MDOC presentation.') |
| 201 | + } |
| 202 | + |
| 203 | + if (!domain) { |
| 204 | + throw new DcqlError('Missing domain property for creating MDOC presentation.') |
| 205 | + } |
| 206 | + |
| 207 | + const { deviceResponseBase64Url } = await MdocDeviceResponse.createOpenId4VpDcqlDeviceResponse(agentContext, { |
| 208 | + mdoc: Mdoc.fromBase64Url(mdocRecord.base64Url), |
| 209 | + docRequest: { |
| 210 | + itemsRequestData: { |
| 211 | + docType: mdocRecord.getTags().docType, |
| 212 | + nameSpaces: Object.fromEntries( |
| 213 | + Object.entries(presentationToCreate.disclosedPayload).map(([key, value]) => { |
| 214 | + return [key, Object.fromEntries(Object.entries(value).map(([key]) => [key, true]))] |
| 215 | + }) |
| 216 | + ), |
| 217 | + }, |
| 218 | + }, |
| 219 | + sessionTranscriptOptions: { |
| 220 | + ...openid4vp, |
| 221 | + clientId: domain, |
| 222 | + verifierGeneratedNonce: challenge, |
| 223 | + }, |
| 224 | + }) |
| 225 | + |
| 226 | + presentationRecord[credentialQueryId] = MdocDeviceResponse.fromBase64Url(deviceResponseBase64Url) |
| 227 | + } else if (presentationToCreate.claimFormat === ClaimFormat.SdJwtVc) { |
| 228 | + const presentationFrame = this.createPresentationFrame(presentationToCreate.disclosedPayload) |
| 229 | + |
| 230 | + if (!domain) { |
| 231 | + throw new DcqlError('Missing domain property for creating SdJwtVc presentation.') |
| 232 | + } |
| 233 | + |
| 234 | + const sdJwtVcApi = this.getSdJwtVcApi(agentContext) |
| 235 | + const presentation = await sdJwtVcApi.present({ |
| 236 | + compactSdJwtVc: presentationToCreate.credentialRecord.compactSdJwtVc, |
| 237 | + presentationFrame, |
| 238 | + verifierMetadata: { |
| 239 | + audience: domain, |
| 240 | + nonce: challenge, |
| 241 | + issuedAt: Math.floor(Date.now() / 1000), |
| 242 | + }, |
| 243 | + }) |
| 244 | + |
| 245 | + presentationRecord[credentialQueryId] = sdJwtVcApi.fromCompact(presentation) |
| 246 | + } else { |
| 247 | + throw new DcqlError('Only MDOC presentations are supported') |
| 248 | + } |
| 249 | + } |
| 250 | + |
| 251 | + return presentationRecord |
| 252 | + } |
| 253 | + |
| 254 | + public async getEncodedPresentationRecord(presentationRecord: DcqlPresentationRecord) { |
| 255 | + return Object.fromEntries( |
| 256 | + Object.entries(presentationRecord).map(([key, value]) => { |
| 257 | + if (value instanceof MdocDeviceResponse) { |
| 258 | + return [key, value.base64Url] |
| 259 | + } else if (value instanceof W3cJsonLdVerifiablePresentation) { |
| 260 | + return [key, value.toJson()] |
| 261 | + } else if (value instanceof W3cJwtVerifiablePresentation) { |
| 262 | + return [key, value.encoded] |
| 263 | + } else { |
| 264 | + return [key, value.compact] |
| 265 | + } |
| 266 | + }) |
| 267 | + ) |
| 268 | + } |
| 269 | + |
| 270 | + private getSdJwtVcApi(agentContext: AgentContext) { |
| 271 | + return agentContext.dependencyManager.resolve(SdJwtVcApi) |
| 272 | + } |
| 273 | + |
| 274 | + private getMdocApi(agentContext: AgentContext) { |
| 275 | + return agentContext.dependencyManager.resolve(MdocApi) |
| 276 | + } |
| 277 | +} |
0 commit comments