Skip to content

Commit 7904088

Browse files
authored
feat: support multi-presentation submission for transaction data (dcql multiple feature) (#82)
Signed-off-by: Timo Glastra <timo@animo.id>
1 parent 5cdd900 commit 7904088

7 files changed

Lines changed: 164 additions & 85 deletions

File tree

.changeset/better-donuts-help.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@openid4vc/openid4vp": minor
3+
---
4+
5+
feat: support multi-presentation submission for transaction data (dcql multiple feature)

packages/openid4vp/src/Openid4vpVerifier.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ export class Openid4vpVerifier {
6060
return parseTransactionData(options)
6161
}
6262

63+
/**
64+
* Verify transaction data against submitted credentials.
65+
*
66+
* NOTE: this expects transaction data based authorization based on hashes. This is the method defined
67+
* for SD-JWT VC, but for mDOCs it's much more generic. If you're using transaction data with mDOCs based
68+
* on hashes, you can extract the values from the DeviceResponse, otherwise you must verify the transaction data
69+
* manually.
70+
*/
6371
public verifyTransactionData(options: Omit<VerifyTransactionDataOptions, 'callbacks'>) {
6472
return verifyTransactionData({
6573
...options,

packages/openid4vp/src/transaction-data/verify-transaction-data.test.mts

Lines changed: 94 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,41 @@ describe('Verify transaction data', () => {
1717
callbacks,
1818
transactionData: [transactionData],
1919
credentials: {
20-
one: {
21-
transaction_data_hashes: [
22-
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
23-
],
24-
transaction_data_hashes_alg: 'sha-256',
25-
},
20+
one: [
21+
{
22+
transaction_data_hashes: [
23+
'random',
24+
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
25+
],
26+
transaction_data_hashes_alg: 'sha-256',
27+
},
28+
{
29+
transaction_data_hashes: [
30+
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
31+
],
32+
transaction_data_hashes_alg: 'sha-256',
33+
},
34+
],
2635
},
2736
})
2837

2938
expect(verifiedMatches).toEqual([
3039
{
31-
credentialHashIndex: 0,
3240
credentialId: 'one',
33-
hash: Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
34-
hashAlg: 'sha-256',
41+
presentations: [
42+
{
43+
presentationIndex: 0,
44+
credentialHashIndex: 1,
45+
hash: Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
46+
hashAlg: 'sha-256',
47+
},
48+
{
49+
presentationIndex: 1,
50+
credentialHashIndex: 0,
51+
hash: Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
52+
hashAlg: 'sha-256',
53+
},
54+
],
3555
transactionDataEntry: {
3656
transactionDataIndex: 0,
3757
encoded: transactionData,
@@ -45,7 +65,7 @@ describe('Verify transaction data', () => {
4565
])
4666
})
4767

48-
test('succesfully matches with multiple credential credential and multiple transaction data entries', async () => {
68+
test('succesfully matches with multiple credentials and multiple transaction data entries', async () => {
4969
const transactionData = Buffer.from(
5070
JSON.stringify({
5171
type: 'qes',
@@ -65,27 +85,36 @@ describe('Verify transaction data', () => {
6585
callbacks,
6686
transactionData: [transactionData, transactionData2],
6787
credentials: {
68-
three: {
69-
transaction_data_hashes: [
70-
Buffer.from(callbacks.hash(Buffer.from(transactionData2), HashAlgorithm.Sha256)).toString('base64url'),
71-
],
72-
},
73-
two: {
74-
transaction_data_hashes: [
75-
'random',
76-
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
77-
],
78-
transaction_data_hashes_alg: 'sha-256',
79-
},
88+
three: [
89+
{
90+
transaction_data_hashes: [
91+
Buffer.from(callbacks.hash(Buffer.from(transactionData2), HashAlgorithm.Sha256)).toString('base64url'),
92+
],
93+
},
94+
],
95+
two: [
96+
{
97+
transaction_data_hashes: [
98+
'random',
99+
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
100+
],
101+
transaction_data_hashes_alg: 'sha-256',
102+
},
103+
],
80104
},
81105
})
82106

83107
expect(verifiedMatches).toEqual([
84108
{
85-
credentialHashIndex: 1,
86109
credentialId: 'two',
87-
hash: Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
88-
hashAlg: 'sha-256',
110+
presentations: [
111+
{
112+
presentationIndex: 0,
113+
credentialHashIndex: 1,
114+
hash: Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
115+
hashAlg: 'sha-256',
116+
},
117+
],
89118
transactionDataEntry: {
90119
transactionDataIndex: 0,
91120
encoded: transactionData,
@@ -97,10 +126,17 @@ describe('Verify transaction data', () => {
97126
},
98127
},
99128
{
100-
credentialHashIndex: 0,
101129
credentialId: 'three',
102-
hash: Buffer.from(callbacks.hash(Buffer.from(transactionData2), HashAlgorithm.Sha256)).toString('base64url'),
103-
hashAlg: 'sha-256',
130+
presentations: [
131+
{
132+
presentationIndex: 0,
133+
credentialHashIndex: 0,
134+
hash: Buffer.from(callbacks.hash(Buffer.from(transactionData2), HashAlgorithm.Sha256)).toString(
135+
'base64url'
136+
),
137+
hashAlg: 'sha-256',
138+
},
139+
],
104140
transactionDataEntry: {
105141
transactionDataIndex: 1,
106142
encoded: transactionData2,
@@ -134,20 +170,22 @@ describe('Verify transaction data', () => {
134170
callbacks,
135171
transactionData: [transactionData, transactionData2],
136172
credentials: {
137-
two: {
138-
transaction_data_hashes: [
139-
'random',
140-
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
141-
],
142-
transaction_data_hashes_alg: 'sha-256',
143-
},
173+
two: [
174+
{
175+
transaction_data_hashes: [
176+
'random',
177+
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
178+
],
179+
transaction_data_hashes_alg: 'sha-256',
180+
},
181+
],
144182
},
145183
})
146184
).rejects.toThrow(
147185
new Oauth2ServerErrorResponseError({
148186
error: Oauth2ErrorCodes.InvalidTransactionData,
149187
error_description:
150-
'Transaction data entry with index 1 does not have a matching hash in any of the submitted credentials',
188+
'Transaction data entry with index 1 for presentation two with index 0 does not have a matching hash in the transaction_data_hashes',
151189
})
152190
)
153191
})
@@ -173,20 +211,22 @@ describe('Verify transaction data', () => {
173211
callbacks,
174212
transactionData: [transactionData, transactionData2],
175213
credentials: {
176-
two: {
177-
transaction_data_hashes: [
178-
'random',
179-
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
180-
],
181-
transaction_data_hashes_alg: 'random',
182-
},
214+
two: [
215+
{
216+
transaction_data_hashes: [
217+
'random',
218+
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
219+
],
220+
transaction_data_hashes_alg: 'random',
221+
},
222+
],
183223
},
184224
})
185225
).rejects.toThrow(
186226
new Oauth2ServerErrorResponseError({
187227
error: Oauth2ErrorCodes.InvalidTransactionData,
188228
error_description:
189-
"Transaction data entry with index 0 is hashed using alg 'random'. However transaction data only allows alg values sha-256.",
229+
"Transaction data entry with index 0 for presentation two with index 0 is hashed using alg 'random'. However transaction data only allows alg values sha-256.",
190230
})
191231
)
192232
})
@@ -212,20 +252,22 @@ describe('Verify transaction data', () => {
212252
callbacks,
213253
transactionData: [transactionData, transactionData2],
214254
credentials: {
215-
two: {
216-
transaction_data_hashes: [
217-
'random',
218-
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
219-
],
220-
transaction_data_hashes_alg: 'random',
221-
},
255+
two: [
256+
{
257+
transaction_data_hashes: [
258+
'random',
259+
Buffer.from(callbacks.hash(Buffer.from(transactionData), HashAlgorithm.Sha256)).toString('base64url'),
260+
],
261+
transaction_data_hashes_alg: 'random',
262+
},
263+
],
222264
},
223265
})
224266
).rejects.toThrow(
225267
new Oauth2ServerErrorResponseError({
226268
error: Oauth2ErrorCodes.InvalidTransactionData,
227269
error_description:
228-
"Transaction data entry with index 0 is hashed using unsupported alg 'random'. This library only supports verification of transaction data hashes using alg values sha-256, sha-384, sha-512. Either verify the hashes outside of this library, or limit the allowed alg values to the ones supported by this library.",
270+
"Transaction data entry with index 0 for presentation two with index 0 is hashed using unsupported alg 'random'. This library only supports verification of transaction data hashes using alg values sha-256, sha-384, sha-512. Either verify the hashes outside of this library, or limit the allowed alg values to the ones supported by this library.",
229271
})
230272
)
231273
})

packages/openid4vp/src/transaction-data/verify-transaction-data.ts

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@ import {
44
Oauth2ErrorCodes,
55
Oauth2ServerErrorResponseError,
66
} from '@openid4vc/oauth2'
7-
import { decodeUtf8String, encodeToBase64Url } from '@openid4vc/utils'
7+
import { type NonEmptyArray, decodeUtf8String, encodeToBase64Url } from '@openid4vc/utils'
88
import { type ParsedTransactionDataEntry, parseTransactionData } from './parse-transaction-data'
99

1010
export interface TransactionDataHashesCredentials {
1111
/**
1212
* credentialId is the pex input descriptor id
13-
* or dcql credential query id
13+
* or dcql credential query id.
1414
*
1515
* The values must be an array of transaction data hashes
1616
*/
1717
[credentialId: string]:
18-
| {
18+
| NonEmptyArray<{
1919
/**
2020
* The hashes of the transaction data
2121
*/
@@ -27,7 +27,7 @@ export interface TransactionDataHashesCredentials {
2727
* is used.
2828
*/
2929
transaction_data_hashes_alg?: string
30-
}
30+
}>
3131
| undefined
3232
}
3333

@@ -61,9 +61,13 @@ export async function verifyTransactionData(
6161
export interface VerifiedTransactionDataEntry {
6262
transactionDataEntry: ParsedTransactionDataEntry
6363
credentialId: string
64-
hash: string
65-
hashAlg: HashAlgorithm
66-
credentialHashIndex: number
64+
65+
presentations: NonEmptyArray<{
66+
presentationIndex: number
67+
hash: string
68+
hashAlg: HashAlgorithm
69+
credentialHashIndex: number
70+
}>
6771
}
6872

6973
async function verifyTransactionDataEntry({
@@ -86,36 +90,53 @@ async function verifyTransactionDataEntry({
8690
}
8791

8892
for (const credentialId of entry.transactionData.credential_ids) {
89-
const transactionDataHashesCredential = credentials[credentialId]
90-
if (!transactionDataHashesCredential) continue
93+
const transactionDataHashesCredentials = credentials[credentialId]
94+
if (!transactionDataHashesCredentials) continue
9195

92-
const alg = transactionDataHashesCredential.transaction_data_hashes_alg ?? 'sha-256'
93-
const hash = hashes[alg as HashAlgorithm]
96+
const presentations: VerifiedTransactionDataEntry['presentations'][number][] = []
9497

95-
if (!allowedAlgs.includes(alg)) {
96-
throw new Oauth2ServerErrorResponseError({
97-
error: Oauth2ErrorCodes.InvalidTransactionData,
98-
error_description: `Transaction data entry with index ${entry.transactionDataIndex} is hashed using alg '${alg}'. However transaction data only allows alg values ${allowedAlgs.join(', ')}.`,
99-
})
100-
}
98+
for (const transactionDataHashesCredential of transactionDataHashesCredentials) {
99+
const alg = transactionDataHashesCredential.transaction_data_hashes_alg ?? 'sha-256'
100+
const hash = hashes[alg as HashAlgorithm]
101+
const presentationIndex = transactionDataHashesCredentials.indexOf(transactionDataHashesCredential)
101102

102-
// This is an error of this library.
103-
if (!hash) {
104-
throw new Oauth2ServerErrorResponseError({
105-
error: Oauth2ErrorCodes.InvalidTransactionData,
106-
error_description: `Transaction data entry with index ${entry.transactionDataIndex} is hashed using unsupported alg '${alg}'. This library only supports verification of transaction data hashes using alg values ${Object.values(HashAlgorithm).join(', ')}. Either verify the hashes outside of this library, or limit the allowed alg values to the ones supported by this library.`,
107-
})
108-
}
103+
if (!allowedAlgs.includes(alg)) {
104+
throw new Oauth2ServerErrorResponseError({
105+
error: Oauth2ErrorCodes.InvalidTransactionData,
106+
error_description: `Transaction data entry with index ${entry.transactionDataIndex} for presentation ${credentialId} with index ${presentationIndex} is hashed using alg '${alg}'. However transaction data only allows alg values ${allowedAlgs.join(', ')}.`,
107+
})
108+
}
109+
110+
if (!hash) {
111+
// This is an error of this library.
112+
throw new Oauth2ServerErrorResponseError({
113+
error: Oauth2ErrorCodes.InvalidTransactionData,
114+
error_description: `Transaction data entry with index ${entry.transactionDataIndex} for presentation ${credentialId} with index ${presentationIndex} is hashed using unsupported alg '${alg}'. This library only supports verification of transaction data hashes using alg values ${Object.values(HashAlgorithm).join(', ')}. Either verify the hashes outside of this library, or limit the allowed alg values to the ones supported by this library.`,
115+
})
116+
}
109117

110-
const credentialHashIndex = transactionDataHashesCredential.transaction_data_hashes.indexOf(hash)
111-
if (credentialHashIndex !== -1) {
112-
return {
113-
transactionDataEntry: entry,
114-
credentialId,
118+
const credentialHashIndex = transactionDataHashesCredential.transaction_data_hashes.indexOf(hash)
119+
120+
if (credentialHashIndex === -1) {
121+
// No matches were found
122+
throw new Oauth2ServerErrorResponseError({
123+
error: Oauth2ErrorCodes.InvalidTransactionData,
124+
error_description: `Transaction data entry with index ${entry.transactionDataIndex} for presentation ${credentialId} with index ${presentationIndex} does not have a matching hash in the transaction_data_hashes`,
125+
})
126+
}
127+
128+
presentations.push({
129+
credentialHashIndex,
115130
hash,
116131
hashAlg: alg as HashAlgorithm,
117-
credentialHashIndex,
118-
}
132+
presentationIndex,
133+
})
134+
}
135+
136+
return {
137+
transactionDataEntry: entry,
138+
credentialId,
139+
presentations: presentations as VerifiedTransactionDataEntry['presentations'],
119140
}
120141
}
121142

packages/openid4vp/src/transaction-data/z-transaction-data.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ export const zTransactionEntry = z
44
.object({
55
type: z.string(),
66
credential_ids: z.array(z.string()).nonempty(),
7-
transaction_data_hashes_alg: z.array(z.string()).optional(),
7+
8+
// SD-JWT VC specific
9+
transaction_data_hashes_alg: z.array(z.string()).nonempty().optional(),
810
})
911
.passthrough()
1012
export type TransactionDataEntry = z.infer<typeof zTransactionEntry>

packages/utils/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export {
3232
type InferOutputUnion,
3333
} from './parse'
3434
export { joinUriParts } from './path'
35-
export type { Optional, OrPromise, Simplify, StringWithAutoCompletion } from './type'
35+
export type { Optional, OrPromise, Simplify, StringWithAutoCompletion, NonEmptyArray } from './type'
3636
export { getQueryParams, objectToQueryParams } from './url'
3737
export { type ZodFetcher, createZodFetcher, createFetcher } from './fetcher'
3838
export {

packages/utils/src/type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {}
22
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
33
export type StringWithAutoCompletion<T extends string> = T | (string & {})
44
export type OrPromise<T> = T | Promise<T>
5+
export type NonEmptyArray<T> = [T, ...T[]]

0 commit comments

Comments
 (0)