Skip to content

Commit acf6966

Browse files
committed
feat: dcql alpha
1 parent e03d204 commit acf6966

21 files changed

+2301
-2700
lines changed

packages/core/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
},
2727
"dependencies": {
2828
"@digitalcredentials/jsonld": "^6.0.0",
29+
"dcql": "^0.2.8",
2930
"@digitalcredentials/jsonld-signatures": "^9.4.0",
3031
"@digitalcredentials/vc": "^6.0.1",
3132
"@multiformats/base-x": "^4.0.1",

packages/core/src/agent/AgentModules.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BasicMessagesModule } from '../modules/basic-messages'
66
import { CacheModule } from '../modules/cache'
77
import { ConnectionsModule } from '../modules/connections'
88
import { CredentialsModule } from '../modules/credentials'
9+
import { DcqlModule } from '../modules/dcql'
910
import { DidsModule } from '../modules/dids'
1011
import { DifPresentationExchangeModule } from '../modules/dif-presentation-exchange'
1112
import { DiscoverFeaturesModule } from '../modules/discover-features'
@@ -136,6 +137,7 @@ function getDefaultAgentModules() {
136137
w3cCredentials: () => new W3cCredentialsModule(),
137138
cache: () => new CacheModule(),
138139
pex: () => new DifPresentationExchangeModule(),
140+
dcql: () => new DcqlModule(),
139141
sdJwtVc: () => new SdJwtVcModule(),
140142
x509: () => new X509Module(),
141143
mdoc: () => new MdocModule(),

packages/core/src/agent/__tests__/AgentModules.test.ts

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BasicMessagesModule } from '../../modules/basic-messages'
44
import { CacheModule } from '../../modules/cache'
55
import { ConnectionsModule } from '../../modules/connections'
66
import { CredentialsModule } from '../../modules/credentials'
7+
import { DcqlModule } from '../../modules/dcql'
78
import { DidsModule } from '../../modules/dids'
89
import { DifPresentationExchangeModule } from '../../modules/dif-presentation-exchange'
910
import { DiscoverFeaturesModule } from '../../modules/discover-features'
@@ -67,6 +68,7 @@ describe('AgentModules', () => {
6768
messagePickup: expect.any(MessagePickupModule),
6869
basicMessages: expect.any(BasicMessagesModule),
6970
pex: expect.any(DifPresentationExchangeModule),
71+
dcql: expect.any(DcqlModule),
7072
genericRecords: expect.any(GenericRecordsModule),
7173
discovery: expect.any(DiscoverFeaturesModule),
7274
dids: expect.any(DidsModule),
@@ -95,6 +97,7 @@ describe('AgentModules', () => {
9597
messagePickup: expect.any(MessagePickupModule),
9698
basicMessages: expect.any(BasicMessagesModule),
9799
pex: expect.any(DifPresentationExchangeModule),
100+
dcql: expect.any(DcqlModule),
98101
genericRecords: expect.any(GenericRecordsModule),
99102
discovery: expect.any(DiscoverFeaturesModule),
100103
dids: expect.any(DidsModule),
@@ -126,6 +129,7 @@ describe('AgentModules', () => {
126129
messagePickup: expect.any(MessagePickupModule),
127130
basicMessages: expect.any(BasicMessagesModule),
128131
pex: expect.any(DifPresentationExchangeModule),
132+
dcql: expect.any(DcqlModule),
129133
genericRecords: expect.any(GenericRecordsModule),
130134
discovery: expect.any(DiscoverFeaturesModule),
131135
dids: expect.any(DidsModule),

packages/core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export * from './modules/cache'
6565
export * from './modules/dif-presentation-exchange'
6666
export * from './modules/sd-jwt-vc'
6767
export * from './modules/mdoc'
68+
export * from './modules/dcql'
6869
export {
6970
JsonEncoder,
7071
JsonTransformer,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { CredoError } from '../../error'
2+
3+
export class DcqlError extends CredoError {
4+
public additionalMessages?: Array<string>
5+
6+
public constructor(
7+
message: string,
8+
{ cause, additionalMessages }: { cause?: Error; additionalMessages?: Array<string> } = {}
9+
) {
10+
super(message, { cause })
11+
this.additionalMessages = additionalMessages
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { DependencyManager, Module } from '../../plugins'
2+
3+
import { AgentConfig } from '../../agent/AgentConfig'
4+
5+
import { DcqlService } from './DcqlService'
6+
7+
/**
8+
* @public
9+
*/
10+
export class DcqlModule implements Module {
11+
/**
12+
* Registers the dependencies of the presentation-exchange module on the dependency manager.
13+
*/
14+
public register(dependencyManager: DependencyManager) {
15+
// Warn about experimental module
16+
dependencyManager
17+
.resolve(AgentConfig)
18+
.logger.warn(
19+
"The 'DcqlModule' module is experimental and could have unexpected breaking changes. When using this module, make sure to use strict versions for all @credo-ts packages."
20+
)
21+
22+
// service
23+
dependencyManager.registerSingleton(DcqlService)
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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+
}
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './DcqlError'
2+
export * from './DcqlModule'
3+
export * from './DcqlService'
4+
export * from './utils'
5+
export * from './models'

0 commit comments

Comments
 (0)