Skip to content

Commit

Permalink
feat: dcql alpha
Browse files Browse the repository at this point in the history
Signed-off-by: Martin Auer <martin.auer97@gmail.com>
  • Loading branch information
auer-martin committed Nov 21, 2024
1 parent e03d204 commit af9fad3
Show file tree
Hide file tree
Showing 21 changed files with 2,301 additions and 2,700 deletions.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"dependencies": {
"@digitalcredentials/jsonld": "^6.0.0",
"dcql": "^0.2.8",
"@digitalcredentials/jsonld-signatures": "^9.4.0",
"@digitalcredentials/vc": "^6.0.1",
"@multiformats/base-x": "^4.0.1",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/agent/AgentModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BasicMessagesModule } from '../modules/basic-messages'
import { CacheModule } from '../modules/cache'
import { ConnectionsModule } from '../modules/connections'
import { CredentialsModule } from '../modules/credentials'
import { DcqlModule } from '../modules/dcql'
import { DidsModule } from '../modules/dids'
import { DifPresentationExchangeModule } from '../modules/dif-presentation-exchange'
import { DiscoverFeaturesModule } from '../modules/discover-features'
Expand Down Expand Up @@ -136,6 +137,7 @@ function getDefaultAgentModules() {
w3cCredentials: () => new W3cCredentialsModule(),
cache: () => new CacheModule(),
pex: () => new DifPresentationExchangeModule(),
dcql: () => new DcqlModule(),
sdJwtVc: () => new SdJwtVcModule(),
x509: () => new X509Module(),
mdoc: () => new MdocModule(),
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/agent/__tests__/AgentModules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BasicMessagesModule } from '../../modules/basic-messages'
import { CacheModule } from '../../modules/cache'
import { ConnectionsModule } from '../../modules/connections'
import { CredentialsModule } from '../../modules/credentials'
import { DcqlModule } from '../../modules/dcql'
import { DidsModule } from '../../modules/dids'
import { DifPresentationExchangeModule } from '../../modules/dif-presentation-exchange'
import { DiscoverFeaturesModule } from '../../modules/discover-features'
Expand Down Expand Up @@ -67,6 +68,7 @@ describe('AgentModules', () => {
messagePickup: expect.any(MessagePickupModule),
basicMessages: expect.any(BasicMessagesModule),
pex: expect.any(DifPresentationExchangeModule),
dcql: expect.any(DcqlModule),
genericRecords: expect.any(GenericRecordsModule),
discovery: expect.any(DiscoverFeaturesModule),
dids: expect.any(DidsModule),
Expand Down Expand Up @@ -95,6 +97,7 @@ describe('AgentModules', () => {
messagePickup: expect.any(MessagePickupModule),
basicMessages: expect.any(BasicMessagesModule),
pex: expect.any(DifPresentationExchangeModule),
dcql: expect.any(DcqlModule),
genericRecords: expect.any(GenericRecordsModule),
discovery: expect.any(DiscoverFeaturesModule),
dids: expect.any(DidsModule),
Expand Down Expand Up @@ -126,6 +129,7 @@ describe('AgentModules', () => {
messagePickup: expect.any(MessagePickupModule),
basicMessages: expect.any(BasicMessagesModule),
pex: expect.any(DifPresentationExchangeModule),
dcql: expect.any(DcqlModule),
genericRecords: expect.any(GenericRecordsModule),
discovery: expect.any(DiscoverFeaturesModule),
dids: expect.any(DidsModule),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export * from './modules/cache'
export * from './modules/dif-presentation-exchange'
export * from './modules/sd-jwt-vc'
export * from './modules/mdoc'
export * from './modules/dcql'
export {
JsonEncoder,
JsonTransformer,
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/modules/dcql/DcqlError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { CredoError } from '../../error'

export class DcqlError extends CredoError {
public additionalMessages?: Array<string>

public constructor(
message: string,
{ cause, additionalMessages }: { cause?: Error; additionalMessages?: Array<string> } = {}
) {
super(message, { cause })
this.additionalMessages = additionalMessages
}
}
25 changes: 25 additions & 0 deletions packages/core/src/modules/dcql/DcqlModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { DependencyManager, Module } from '../../plugins'

import { AgentConfig } from '../../agent/AgentConfig'

import { DcqlService } from './DcqlService'

/**
* @public
*/
export class DcqlModule implements Module {
/**
* Registers the dependencies of the presentation-exchange module on the dependency manager.
*/
public register(dependencyManager: DependencyManager) {
// Warn about experimental module
dependencyManager
.resolve(AgentConfig)
.logger.warn(
"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."
)

// service
dependencyManager.registerSingleton(DcqlService)
}
}
277 changes: 277 additions & 0 deletions packages/core/src/modules/dcql/DcqlService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import type { AgentContext } from '../../agent'

import { DcqlCredentialRepresentation, DcqlMdocRepresentation, DcqlQuery, DcqlSdJwtVcRepresentation } from 'dcql'
import { injectable } from 'tsyringe'

import { JsonValue } from '../../types'
import { Mdoc, MdocApi, MdocDeviceResponse, MdocOpenId4VpSessionTranscriptOptions, MdocRecord } from '../mdoc'
import { IPresentationFrame, SdJwtVcApi, SdJwtVcRecord } from '../sd-jwt-vc'
import {
ClaimFormat,
W3cCredentialRecord,
W3cCredentialRepository,
W3cJsonLdVerifiablePresentation,
W3cJwtVerifiablePresentation,
} from '../vc'

import { DcqlError } from './DcqlError'
import { DcqlQueryResult, DcqlCredentialsForRequest, DcqlPresentationRecord } from './models'
import { dcqlGetPresentationsToCreate } from './utils'

/**
* @todo create a public api for using dif presentation exchange
*/
@injectable()
export class DcqlService {
/**
* Queries the wallet for credentials that match the given presentation definition. This only does an initial query based on the
* schema of the input descriptors. It does not do any further filtering based on the constraints in the input descriptors.
*/
private async queryCredentialForPresentationDefinition(
agentContext: AgentContext,
dcqlQuery: DcqlQuery
): Promise<Array<SdJwtVcRecord | W3cCredentialRecord | MdocRecord>> {
const w3cCredentialRepository = agentContext.dependencyManager.resolve(W3cCredentialRepository)

const formats = new Set(dcqlQuery.credentials.map((c) => c.format))
for (const format of formats) {
if (format !== 'vc+sd-jwt' && format !== 'jwt_vc_json' && format !== 'jwt_vc_json-ld' && format !== 'mso_mdoc') {
throw new DcqlError(`Unsupported credential format ${format}.`)
}
}

const allRecords: Array<SdJwtVcRecord | W3cCredentialRecord | MdocRecord> = []

// query the wallet ourselves first to avoid the need to query the pex library for all
// credentials for every proof request
const w3cCredentialRecords =
formats.has('jwt_vc_json') || formats.has('jwt_vc_json-ld')
? await w3cCredentialRepository.getAll(agentContext)
: []
allRecords.push(...w3cCredentialRecords)

const sdJwtVcApi = this.getSdJwtVcApi(agentContext)
const sdJwtVcRecords = formats.has('vc+sd-jwt') ? await sdJwtVcApi.getAll() : []
allRecords.push(...sdJwtVcRecords)

const mdocApi = this.getMdocApi(agentContext)
const mdocRecords = formats.has('mso_mdoc') ? await mdocApi.getAll() : []
allRecords.push(...mdocRecords)

return allRecords
}

public async getCredentialsForRequest(agentContext: AgentContext, dcqlQuery: DcqlQuery): Promise<DcqlQueryResult> {
const credentialRecords = await this.queryCredentialForPresentationDefinition(agentContext, dcqlQuery)

const mappedCredentials: DcqlCredentialRepresentation[] = credentialRecords.map((record) => {
if (record.type === 'MdocRecord') {
return {
docType: record.getTags().docType,
namespaces: Mdoc.fromBase64Url(record.base64Url).issuerSignedNamespaces,
} satisfies DcqlMdocRepresentation
} else if (record.type === 'SdJwtVcRecord') {
return {
vct: record.getTags().vct,
claims: this.getSdJwtVcApi(agentContext).fromCompact(record.compactSdJwtVc)
.prettyClaims as DcqlSdJwtVcRepresentation.Claims,
} satisfies DcqlSdJwtVcRepresentation
} else {
// TODO:
throw new DcqlError('W3C credentials are not supported yet')
}
})

const queryResult = DcqlQuery.query(dcqlQuery, mappedCredentials)
const matchesWithRecord = Object.fromEntries(
Object.entries(queryResult.credential_matches).map(([credential_query_id, result]) => {
return [credential_query_id, { ...result, record: credentialRecords[result.credential_index] }]
})
)

return {
...queryResult,
credential_matches: matchesWithRecord,
}
}

/**
* Selects the credentials to use based on the output from `getCredentialsForRequest`
* Use this method if you don't want to manually select the credentials yourself.
*/
public selectCredentialsForRequest(dcqlQueryResult: DcqlQueryResult): DcqlCredentialsForRequest {
if (!dcqlQueryResult.canBeSatisfied) {
throw new DcqlError(
'Cannot select the credentials for the dcql query presentation if the request cannot be satisfied'
)
}

const credentials: DcqlCredentialsForRequest = {}

if (dcqlQueryResult.credential_sets) {
for (const credentialSet of dcqlQueryResult.credential_sets) {
// undefined defaults to true
if (credentialSet.required === false) continue
const firstFullFillableOption = credentialSet.options.find((option) =>
option.every((credential_id) => dcqlQueryResult.credential_matches[credential_id].success)
)

if (!firstFullFillableOption) {
throw new DcqlError('Invalid dcql query result. No option is fullfillable')
}

for (const credentialQueryId of firstFullFillableOption) {
const credential = dcqlQueryResult.credential_matches[credentialQueryId]

if (credential.success && credential.record.type === 'MdocRecord' && 'namespaces' in credential.output) {
credentials[credentialQueryId] = {
credentialRecord: credential.record,
disclosedPayload: credential.output.namespaces,
}
} else if (credential.success && credential.record.type !== 'MdocRecord' && 'claims' in credential.output) {
credentials[credentialQueryId] = {
credentialRecord: credential.record,
disclosedPayload: credential.output.claims,
}
} else {
throw new DcqlError('Invalid dcql query result. Cannot auto-select credentials')
}
}
}
} else {
for (const credentialQuery of dcqlQueryResult.credentials) {
const credential = dcqlQueryResult.credential_matches[credentialQuery.id]
if (credential.success && credential.record.type === 'MdocRecord' && 'namespaces' in credential.output) {
credentials[credentialQuery.id] = {
credentialRecord: credential.record,
disclosedPayload: credential.output.namespaces,
}
} else if (credential.success && credential.record.type !== 'MdocRecord' && 'claims' in credential.output) {
credentials[credentialQuery.id] = {
credentialRecord: credential.record,
disclosedPayload: credential.output.claims,
}
} else {
throw new DcqlError('Invalid dcql query result. Cannot auto-select credentials')
}
}
}

return credentials
}

public validateDcqlQuery(dcqlQuery: DcqlQuery.Input | DcqlQuery) {
return DcqlQuery.parse(dcqlQuery)
}

// TODO: this IS WRONG
private createPresentationFrame(obj: Record<string, JsonValue>): IPresentationFrame {
const frame: IPresentationFrame = {}

for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'object' && value !== null) {
frame[key] = true
} else {
frame[key] = !!value
}
}

return frame
}

public async createPresentationRecord(
agentContext: AgentContext,
options: {
credentialQueryToCredential: DcqlCredentialsForRequest
challenge: string
domain?: string
openid4vp?: Omit<MdocOpenId4VpSessionTranscriptOptions, 'verifierGeneratedNonce' | 'clientId'>
}
): Promise<DcqlPresentationRecord> {
const { domain, challenge, openid4vp } = options

const presentationRecord: DcqlPresentationRecord = {}

const presentationsToCreate = dcqlGetPresentationsToCreate(options.credentialQueryToCredential)
for (const [credentialQueryId, presentationToCreate] of Object.entries(presentationsToCreate)) {
if (presentationToCreate.claimFormat === ClaimFormat.MsoMdoc) {
const mdocRecord = presentationToCreate.credentialRecord
if (!openid4vp) {
throw new DcqlError('Missing openid4vp options for creating MDOC presentation.')
}

if (!domain) {
throw new DcqlError('Missing domain property for creating MDOC presentation.')
}

const { deviceResponseBase64Url } = await MdocDeviceResponse.createOpenId4VpDcqlDeviceResponse(agentContext, {
mdoc: Mdoc.fromBase64Url(mdocRecord.base64Url),
docRequest: {
itemsRequestData: {
docType: mdocRecord.getTags().docType,
nameSpaces: Object.fromEntries(
Object.entries(presentationToCreate.disclosedPayload).map(([key, value]) => {
return [key, Object.fromEntries(Object.entries(value).map(([key]) => [key, true]))]
})
),
},
},
sessionTranscriptOptions: {
...openid4vp,
clientId: domain,
verifierGeneratedNonce: challenge,
},
})

presentationRecord[credentialQueryId] = MdocDeviceResponse.fromBase64Url(deviceResponseBase64Url)
} else if (presentationToCreate.claimFormat === ClaimFormat.SdJwtVc) {
const presentationFrame = this.createPresentationFrame(presentationToCreate.disclosedPayload)

if (!domain) {
throw new DcqlError('Missing domain property for creating SdJwtVc presentation.')
}

const sdJwtVcApi = this.getSdJwtVcApi(agentContext)
const presentation = await sdJwtVcApi.present({
compactSdJwtVc: presentationToCreate.credentialRecord.compactSdJwtVc,
presentationFrame,
verifierMetadata: {
audience: domain,
nonce: challenge,
issuedAt: Math.floor(Date.now() / 1000),
},
})

presentationRecord[credentialQueryId] = sdJwtVcApi.fromCompact(presentation)
} else {
throw new DcqlError('Only MDOC presentations are supported')
}
}

return presentationRecord
}

public async getEncodedPresentationRecord(presentationRecord: DcqlPresentationRecord) {
return Object.fromEntries(
Object.entries(presentationRecord).map(([key, value]) => {
if (value instanceof MdocDeviceResponse) {
return [key, value.base64Url]
} else if (value instanceof W3cJsonLdVerifiablePresentation) {
return [key, value.toJson()]
} else if (value instanceof W3cJwtVerifiablePresentation) {
return [key, value.encoded]
} else {
return [key, value.compact]
}
})
)
}

private getSdJwtVcApi(agentContext: AgentContext) {
return agentContext.dependencyManager.resolve(SdJwtVcApi)
}

private getMdocApi(agentContext: AgentContext) {
return agentContext.dependencyManager.resolve(MdocApi)
}
}
5 changes: 5 additions & 0 deletions packages/core/src/modules/dcql/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './DcqlError'
export * from './DcqlModule'
export * from './DcqlService'
export * from './utils'
export * from './models'
Loading

0 comments on commit af9fad3

Please sign in to comment.