@digitalbazaar/oid4-client 5.0.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/OID4Client.js +6 -74
- package/lib/convert/index.js +349 -0
- package/lib/index.js +9 -4
- package/lib/oid4vci/credentialOffer.js +138 -0
- package/lib/oid4vci/discovery.js +126 -0
- package/lib/oid4vci/proofs.js +50 -0
- package/lib/{authorizationRequest.js → oid4vp/authorizationRequest.js} +4 -11
- package/lib/{authorizationResponse.js → oid4vp/authorizationResponse.js} +85 -44
- package/lib/{oid4vp.js → oid4vp/index.js} +3 -6
- package/lib/oid4vp/verifier.js +102 -0
- package/lib/{x509.js → oid4vp/x509.js} +1 -1
- package/lib/query/dcql.js +244 -0
- package/lib/query/index.js +18 -0
- package/lib/query/match.js +80 -0
- package/lib/query/presentationExchange.js +328 -0
- package/lib/query/queryByExample.js +8 -0
- package/lib/query/util.js +105 -0
- package/lib/util.js +27 -232
- package/package.json +5 -3
- package/lib/convert.js +0 -430
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
fromJsonPointerMap, isNumber, toJsonPointerMap, toNumberIfNumber
|
|
6
|
+
} from './util.js';
|
|
7
|
+
import {exampleToJsonPointerMap} from './queryByExample.js';
|
|
8
|
+
import jsonpointer from 'json-pointer';
|
|
9
|
+
|
|
10
|
+
const MDOC_MDL = 'org.iso.18013.5.1.mDL';
|
|
11
|
+
|
|
12
|
+
export function dcqlQueryToVprGroups({dcql_query} = {}) {
|
|
13
|
+
const {credentials} = dcql_query;
|
|
14
|
+
let {credential_sets: credentialSets} = dcql_query;
|
|
15
|
+
if(!credentialSets) {
|
|
16
|
+
credentialSets = [{
|
|
17
|
+
options: [credentials.map(q => q.id)]
|
|
18
|
+
}];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// convert `credentials` into a map based on `ID`
|
|
22
|
+
const credentialQueryMap = new Map(credentials.map(
|
|
23
|
+
query => [query.id, query]));
|
|
24
|
+
|
|
25
|
+
// output group map
|
|
26
|
+
const groupMap = new Map();
|
|
27
|
+
for(const credentialSet of credentialSets) {
|
|
28
|
+
for(const option of credentialSet.options) {
|
|
29
|
+
const groupId = crypto.randomUUID();
|
|
30
|
+
const queries = option.map(id => {
|
|
31
|
+
const dcqlCredentialQuery = credentialQueryMap.get(id);
|
|
32
|
+
return {
|
|
33
|
+
type: 'QueryByExample',
|
|
34
|
+
group: groupId,
|
|
35
|
+
credentialQuery: _toQueryByExampleQuery({dcqlCredentialQuery})
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
groupMap.set(groupId, new Map([['QueryByExample', queries]]));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return groupMap;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function dcqlCredentialQueryToJsonPointerMap({
|
|
45
|
+
dcqlCredentialQuery
|
|
46
|
+
} = {}) {
|
|
47
|
+
const queryByExample = _toQueryByExampleQuery({dcqlCredentialQuery});
|
|
48
|
+
return exampleToJsonPointerMap(queryByExample);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function vprGroupsToDcqlQuery({groupMap, options = {}} = {}) {
|
|
52
|
+
// if there is any DCQL query, return it as-is
|
|
53
|
+
const groups = [...groupMap.values()];
|
|
54
|
+
let dcqlQuery = groups
|
|
55
|
+
.filter(g => g.has('DigitalCredentialQueryLanguage'))
|
|
56
|
+
.map(g => g.get('DigitalCredentialQueryLanguage'))
|
|
57
|
+
.at(-1);
|
|
58
|
+
if(dcqlQuery) {
|
|
59
|
+
return dcqlQuery;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// convert all `QueryByExample` queries to a single DCQL query
|
|
63
|
+
const {nullyifyArrayIndices = false} = options;
|
|
64
|
+
dcqlQuery = {};
|
|
65
|
+
const credentials = [];
|
|
66
|
+
const credentialSets = [{options: []}];
|
|
67
|
+
|
|
68
|
+
// note: same group ID is logical "AND" and different group ID is "OR"
|
|
69
|
+
for(const queries of groups) {
|
|
70
|
+
// only `QueryByExample` is convertible
|
|
71
|
+
const queryByExamples = queries.get('QueryByExample');
|
|
72
|
+
if(!(queryByExamples?.length > 0)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// for each `QueryByExample`, add another option
|
|
77
|
+
const option = [];
|
|
78
|
+
for(const queryByExample of queryByExamples) {
|
|
79
|
+
// should only be one `credentialQuery` but handle each one as a new
|
|
80
|
+
// DCQL credential query
|
|
81
|
+
const all = Array.isArray(queryByExample.credentialQuery) ?
|
|
82
|
+
queryByExample.credentialQuery : [queryByExample.credentialQuery];
|
|
83
|
+
for(const credentialQuery of all) {
|
|
84
|
+
const result = _fromQueryByExampleQuery({
|
|
85
|
+
credentialQuery, nullyifyArrayIndices
|
|
86
|
+
});
|
|
87
|
+
credentials.push(result);
|
|
88
|
+
// DCQL credential set "option" includes all queries in the "AND" group
|
|
89
|
+
option.push(result.id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// add "option" as another "OR" in the DCQL "options"
|
|
94
|
+
credentialSets[0].options.push(option);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if(credentials.length > 0) {
|
|
98
|
+
dcqlQuery.credentials = credentials;
|
|
99
|
+
}
|
|
100
|
+
if(credentialSets.length > 0) {
|
|
101
|
+
dcqlQuery.credential_sets = credentialSets;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return dcqlQuery;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// exported for testing purposes only
|
|
108
|
+
export function _fromQueryByExampleQuery({
|
|
109
|
+
credentialQuery, nullyifyArrayIndices = false
|
|
110
|
+
}) {
|
|
111
|
+
const result = {
|
|
112
|
+
id: crypto.randomUUID(),
|
|
113
|
+
format: 'ldp_vc',
|
|
114
|
+
meta: {
|
|
115
|
+
type_values: ['https://www.w3.org/2018/credentials#VerifiableCredential']
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
if(credentialQuery?.reason) {
|
|
119
|
+
result.meta.reason = credentialQuery.reason;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const {example = {}} = credentialQuery ?? {};
|
|
123
|
+
|
|
124
|
+
// determine credential format
|
|
125
|
+
if(Array.isArray(credentialQuery.acceptedEnvelopes)) {
|
|
126
|
+
const set = new Set(credentialQuery.acceptedEnvelopes);
|
|
127
|
+
if(set.has('application/jwt')) {
|
|
128
|
+
result.format = 'jwt_vc_json';
|
|
129
|
+
} else if(set.has('application/mdl')) {
|
|
130
|
+
result.format = 'mso_mdoc';
|
|
131
|
+
result.meta = {doctype_value: MDOC_MDL};
|
|
132
|
+
} else if(set.has('application/dc+sd-jwt')) {
|
|
133
|
+
result.format = 'dc+sd-jwt';
|
|
134
|
+
result.meta = {vct_values: []};
|
|
135
|
+
if(Array.isArray(example?.type)) {
|
|
136
|
+
result.meta.vct_values.push(...example.type);
|
|
137
|
+
} else if(typeof example.type === 'string') {
|
|
138
|
+
result.meta.vct_values.push(example.type);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// convert `example` into json pointers and walk to produce DCQL claim paths
|
|
144
|
+
const pointers = toJsonPointerMap({obj: example, flat: true});
|
|
145
|
+
const pathsMap = new Map();
|
|
146
|
+
for(const [pointer, value] of pointers) {
|
|
147
|
+
// parse path into DCQL path w/ numbers for array indexes
|
|
148
|
+
let path = jsonpointer.parse(pointer).map(toNumberIfNumber);
|
|
149
|
+
|
|
150
|
+
// special process non-`@context` paths to convert some array indexes
|
|
151
|
+
// to DCQL `null` (which means "any" index)
|
|
152
|
+
if(path[0] !== '@context') {
|
|
153
|
+
if(nullyifyArrayIndices) {
|
|
154
|
+
// brute force convert every array index to `null` by request
|
|
155
|
+
path = path.map(p => isNumber(p) ? null : p);
|
|
156
|
+
} else if(isNumber(path.at(-1)) && !isNumber(path.at(-2))) {
|
|
157
|
+
// when a pointer terminates at an array element it means candidate
|
|
158
|
+
// matching values are expressed in the `example`, so make sure to
|
|
159
|
+
// share the path for all candidates
|
|
160
|
+
path[path.length - 1] = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// compile processed path back to a key to consolidate `values`
|
|
165
|
+
const key = jsonpointer.compile(path.map(p => p === null ? 'null' : p));
|
|
166
|
+
|
|
167
|
+
// create shared entry for path and candidate matching values
|
|
168
|
+
let entry = pathsMap.get(key);
|
|
169
|
+
if(!entry) {
|
|
170
|
+
entry = {path, valueSet: new Set()};
|
|
171
|
+
pathsMap.set(key, entry);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// add any non-QueryByExample-wildcard as a DCQL candidate match value
|
|
175
|
+
if(!(value === '' || value instanceof Map || value instanceof Set)) {
|
|
176
|
+
entry.valueSet.add(value);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// produce DCQL `claims`
|
|
181
|
+
const claims = [...pathsMap.values()].map(({path, valueSet}) => {
|
|
182
|
+
const claim = {path};
|
|
183
|
+
if(valueSet.size > 0) {
|
|
184
|
+
claim.values = [...valueSet];
|
|
185
|
+
}
|
|
186
|
+
return claim;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if(claims.length > 0) {
|
|
190
|
+
result.claims = claims;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// exported for testing purposes only
|
|
197
|
+
export function _toQueryByExampleQuery({dcqlCredentialQuery}) {
|
|
198
|
+
// convert DCQL credential query to pointers
|
|
199
|
+
const pointers = new Map();
|
|
200
|
+
const {format, meta, claims = []} = dcqlCredentialQuery;
|
|
201
|
+
for(const claim of claims) {
|
|
202
|
+
const {values} = claim;
|
|
203
|
+
|
|
204
|
+
// a trailing `null` in a path means `values` should be treated as a set
|
|
205
|
+
// of candidates inside an array value at path-1
|
|
206
|
+
const path = claim.path?.at(-1) === null ?
|
|
207
|
+
claim.path.slice(0, -1) : claim.path;
|
|
208
|
+
|
|
209
|
+
// convert `null` path tokens to an index; assume the use of `null` will
|
|
210
|
+
// not be combined with any other index
|
|
211
|
+
const pointer = jsonpointer.compile(path.map(p => p === null ? '0' : p));
|
|
212
|
+
if(!values) {
|
|
213
|
+
pointers.set(pointer, '');
|
|
214
|
+
} else if(values.length === 1 && claim.path.at(-1) !== null) {
|
|
215
|
+
// convert a single choice for a non-array value to a primitive
|
|
216
|
+
pointers.set(pointer, values[0]);
|
|
217
|
+
} else {
|
|
218
|
+
pointers.set(pointer, new Set(values));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const credentialQuery = {};
|
|
223
|
+
if(meta?.reason) {
|
|
224
|
+
credentialQuery.reason = meta.reason;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// convert pointers to example object
|
|
228
|
+
credentialQuery.example = fromJsonPointerMap({map: pointers});
|
|
229
|
+
|
|
230
|
+
if(format === 'jwt_vc_json') {
|
|
231
|
+
credentialQuery.acceptedEnvelopes = ['application/jwt'];
|
|
232
|
+
} else if(format === 'mso_mdoc') {
|
|
233
|
+
if(meta?.doctype_value === MDOC_MDL) {
|
|
234
|
+
credentialQuery.acceptedEnvelopes = ['application/mdl'];
|
|
235
|
+
} else {
|
|
236
|
+
credentialQuery.acceptedEnvelopes = ['application/mdoc'];
|
|
237
|
+
}
|
|
238
|
+
} else if(format === 'dc+sd-jwt') {
|
|
239
|
+
// FIXME: consider adding `vct_values` as params
|
|
240
|
+
credentialQuery.acceptedEnvelopes = ['application/dc+sd-jwt'];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return credentialQuery;
|
|
244
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {dcqlCredentialQueryToJsonPointerMap} from './dcql.js';
|
|
5
|
+
import {exampleToJsonPointerMap} from './queryByExample.js';
|
|
6
|
+
import {inputDescriptorToJsonPointerMap} from './presentationExchange.js';
|
|
7
|
+
|
|
8
|
+
export {credentialMatches} from './match.js';
|
|
9
|
+
|
|
10
|
+
export const dcql = {
|
|
11
|
+
toJsonPointerMap: dcqlCredentialQueryToJsonPointerMap
|
|
12
|
+
};
|
|
13
|
+
export const presentationExchange = {
|
|
14
|
+
toJsonPointerMap: inputDescriptorToJsonPointerMap
|
|
15
|
+
};
|
|
16
|
+
export const queryByExample = {
|
|
17
|
+
toJsonPointerMap: exampleToJsonPointerMap
|
|
18
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {isObject, resolvePointer, toNumberIfNumber} from './util.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns whether a credential matches against a JSON pointer map.
|
|
8
|
+
*
|
|
9
|
+
* A JSON pointer map must be created from a QueryByExample `example`, a DCQL
|
|
10
|
+
* `credential` query value, or a Presentation Exchange input descriptor
|
|
11
|
+
* by calling the respective utility APIs in this library. It is more efficient
|
|
12
|
+
* to produce this JSON pointer map just once when looking for matches in a
|
|
13
|
+
* list of more than one credential.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} options - The options.
|
|
16
|
+
* @param {object} options.credential - The credential to try to match.
|
|
17
|
+
* @param {Map} options.map - The JSON pointer map.
|
|
18
|
+
* @param {object} options.options - Match options, such as:
|
|
19
|
+
* [coerceNumbers=true] - String/numbers will be coerced.
|
|
20
|
+
*
|
|
21
|
+
* @returns {boolean} `true` if the credential matches, `false` if not.
|
|
22
|
+
*/
|
|
23
|
+
export function credentialMatches({
|
|
24
|
+
credential, map, options = {coerceNumbers: true}
|
|
25
|
+
} = {}) {
|
|
26
|
+
// credential must be an object to match
|
|
27
|
+
if(!isObject(credential)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return _match({cursor: credential, matchValue: map, options});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _match({cursor, matchValue, options}) {
|
|
34
|
+
// handle wildcard matching
|
|
35
|
+
if(_isWildcard(matchValue)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if(matchValue instanceof Set) {
|
|
40
|
+
// some element in the set must match `cursor`
|
|
41
|
+
return [...matchValue].some(e => _match({cursor, matchValue: e, options}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if(matchValue instanceof Map) {
|
|
45
|
+
// all pointers and values in the map must match `cursor`
|
|
46
|
+
return [...matchValue.entries()].every(([pointer, matchValue]) => {
|
|
47
|
+
const value = resolvePointer(cursor, pointer);
|
|
48
|
+
if(value === undefined) {
|
|
49
|
+
// no value at `pointer`; no match
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
// handles case where `value` is an empty array + wildcard `matchValue`
|
|
53
|
+
if(_isWildcard(matchValue)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
// normalize value to an array for matching
|
|
57
|
+
const values = Array.isArray(value) ? value : [value];
|
|
58
|
+
return values.some(v => _match({cursor: v, matchValue, options}));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// primitive comparison
|
|
63
|
+
if(cursor === matchValue) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// string/number coercion
|
|
68
|
+
if(options.coerceNumbers) {
|
|
69
|
+
const cursorNumber = toNumberIfNumber(cursor);
|
|
70
|
+
const matchNumber = toNumberIfNumber(matchValue);
|
|
71
|
+
return cursorNumber !== undefined && cursorNumber === matchNumber;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function _isWildcard(value) {
|
|
78
|
+
// empty string, Map, or Set
|
|
79
|
+
return value === '' || value?.size === 0;
|
|
80
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) 2023-2025 Digital Bazaar, Inc. All rights reserved.
|
|
3
|
+
*/
|
|
4
|
+
import {resolvePointer, toJsonPointerMap} from './util.js';
|
|
5
|
+
import {exampleToJsonPointerMap} from './queryByExample.js';
|
|
6
|
+
import {JSONPath} from 'jsonpath-plus';
|
|
7
|
+
import jsonpointer from 'json-pointer';
|
|
8
|
+
|
|
9
|
+
const VALUE_TYPES = new Set(['string', 'number', 'boolean']);
|
|
10
|
+
|
|
11
|
+
export function presentationDefinitionToVprGroups({
|
|
12
|
+
presentation_definition, strict = false
|
|
13
|
+
} = {}) {
|
|
14
|
+
const {input_descriptors: inputDescriptors} = presentation_definition;
|
|
15
|
+
|
|
16
|
+
// only one group (no "OR" with presentation exchange), every input
|
|
17
|
+
// input descriptor converts to a `QueryByExample` query in the same group
|
|
18
|
+
const queries = inputDescriptors.map(inputDescriptor => {
|
|
19
|
+
return {
|
|
20
|
+
type: 'QueryByExample',
|
|
21
|
+
credentialQuery: _toQueryByExampleQuery({inputDescriptor, strict})
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// output group map with a single group with all `QueryByExample` queries
|
|
26
|
+
const group = new Map([['QueryByExample', queries]]);
|
|
27
|
+
const groupMap = new Map([[undefined, group]]);
|
|
28
|
+
return groupMap;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function inputDescriptorToJsonPointerMap({inputDescriptor} = {}) {
|
|
32
|
+
const queryByExample = _toQueryByExampleQuery({inputDescriptor});
|
|
33
|
+
return exampleToJsonPointerMap(queryByExample);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function vprGroupsToPresentationDefinition({
|
|
37
|
+
groupMap, prefixJwtVcPath
|
|
38
|
+
} = {}) {
|
|
39
|
+
// only a single `QueryByExample` is supported at this time; use last one
|
|
40
|
+
const queryByExample = [...groupMap.values()]
|
|
41
|
+
.filter(g => g.has('QueryByExample'))
|
|
42
|
+
.map(g => g.get('QueryByExample'))
|
|
43
|
+
.at(-1);
|
|
44
|
+
if(!queryByExample) {
|
|
45
|
+
// no presentation definition
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const input_descriptors = [];
|
|
50
|
+
const presentationDefinition = {
|
|
51
|
+
id: crypto.randomUUID(),
|
|
52
|
+
input_descriptors
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// note: same group ID is logical "AND" and different group ID is "OR"
|
|
56
|
+
const groups = [...groupMap.values()];
|
|
57
|
+
for(const queries of groups) {
|
|
58
|
+
// only `QueryByExample` is convertible
|
|
59
|
+
const queryByExamples = queries.get('QueryByExample');
|
|
60
|
+
if(!(queryByExamples?.length > 0)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// for each `QueryByExample`, add another input descriptor (for every
|
|
65
|
+
// "credentialQuery" within it
|
|
66
|
+
for(const queryByExample of queryByExamples) {
|
|
67
|
+
// should only be one `credentialQuery` but handle each one as a new
|
|
68
|
+
// set of input descriptors
|
|
69
|
+
const all = Array.isArray(queryByExample.credentialQuery) ?
|
|
70
|
+
queryByExample.credentialQuery : [queryByExample.credentialQuery];
|
|
71
|
+
for(const credentialQuery of all) {
|
|
72
|
+
input_descriptors.push(_fromQueryByExampleQuery({
|
|
73
|
+
credentialQuery, prefixJwtVcPath
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return presentationDefinition;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// exported for backwards compatibility only
|
|
83
|
+
export function pathsToVerifiableCredentialPointers({paths} = {}) {
|
|
84
|
+
// get only the paths inside a verifiable credential
|
|
85
|
+
paths = Array.isArray(paths) ? paths : [paths];
|
|
86
|
+
paths = _getVerifiableCredentialPaths(paths);
|
|
87
|
+
// convert each JSON path to a JSON pointer
|
|
88
|
+
return paths.map(_jsonPathToJsonPointer);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _filterToValue({filter, strict = false}) {
|
|
92
|
+
/* Each `filter` has a JSON Schema object. In recognition of the fact that
|
|
93
|
+
a query must be usable by common database engines (including perhaps
|
|
94
|
+
encrypted cloud databases) and of the fact that each JSON Schema object will
|
|
95
|
+
come from an untrusted source (and could have malicious regexes, etc.), only
|
|
96
|
+
simple JSON Schema types are supported:
|
|
97
|
+
|
|
98
|
+
simple type filters (`string`/`number`/`boolean`/`object`): with `const` or
|
|
99
|
+
`enum`, `format` is not supported and `pattern` has partial support as it
|
|
100
|
+
will be treated as a simple string not a regex; regex is a DoS attack
|
|
101
|
+
vector
|
|
102
|
+
|
|
103
|
+
`array`: with `contains` where uses a simple type filter
|
|
104
|
+
|
|
105
|
+
`allOf`: supported only with the above schemas present in it.
|
|
106
|
+
|
|
107
|
+
*/
|
|
108
|
+
let value;
|
|
109
|
+
|
|
110
|
+
const {type} = filter;
|
|
111
|
+
if(type === 'array') {
|
|
112
|
+
if(filter.contains) {
|
|
113
|
+
if(Array.isArray(filter.contains)) {
|
|
114
|
+
return filter.contains.map(filter => _filterToValue({filter, strict}));
|
|
115
|
+
}
|
|
116
|
+
return _filterToValue({filter: filter.contains, strict});
|
|
117
|
+
}
|
|
118
|
+
if(Array.isArray(filter.allOf) && filter.allOf.every(f => f.contains)) {
|
|
119
|
+
return filter.allOf.map(
|
|
120
|
+
f => _filterToValue({filter: f.contains, strict}));
|
|
121
|
+
}
|
|
122
|
+
if(strict) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'Unsupported filter; array filters must use "allOf" and/or ' +
|
|
125
|
+
'"contains" with a simple type filter.');
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
if(VALUE_TYPES.has(type) || type === 'object' || type === undefined) {
|
|
130
|
+
if(filter.const !== undefined) {
|
|
131
|
+
value = filter.const;
|
|
132
|
+
} else if(filter.pattern) {
|
|
133
|
+
value = filter.pattern;
|
|
134
|
+
} else if(filter.enum) {
|
|
135
|
+
value = filter.enum.slice();
|
|
136
|
+
} else if(filter.type === 'object') {
|
|
137
|
+
value = {};
|
|
138
|
+
} else if(strict && type === 'string') {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'Unsupported filter; string filters must use "const" or "pattern".');
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
if(strict) {
|
|
145
|
+
throw new Error(`Unsupported filter type "${type}".`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// exported for testing purposes only
|
|
150
|
+
export function _fromQueryByExampleQuery({credentialQuery, prefixJwtVcPath}) {
|
|
151
|
+
// determine `prefixJwtVcPath` default:
|
|
152
|
+
// if `credentialQuery` specifies `acceptedEnvelopes: ['application/jwt']`,
|
|
153
|
+
// then default `prefixJwtVcPath` to `true`
|
|
154
|
+
if(prefixJwtVcPath === undefined &&
|
|
155
|
+
(Array.isArray(credentialQuery.acceptedEnvelopes) &&
|
|
156
|
+
credentialQuery.acceptedEnvelopes.includes?.('application/jwt'))) {
|
|
157
|
+
prefixJwtVcPath = true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const fields = [];
|
|
161
|
+
const inputDescriptor = {
|
|
162
|
+
id: crypto.randomUUID(),
|
|
163
|
+
constraints: {fields}
|
|
164
|
+
};
|
|
165
|
+
if(credentialQuery?.reason) {
|
|
166
|
+
inputDescriptor.purpose = credentialQuery?.reason;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// convert `example` into json pointers
|
|
170
|
+
const {example = {}} = credentialQuery || {};
|
|
171
|
+
const pointers = toJsonPointerMap({obj: example});
|
|
172
|
+
|
|
173
|
+
// walk pointers and produce fields
|
|
174
|
+
for(const [pointer, value] of pointers) {
|
|
175
|
+
const path = jsonpointer.parse(pointer);
|
|
176
|
+
|
|
177
|
+
const field = {
|
|
178
|
+
path: [
|
|
179
|
+
JSONPath.toPathString(['$', ...path])
|
|
180
|
+
]
|
|
181
|
+
};
|
|
182
|
+
// include 'vc' path for queries against JWT payloads instead of VCs
|
|
183
|
+
if(prefixJwtVcPath) {
|
|
184
|
+
field.path.push(JSONPath.toPathString(['$', 'vc', ...path]));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if(value instanceof Set) {
|
|
188
|
+
field.filter = {
|
|
189
|
+
type: 'array',
|
|
190
|
+
allOf: [...value].map(value => ({
|
|
191
|
+
contains: _primitiveValueToFilter(value)
|
|
192
|
+
}))
|
|
193
|
+
};
|
|
194
|
+
} else {
|
|
195
|
+
field.filter = _primitiveValueToFilter(value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fields.push(field);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return inputDescriptor;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _getVerifiableCredentialPaths(paths) {
|
|
205
|
+
// remove any paths that start with what would be present in a
|
|
206
|
+
// presentation submission and adjust any paths that would be part of a
|
|
207
|
+
// JWT-secured VC, such that only actual VC paths remain
|
|
208
|
+
const removed = paths.filter(p => !_isPresentationSubmissionPath(p));
|
|
209
|
+
return [...new Set(removed.map(p => {
|
|
210
|
+
if(_isJWTPath(p)) {
|
|
211
|
+
return '$' + p.slice('$.vc'.length);
|
|
212
|
+
}
|
|
213
|
+
if(_isSquareJWTPath(p)) {
|
|
214
|
+
return '$' + p.slice('$[\'vc\']'.length);
|
|
215
|
+
}
|
|
216
|
+
return p;
|
|
217
|
+
}))];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function _isPresentationSubmissionPath(path) {
|
|
221
|
+
return path.startsWith('$.verifiableCredential[') ||
|
|
222
|
+
path.startsWith('$.vp.') ||
|
|
223
|
+
path.startsWith('$[\'verifiableCredential') || path.startsWith('$[\'vp');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _isJWTPath(path) {
|
|
227
|
+
return path.startsWith('$.vc.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function _isSquareJWTPath(path) {
|
|
231
|
+
return path.startsWith('$[\'vc\']');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _jsonPathToJsonPointer(jsonPath) {
|
|
235
|
+
return JSONPath.toPointer(JSONPath.toPathArray(jsonPath));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _toQueryByExampleQuery({inputDescriptor, strict = false}) {
|
|
239
|
+
// every input descriptor must have an `id`
|
|
240
|
+
if(typeof inputDescriptor?.id !== 'string') {
|
|
241
|
+
throw new TypeError('Input descriptor "id" must be a string.');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const example = {};
|
|
245
|
+
const credentialQuery = {example};
|
|
246
|
+
if(inputDescriptor.purpose) {
|
|
247
|
+
credentialQuery.reason = inputDescriptor.purpose;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* Note: Each input descriptor object is currently mapped to a single example
|
|
251
|
+
query. If multiple possible path values appear for a single field, these will
|
|
252
|
+
be mapped to multiple properties in the example which may or may not be what
|
|
253
|
+
is intended. This behavior could be changed in a future revision if it
|
|
254
|
+
becomes clear there is a better approach. */
|
|
255
|
+
|
|
256
|
+
const fields = inputDescriptor.constraints?.fields || [];
|
|
257
|
+
for(const field of fields) {
|
|
258
|
+
const {path, filter, optional} = field;
|
|
259
|
+
// skip optional fields
|
|
260
|
+
if(optional === true) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
// each field must have a `path` (which can be a string or an array)
|
|
266
|
+
if(!(Array.isArray(path) || typeof path === 'string')) {
|
|
267
|
+
throw new TypeError(
|
|
268
|
+
'Input descriptor field "path" must be a string or array.');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// process any filter
|
|
272
|
+
let value = '';
|
|
273
|
+
if(filter !== undefined) {
|
|
274
|
+
value = _filterToValue({filter, strict});
|
|
275
|
+
}
|
|
276
|
+
// no value understood, skip field
|
|
277
|
+
if(value === undefined) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// normalize value to array
|
|
281
|
+
const originalValue = value;
|
|
282
|
+
if(!Array.isArray(value)) {
|
|
283
|
+
value = [value];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// get JSON pointers for every path inside a verifiable credential
|
|
287
|
+
const pointers = pathsToVerifiableCredentialPointers({paths: path});
|
|
288
|
+
|
|
289
|
+
// add values at each path, converting to an array / appending as needed
|
|
290
|
+
for(const pointer of pointers) {
|
|
291
|
+
const existing = resolvePointer(example, pointer);
|
|
292
|
+
if(existing === undefined) {
|
|
293
|
+
jsonpointer.set(example, pointer, originalValue);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if(Array.isArray(existing)) {
|
|
298
|
+
if(!existing.includes(value)) {
|
|
299
|
+
existing.push(...value);
|
|
300
|
+
}
|
|
301
|
+
} else if(existing !== value) {
|
|
302
|
+
jsonpointer.set(example, pointer, [existing, ...value]);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} catch(cause) {
|
|
306
|
+
const id = field.id || (JSON.stringify(field).slice(0, 50) + ' ...');
|
|
307
|
+
const error = new Error(
|
|
308
|
+
`Could not process input descriptor field: "${id}".`, {cause});
|
|
309
|
+
error.field = field;
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return credentialQuery;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function _primitiveValueToFilter(value) {
|
|
318
|
+
const filter = {
|
|
319
|
+
type: typeof value
|
|
320
|
+
};
|
|
321
|
+
if(VALUE_TYPES.has(filter.type)) {
|
|
322
|
+
filter.const = value;
|
|
323
|
+
} else {
|
|
324
|
+
// default to `object`
|
|
325
|
+
filter.type = 'object';
|
|
326
|
+
}
|
|
327
|
+
return filter;
|
|
328
|
+
}
|