@learncard/helpers 1.3.6 → 1.3.7
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/README.md +5 -5
- package/dist/helpers.cjs.development.cjs +8 -9
- package/dist/helpers.cjs.development.cjs.map +2 -2
- package/dist/helpers.cjs.production.min.cjs +3 -3
- package/dist/helpers.cjs.production.min.cjs.map +3 -3
- package/dist/helpers.d.cts +2 -2
- package/dist/helpers.d.ts +2 -2
- package/dist/helpers.esm.js +7 -8
- package/dist/helpers.esm.js.map +2 -2
- package/package.json +68 -66
- package/src/Utilities.ts +71 -0
- package/src/app-install/index.ts +168 -0
- package/src/arrays/index.ts +20 -0
- package/src/bitstring-status-list/index.ts +69 -0
- package/src/credential-format.ts +508 -0
- package/src/images/discord.helpers.ts +103 -0
- package/src/images/filestack.helpers.ts +179 -0
- package/src/images/images.helpers.ts +75 -0
- package/src/images/index.ts +1 -0
- package/src/images/unsplash.helpers.ts +110 -0
- package/src/images/url.helpers.ts +46 -0
- package/src/index.ts +105 -0
- package/src/numbers/index.ts +21 -0
- package/src/state.ts +576 -0
- package/src/strings/index.ts +4 -0
- package/src/types/index.ts +5 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha2';
|
|
2
|
+
import { decodeSdJwtSync, getClaimsSync } from '@sd-jwt/decode';
|
|
3
|
+
import type {
|
|
4
|
+
CredentialRecord,
|
|
5
|
+
StoredCredential,
|
|
6
|
+
StoredCredentialEnvelope,
|
|
7
|
+
VC,
|
|
8
|
+
} from '@learncard/types';
|
|
9
|
+
import { isStoredCredentialEnvelope } from '@learncard/types';
|
|
10
|
+
|
|
11
|
+
const SD_JWT_RESERVED_CLAIMS = new Set([
|
|
12
|
+
'iss',
|
|
13
|
+
'iat',
|
|
14
|
+
'exp',
|
|
15
|
+
'nbf',
|
|
16
|
+
'sub',
|
|
17
|
+
'vct',
|
|
18
|
+
'cnf',
|
|
19
|
+
'_sd_alg',
|
|
20
|
+
'_sd',
|
|
21
|
+
'...',
|
|
22
|
+
'status',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const SD_JWT_FALLBACK_ISSUED_AT_ISO = '1970-01-01T00:00:00.000Z';
|
|
26
|
+
|
|
27
|
+
const SUPPORTED_SD_JWT_HASH_ALGS = new Set(['sha-256', 'SHA-256', 'sha256']);
|
|
28
|
+
|
|
29
|
+
const sha256HasherSync = (data: string | ArrayBuffer, alg: string): Uint8Array => {
|
|
30
|
+
if (!SUPPORTED_SD_JWT_HASH_ALGS.has(alg)) {
|
|
31
|
+
throw new Error(`Unsupported hash algorithm: "${alg}". Only sha-256 is supported.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
35
|
+
return sha256(bytes);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Project a `CredentialRecord` into a format-discriminated read view.
|
|
40
|
+
*
|
|
41
|
+
* Two paths into the same output:
|
|
42
|
+
*
|
|
43
|
+
* 1. **Explicit format**: if the record carries an explicit `format`
|
|
44
|
+
* discriminator (and `rawWireForm` where required), the projector
|
|
45
|
+
* trusts it. This is the path new format-aware writers take.
|
|
46
|
+
*
|
|
47
|
+
* 2. **Inferred from shape** (legacy fallback): for records that
|
|
48
|
+
* pre-date format-tagging, the projector inspects `record.vc` and
|
|
49
|
+
* infers the format. W3C VCs → `w3c-vc-2.0` / `w3c-vc-1.1` based
|
|
50
|
+
* on `@context`; transitional SD-JWT wrappers (which never shipped
|
|
51
|
+
* but exist on branch `lc-1796-3`) → `dc+sd-jwt` with the compact
|
|
52
|
+
* extracted from `proof.jwt`; legacy JWT-VC envelopes → `jwt-vc-json`.
|
|
53
|
+
*
|
|
54
|
+
* Returns a `StoredCredential` for every conceivable record. Never
|
|
55
|
+
* throws — credentials with unrecognizable shape fall back to
|
|
56
|
+
* `w3c-vc-1.1` with `data = record.vc` so legacy consumers keep
|
|
57
|
+
* something they can read.
|
|
58
|
+
*
|
|
59
|
+
* This projector is the SAFETY NET that makes the format-tagging
|
|
60
|
+
* migration non-breaking: writers can adopt format-tagging at their
|
|
61
|
+
* own pace; readers using this projector see the right discriminator
|
|
62
|
+
* either way.
|
|
63
|
+
*/
|
|
64
|
+
export const toStoredCredential = (record: CredentialRecord): StoredCredential => {
|
|
65
|
+
if (record.format) {
|
|
66
|
+
if (
|
|
67
|
+
record.format === 'dc+sd-jwt' ||
|
|
68
|
+
record.format === 'vc+sd-jwt' ||
|
|
69
|
+
record.format === 'jwt-vc-json' ||
|
|
70
|
+
record.format === 'mso_mdoc'
|
|
71
|
+
) {
|
|
72
|
+
const wireForm = record.rawWireForm ?? extractWireFormFromVc(record.vc);
|
|
73
|
+
if (typeof wireForm === 'string' && wireForm.length > 0) {
|
|
74
|
+
return { format: record.format, data: wireForm };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (record.format === 'w3c-vc-2.0' || record.format === 'w3c-vc-1.1') {
|
|
79
|
+
return { format: record.format, data: record.vc as VC };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const vc = record.vc as unknown;
|
|
84
|
+
|
|
85
|
+
if (typeof vc === 'string') {
|
|
86
|
+
if (looksLikeSdJwtCompact(vc)) {
|
|
87
|
+
return { format: 'dc+sd-jwt', data: vc };
|
|
88
|
+
}
|
|
89
|
+
if (looksLikeJwsCompact(vc)) {
|
|
90
|
+
return { format: 'jwt-vc-json', data: vc };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (vc && typeof vc === 'object') {
|
|
95
|
+
const proof = getWireFormProof(vc, true);
|
|
96
|
+
const wireFromProof = getWireFormFromProof(proof);
|
|
97
|
+
|
|
98
|
+
if (wireFromProof) {
|
|
99
|
+
const proofType = proof?.type;
|
|
100
|
+
|
|
101
|
+
if (proofType === 'SdJwtCompactProof') {
|
|
102
|
+
return { format: 'dc+sd-jwt', data: wireFromProof };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (proofType === 'JwtProof2020') {
|
|
106
|
+
return { format: 'jwt-vc-json', data: wireFromProof };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const inferred = inferW3cVersionFromContext(vc);
|
|
111
|
+
return { format: inferred, data: vc as VC };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { format: 'w3c-vc-1.1', data: vc as VC };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const SD_JWT_COMPACT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+~/;
|
|
118
|
+
const JWS_COMPACT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
119
|
+
|
|
120
|
+
const looksLikeSdJwtCompact = (value: string): boolean => SD_JWT_COMPACT_RE.test(value);
|
|
121
|
+
const looksLikeJwsCompact = (value: string): boolean => JWS_COMPACT_RE.test(value);
|
|
122
|
+
|
|
123
|
+
type WireFormProof = { type?: unknown; jwt?: unknown };
|
|
124
|
+
|
|
125
|
+
const getWireFormProof = (vc: unknown, requireSupportedType = false): WireFormProof | undefined => {
|
|
126
|
+
if (!vc || typeof vc !== 'object') return undefined;
|
|
127
|
+
|
|
128
|
+
const proof = (vc as { proof?: unknown }).proof;
|
|
129
|
+
|
|
130
|
+
if (Array.isArray(proof)) {
|
|
131
|
+
for (const entry of proof) {
|
|
132
|
+
const proofObject = asWireFormProof(entry);
|
|
133
|
+
|
|
134
|
+
if (proofObject && isUsableWireFormProof(proofObject, requireSupportedType)) {
|
|
135
|
+
return proofObject;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const proofObject = asWireFormProof(proof);
|
|
143
|
+
|
|
144
|
+
return proofObject && isUsableWireFormProof(proofObject, requireSupportedType)
|
|
145
|
+
? proofObject
|
|
146
|
+
: undefined;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const asWireFormProof = (proof: unknown): WireFormProof | undefined =>
|
|
150
|
+
proof && typeof proof === 'object' ? (proof as WireFormProof) : undefined;
|
|
151
|
+
|
|
152
|
+
const isUsableWireFormProof = (proof: WireFormProof, requireSupportedType: boolean): boolean =>
|
|
153
|
+
typeof proof.jwt === 'string' &&
|
|
154
|
+
proof.jwt.length > 0 &&
|
|
155
|
+
(!requireSupportedType || proof.type === 'SdJwtCompactProof' || proof.type === 'JwtProof2020');
|
|
156
|
+
|
|
157
|
+
const getWireFormFromProof = (proof: WireFormProof | undefined): string | undefined =>
|
|
158
|
+
typeof proof?.jwt === 'string' && proof.jwt.length > 0 ? proof.jwt : undefined;
|
|
159
|
+
|
|
160
|
+
const extractWireFormFromVc = (vc: unknown): string | undefined =>
|
|
161
|
+
getWireFormFromProof(getWireFormProof(vc));
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Extract the meaningful identifying segment from an SD-JWT-VC `vct` claim
|
|
165
|
+
* so it can be projected onto the W3C-VC `type` array and `name` field.
|
|
166
|
+
*
|
|
167
|
+
* Returns `undefined` for vct values where no meaningful name can be
|
|
168
|
+
* derived (numeric-only URN tails, empty string). Shared between the
|
|
169
|
+
* receipt-time wrapper synthesizer and the read-time display projector
|
|
170
|
+
* so SD-JWT credentials produce the same wrapper shape on both paths.
|
|
171
|
+
*/
|
|
172
|
+
export const extractVctSegment = (vct: string): string | undefined => {
|
|
173
|
+
let segment: string | undefined;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const url = new URL(vct);
|
|
177
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
178
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
179
|
+
if (parts.length > 0) segment = parts[parts.length - 1]!;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Not URL-shaped — fall through to URN / plain-string handling.
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (segment === undefined) {
|
|
186
|
+
if (vct.includes(':')) {
|
|
187
|
+
const parts = vct.split(':').filter(Boolean);
|
|
188
|
+
const last = parts[parts.length - 1];
|
|
189
|
+
// Skip purely numeric trailing segments (e.g., URN version tags like
|
|
190
|
+
// "urn:eudi:pid:1") — the semantically meaningful name is one up.
|
|
191
|
+
if (last && !/^\d+$/.test(last)) {
|
|
192
|
+
segment = last;
|
|
193
|
+
} else if (parts.length >= 2) {
|
|
194
|
+
segment = parts[parts.length - 2];
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
segment = vct;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return segment && segment.length > 0 ? segment : undefined;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* PascalCase identifier suitable for the W3C-VC `type` array. Picks acronyms
|
|
206
|
+
* for short single-word lowercase segments ("pid" → "PID") and otherwise
|
|
207
|
+
* concatenates capitalized words ("playground-credential" → "PlaygroundCredential").
|
|
208
|
+
*/
|
|
209
|
+
export const deriveTypeFromVct = (vct: string | undefined): string | undefined => {
|
|
210
|
+
if (!vct || typeof vct !== 'string') return undefined;
|
|
211
|
+
const segment = extractVctSegment(vct);
|
|
212
|
+
if (!segment) return undefined;
|
|
213
|
+
|
|
214
|
+
const normalized = segment
|
|
215
|
+
.replace(/[-_.]+/g, ' ')
|
|
216
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
217
|
+
.trim();
|
|
218
|
+
const words = normalized.split(/\s+/).filter(Boolean);
|
|
219
|
+
if (words.length === 0) return undefined;
|
|
220
|
+
|
|
221
|
+
if (words.length === 1 && words[0]!.length <= 4 && words[0] === words[0]!.toLowerCase()) {
|
|
222
|
+
return words[0]!.toUpperCase();
|
|
223
|
+
}
|
|
224
|
+
return words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Human-readable display name for the `name` field. Same input handling
|
|
229
|
+
* as {@link deriveTypeFromVct} but keeps spaces between words so it reads
|
|
230
|
+
* naturally in UIs ("Playground Credential" not "PlaygroundCredential").
|
|
231
|
+
*/
|
|
232
|
+
export const deriveNameFromVct = (vct: string | undefined): string | undefined => {
|
|
233
|
+
if (!vct || typeof vct !== 'string') return undefined;
|
|
234
|
+
const segment = extractVctSegment(vct);
|
|
235
|
+
if (!segment) return undefined;
|
|
236
|
+
|
|
237
|
+
const normalized = segment
|
|
238
|
+
.replace(/[-_.]+/g, ' ')
|
|
239
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
240
|
+
.trim();
|
|
241
|
+
const words = normalized.split(/\s+/).filter(Boolean);
|
|
242
|
+
if (words.length === 0) return undefined;
|
|
243
|
+
|
|
244
|
+
if (words.length === 1 && words[0]!.length <= 4 && words[0] === words[0]!.toLowerCase()) {
|
|
245
|
+
return words[0]!.toUpperCase();
|
|
246
|
+
}
|
|
247
|
+
return words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export const stripSdJwtReservedClaims = (
|
|
251
|
+
claims: Record<string, unknown>
|
|
252
|
+
): Record<string, unknown> => {
|
|
253
|
+
const out: Record<string, unknown> = {};
|
|
254
|
+
for (const [key, value] of Object.entries(claims)) {
|
|
255
|
+
if (!SD_JWT_RESERVED_CLAIMS.has(key)) out[key] = value;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const PRIVATE_JWK_FIELDS = new Set(['d', 'dp', 'dq', 'p', 'q', 'qi', 'oth', 'k']);
|
|
261
|
+
|
|
262
|
+
const bytesToBase64Url = (bytes: Uint8Array): string => {
|
|
263
|
+
let binary = '';
|
|
264
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
|
|
265
|
+
const base64 =
|
|
266
|
+
typeof btoa === 'function'
|
|
267
|
+
? btoa(binary)
|
|
268
|
+
: Buffer.from(binary, 'binary').toString('base64');
|
|
269
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export const synthesizeDidJwk = (jwk: Record<string, unknown>): string | undefined => {
|
|
273
|
+
if (typeof jwk.kty !== 'string') return undefined;
|
|
274
|
+
|
|
275
|
+
const publicOnly: Record<string, unknown> = {};
|
|
276
|
+
for (const [key, value] of Object.entries(jwk)) {
|
|
277
|
+
if (!PRIVATE_JWK_FIELDS.has(key)) publicOnly[key] = value;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const sortedKeys = Object.keys(publicOnly).sort();
|
|
282
|
+
const canonical: Record<string, unknown> = {};
|
|
283
|
+
for (const key of sortedKeys) canonical[key] = publicOnly[key];
|
|
284
|
+
return `did:jwk:${bytesToBase64Url(new TextEncoder().encode(JSON.stringify(canonical)))}`;
|
|
285
|
+
} catch {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
export const deriveSdJwtIssuedAtIso = (options: {
|
|
291
|
+
issuedAt?: Date;
|
|
292
|
+
notBefore?: Date;
|
|
293
|
+
iat?: number;
|
|
294
|
+
nbf?: number;
|
|
295
|
+
}): string => {
|
|
296
|
+
if (options.issuedAt) return options.issuedAt.toISOString();
|
|
297
|
+
if (options.notBefore) return options.notBefore.toISOString();
|
|
298
|
+
if (typeof options.iat === 'number') return new Date(options.iat * 1000).toISOString();
|
|
299
|
+
if (typeof options.nbf === 'number') return new Date(options.nbf * 1000).toISOString();
|
|
300
|
+
return SD_JWT_FALLBACK_ISSUED_AT_ISO;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const deriveVerificationMethodFromIssuer = (options: {
|
|
304
|
+
issuer: string;
|
|
305
|
+
headerKid?: unknown;
|
|
306
|
+
}): string | undefined => {
|
|
307
|
+
if (!options.issuer.startsWith('did:') || typeof options.headerKid !== 'string') {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return options.headerKid.startsWith('#')
|
|
312
|
+
? `${options.issuer}${options.headerKid}`
|
|
313
|
+
: options.headerKid;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Read-path shim: when a storage plugin returns a `StoredCredentialEnvelope` (because we
|
|
318
|
+
* wrote a native non-W3C format like SD-JWT-VC), legacy UI components
|
|
319
|
+
* that read `.credentialSubject` / `.issuer` / `.proof` need a VC-shaped
|
|
320
|
+
* object. This projector synthesizes a minimal display-only VC from the
|
|
321
|
+
* envelope so existing consumers keep working without per-component
|
|
322
|
+
* format awareness.
|
|
323
|
+
*
|
|
324
|
+
* For SD-JWT-VC: decodes the unsigned JWS payload (signature is NOT
|
|
325
|
+
* verified here — verification belongs to the dedicated verify chain),
|
|
326
|
+
* surfaces the disclosed claims under `credentialSubject`, preserves
|
|
327
|
+
* the compact form under `proof.jwt` and `proof.type = 'SdJwtCompactProof'`
|
|
328
|
+
* so anything that already understands the SD-JWT-Compact-Proof shape
|
|
329
|
+
* keeps working.
|
|
330
|
+
*
|
|
331
|
+
* For JWT-VC: decodes the JWS payload, returns its `vc` claim.
|
|
332
|
+
*
|
|
333
|
+
* For mDoc / unknown formats: returns `undefined` — the caller MUST
|
|
334
|
+
* branch on format (`record.format === 'mso_mdoc'`) and use a
|
|
335
|
+
* format-specific display path. We don't fabricate a fake VC for
|
|
336
|
+
* binary credentials.
|
|
337
|
+
*
|
|
338
|
+
* This projector is intentionally LOSSY for SD-JWT — it produces a
|
|
339
|
+
* display-only view, NOT a re-issuable credential. Egress paths
|
|
340
|
+
* (sharing, presenting, re-uploading) MUST go through
|
|
341
|
+
* `serializeForWire(stored)` (Phase 3) to recover the canonical
|
|
342
|
+
* compact form from `envelope.data`. NEVER re-serialize from the
|
|
343
|
+
* projected VC.
|
|
344
|
+
*/
|
|
345
|
+
export const projectEnvelopeToDisplayVc = (envelope: StoredCredentialEnvelope): VC | undefined => {
|
|
346
|
+
if (envelope.format === 'mso_mdoc') return undefined;
|
|
347
|
+
|
|
348
|
+
const data = envelope.data;
|
|
349
|
+
if (typeof data !== 'string' || data.length === 0) return undefined;
|
|
350
|
+
|
|
351
|
+
if (envelope.format === 'dc+sd-jwt' || envelope.format === 'vc+sd-jwt') {
|
|
352
|
+
return projectSdJwtCompactToVc(data);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (envelope.format === 'jwt-vc-json') {
|
|
356
|
+
return projectJwtVcCompactToVc(data);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return undefined;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const decodeJwsPayload = (compact: string): Record<string, unknown> | undefined => {
|
|
363
|
+
const firstTilde = compact.indexOf('~');
|
|
364
|
+
const jws = firstTilde >= 0 ? compact.slice(0, firstTilde) : compact;
|
|
365
|
+
const parts = jws.split('.');
|
|
366
|
+
if (parts.length !== 3) return undefined;
|
|
367
|
+
try {
|
|
368
|
+
const payloadJson = base64UrlDecodeToString(parts[1]!);
|
|
369
|
+
const parsed = JSON.parse(payloadJson) as Record<string, unknown>;
|
|
370
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined;
|
|
371
|
+
} catch {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const base64UrlDecodeToString = (b64url: string): string => {
|
|
377
|
+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
378
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
379
|
+
if (typeof atob === 'function') return decodeURIComponent(escape(atob(padded)));
|
|
380
|
+
return Buffer.from(padded, 'base64').toString('utf-8');
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const projectSdJwtCompactToVc = (compact: string): VC | undefined => {
|
|
384
|
+
let decoded;
|
|
385
|
+
try {
|
|
386
|
+
decoded = decodeSdJwtSync(compact, sha256HasherSync);
|
|
387
|
+
} catch {
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const payload = decoded.jwt?.payload;
|
|
392
|
+
if (!payload || typeof payload !== 'object') return undefined;
|
|
393
|
+
|
|
394
|
+
const issuer = typeof payload.iss === 'string' ? payload.iss : 'unknown';
|
|
395
|
+
const issuedAtIso = deriveSdJwtIssuedAtIso({
|
|
396
|
+
iat: typeof payload.iat === 'number' ? payload.iat : undefined,
|
|
397
|
+
nbf: typeof payload.nbf === 'number' ? payload.nbf : undefined,
|
|
398
|
+
});
|
|
399
|
+
const expiresAtSec = typeof payload.exp === 'number' ? payload.exp : undefined;
|
|
400
|
+
|
|
401
|
+
let reconstructedClaims = payload as Record<string, unknown>;
|
|
402
|
+
try {
|
|
403
|
+
reconstructedClaims = getClaimsSync<Record<string, unknown>>(
|
|
404
|
+
payload as Record<string, unknown>,
|
|
405
|
+
decoded.disclosures ?? [],
|
|
406
|
+
sha256HasherSync
|
|
407
|
+
);
|
|
408
|
+
} catch {
|
|
409
|
+
// Fail closed on disclosures: keep only issuer-payload claims. A disclosure
|
|
410
|
+
// whose digest doesn't match MUST NOT appear in the projected subject.
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const credentialSubject: Record<string, unknown> =
|
|
414
|
+
stripSdJwtReservedClaims(reconstructedClaims);
|
|
415
|
+
const cnf = payload.cnf;
|
|
416
|
+
const holderPublicKey =
|
|
417
|
+
cnf && typeof cnf === 'object' && 'jwk' in cnf && cnf.jwk && typeof cnf.jwk === 'object'
|
|
418
|
+
? (cnf.jwk as Record<string, unknown>)
|
|
419
|
+
: undefined;
|
|
420
|
+
const holderDid = holderPublicKey ? synthesizeDidJwk(holderPublicKey) : undefined;
|
|
421
|
+
if (holderDid) credentialSubject.id = holderDid;
|
|
422
|
+
|
|
423
|
+
const vct = typeof payload.vct === 'string' ? payload.vct : undefined;
|
|
424
|
+
|
|
425
|
+
const derivedType = deriveTypeFromVct(vct);
|
|
426
|
+
const derivedName = deriveNameFromVct(vct);
|
|
427
|
+
const typeArray = ['VerifiableCredential', 'SdJwtVcCredential'];
|
|
428
|
+
if (derivedType && derivedType !== 'SdJwtVcCredential') typeArray.push(derivedType);
|
|
429
|
+
|
|
430
|
+
const verificationMethod = deriveVerificationMethodFromIssuer({
|
|
431
|
+
issuer,
|
|
432
|
+
headerKid: decoded.jwt?.header?.kid,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const proof: Record<string, unknown> = {
|
|
436
|
+
type: 'SdJwtCompactProof',
|
|
437
|
+
created: issuedAtIso,
|
|
438
|
+
proofPurpose: 'assertionMethod',
|
|
439
|
+
jwt: compact,
|
|
440
|
+
};
|
|
441
|
+
if (verificationMethod) proof.verificationMethod = verificationMethod;
|
|
442
|
+
|
|
443
|
+
const vc: VC = {
|
|
444
|
+
'@context': ['https://www.w3.org/ns/credentials/v2' as string],
|
|
445
|
+
type: typeArray,
|
|
446
|
+
issuer,
|
|
447
|
+
validFrom: issuedAtIso,
|
|
448
|
+
credentialSubject,
|
|
449
|
+
proof: proof as VC['proof'],
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
if (vct) (vc as Record<string, unknown>).sdJwtVct = vct;
|
|
453
|
+
if (derivedName) (vc as Record<string, unknown>).name = derivedName;
|
|
454
|
+
if (expiresAtSec) {
|
|
455
|
+
(vc as Record<string, unknown>).validUntil = new Date(expiresAtSec * 1000).toISOString();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return vc;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const projectJwtVcCompactToVc = (compact: string): VC | undefined => {
|
|
462
|
+
const payload = decodeJwsPayload(compact);
|
|
463
|
+
if (!payload) return undefined;
|
|
464
|
+
const vcClaim = payload.vc;
|
|
465
|
+
if (!vcClaim || typeof vcClaim !== 'object') return undefined;
|
|
466
|
+
|
|
467
|
+
// Preserve the compact JWS under a JwtProof2020 proof.jwt so the
|
|
468
|
+
// projected VC stays verifiable and VC-validator compatible, mirroring
|
|
469
|
+
// normalizeIssuedCredential. Returning only payload.vc drops the issuer
|
|
470
|
+
// signature and yields a proofless object downstream consumers reject.
|
|
471
|
+
const vc = { ...(vcClaim as Record<string, unknown>) } as Record<string, unknown>;
|
|
472
|
+
if (!vc.proof) {
|
|
473
|
+
vc.proof = {
|
|
474
|
+
type: 'JwtProof2020',
|
|
475
|
+
jwt: compact,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
return vc as VC;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Convenience: detect an envelope on the wire and return a legacy VC
|
|
483
|
+
* shape suitable for downstream W3C-only consumers. Returns the input
|
|
484
|
+
* unchanged if it's not an envelope (so callers can wrap any
|
|
485
|
+
* `read.get` result without branching themselves).
|
|
486
|
+
*/
|
|
487
|
+
export const resolveStorageReadResult = (
|
|
488
|
+
value: VC | StoredCredentialEnvelope | undefined
|
|
489
|
+
): VC | StoredCredentialEnvelope | undefined => {
|
|
490
|
+
if (!value) return value;
|
|
491
|
+
if (!isStoredCredentialEnvelope(value)) return value;
|
|
492
|
+
const projected = projectEnvelopeToDisplayVc(value);
|
|
493
|
+
return projected ?? value;
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const inferW3cVersionFromContext = (vc: unknown): 'w3c-vc-2.0' | 'w3c-vc-1.1' => {
|
|
497
|
+
if (!vc || typeof vc !== 'object') return 'w3c-vc-1.1';
|
|
498
|
+
const contextRaw = (vc as { '@context'?: unknown })['@context'];
|
|
499
|
+
const contexts = Array.isArray(contextRaw)
|
|
500
|
+
? contextRaw
|
|
501
|
+
: contextRaw !== undefined
|
|
502
|
+
? [contextRaw]
|
|
503
|
+
: [];
|
|
504
|
+
const isV2 = contexts.some(
|
|
505
|
+
c => typeof c === 'string' && c.includes('w3.org/ns/credentials/v2')
|
|
506
|
+
);
|
|
507
|
+
return isV2 ? 'w3c-vc-2.0' : 'w3c-vc-1.1';
|
|
508
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { quantizeValue } from '../arrays';
|
|
2
|
+
import { getUrlsFromSrcSet } from './images.helpers';
|
|
3
|
+
import { parseUrl, stringifyUrl } from './url.helpers';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fixes image rotation based on EXIF data and uses auto_image on a given srcset string
|
|
7
|
+
*
|
|
8
|
+
* @param srcSetString srcset string to fix
|
|
9
|
+
* @param mimetype MIME type (e.g. image/gif)
|
|
10
|
+
* @param webp flag to indicate whether we should manually specify conversion to webp
|
|
11
|
+
*
|
|
12
|
+
* @return fixed srcset string
|
|
13
|
+
*/
|
|
14
|
+
export const fixSrcSetString = (
|
|
15
|
+
srcSetString: string,
|
|
16
|
+
_mimetype?: string,
|
|
17
|
+
_webp = false
|
|
18
|
+
): string => {
|
|
19
|
+
const urls = getUrlsFromSrcSet(srcSetString);
|
|
20
|
+
|
|
21
|
+
return urls.map(([url, width]) => `${fixUrl(url)} ${width}`).join(', ');
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the best format on a given URL
|
|
26
|
+
*
|
|
27
|
+
* @param url URL to fix
|
|
28
|
+
*
|
|
29
|
+
* @return fixed URL
|
|
30
|
+
*/
|
|
31
|
+
export const fixUrl = (url: string, _mimetype?: string, _webp = false): string => {
|
|
32
|
+
return url;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const VALID_DISCORD_SIZES = [20, 32, 40, 60, 64, 80, 100, 128, 256, 512, 1024, 2048, 4096];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resizes an Discord image to the given size
|
|
39
|
+
*
|
|
40
|
+
* @param url Discord URL to resize
|
|
41
|
+
* @param size Target output width
|
|
42
|
+
*
|
|
43
|
+
* @return Discord URL
|
|
44
|
+
*/
|
|
45
|
+
export const resizeUrl = (url: string, size: number): string => {
|
|
46
|
+
const parsedUrl = parseUrl(url);
|
|
47
|
+
|
|
48
|
+
parsedUrl.query.size = quantizeValue(size, VALID_DISCORD_SIZES).toString();
|
|
49
|
+
|
|
50
|
+
return stringifyUrl(parsedUrl);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Changes an Discord image's quality to the given value
|
|
55
|
+
*
|
|
56
|
+
* @param url Discord URL to change
|
|
57
|
+
* @param quality quality value (1-100)
|
|
58
|
+
*
|
|
59
|
+
* @return Discord URL
|
|
60
|
+
*/
|
|
61
|
+
export const changeQuality = (url: string, _quality: number): string => {
|
|
62
|
+
return url;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resizes and changes an Discord image's quality
|
|
67
|
+
*
|
|
68
|
+
* @param url Discord URL to change
|
|
69
|
+
* @param size Target output width
|
|
70
|
+
* @param quality quality value (1-100)
|
|
71
|
+
*
|
|
72
|
+
* @return Discord URL
|
|
73
|
+
*/
|
|
74
|
+
export const resizeAndChangeQuality = (
|
|
75
|
+
url: string,
|
|
76
|
+
size: number,
|
|
77
|
+
quality: number,
|
|
78
|
+
{ fix = false }: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
|
|
79
|
+
): string => {
|
|
80
|
+
const updatedUrl = changeQuality(resizeUrl(url, size), quality);
|
|
81
|
+
|
|
82
|
+
return fix ? fixUrl(updatedUrl) : updatedUrl;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generates a responsive srcset string from an Discord URL and array of resolutions
|
|
87
|
+
*
|
|
88
|
+
* @param url Discord URL
|
|
89
|
+
* @param resolutions list of resolutions
|
|
90
|
+
*
|
|
91
|
+
* @return srcset string
|
|
92
|
+
*/
|
|
93
|
+
export const generateSrcSet = (
|
|
94
|
+
url: string,
|
|
95
|
+
resolutions: number[],
|
|
96
|
+
{ fix = false }: { mimetype?: string; fix?: boolean; webp?: boolean } = {}
|
|
97
|
+
): string => {
|
|
98
|
+
const srcSet = resolutions
|
|
99
|
+
.map(resolution => `${resizeUrl(url, resolution)} ${resolution}w`)
|
|
100
|
+
.join(', ');
|
|
101
|
+
|
|
102
|
+
return fix ? fixSrcSetString(srcSet) : srcSet;
|
|
103
|
+
};
|