Skip to content

Commit 23e4beb

Browse files
authored
feat: validate sd jwt vc type metadata with zod (#348)
Signed-off-by: Timo Glastra <timo@animo.id>
1 parent c195397 commit 23e4beb

File tree

5 files changed

+153
-152
lines changed

5 files changed

+153
-152
lines changed

packages/sd-jwt-vc/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
"dependencies": {
4141
"@sd-jwt/core": "workspace:*",
4242
"@sd-jwt/jwt-status-list": "workspace:*",
43-
"@sd-jwt/utils": "workspace:*"
43+
"@sd-jwt/utils": "workspace:*",
44+
"zod": "^4.3.5"
4445
},
4546
"devDependencies": {
4647
"@sd-jwt/crypto-nodejs": "workspace:*",

packages/sd-jwt-vc/src/sd-jwt-vc-instance.ts

Lines changed: 51 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,21 @@ import {
66
type StatusListJWTPayload,
77
} from '@sd-jwt/jwt-status-list';
88
import type { DisclosureFrame, Hasher, Verifier } from '@sd-jwt/types';
9-
import { base64urlDecode, SDJWTException } from '@sd-jwt/utils';
9+
import { SDJWTException } from '@sd-jwt/utils';
10+
import z from 'zod';
1011
import type {
1112
SDJWTVCConfig,
1213
StatusListFetcher,
1314
StatusValidator,
1415
} from './sd-jwt-vc-config';
1516
import type { SdJwtVcPayload } from './sd-jwt-vc-payload';
16-
import type {
17-
Claim,
18-
ClaimPath,
19-
ResolvedTypeMetadata,
20-
TypeMetadataFormat,
17+
import {
18+
type Claim,
19+
type ClaimPath,
20+
type ResolvedTypeMetadata,
21+
type TypeMetadataFormat,
22+
TypeMetadataFormatSchema,
2123
} from './sd-jwt-vc-type-metadata-format';
22-
import type { VcTFetcher } from './sd-jwt-vc-vct';
2324
import type { VerificationResult } from './verification-result';
2425

2526
export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
@@ -172,24 +173,24 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
172173
url: string,
173174
integrity?: string,
174175
) {
175-
if (integrity) {
176-
// validate the integrity of the response according to https://www.w3.org/TR/SRI/
177-
const arrayBuffer = await response.arrayBuffer();
178-
const alg = integrity.split('-')[0];
179-
//TODO: error handling when a hasher is passed that is not supporting the required algorithm according to the spec
180-
const hashBuffer = await (this.userConfig.hasher as Hasher)(
181-
arrayBuffer,
182-
alg,
176+
if (!integrity) return;
177+
178+
// validate the integrity of the response according to https://www.w3.org/TR/SRI/
179+
const arrayBuffer = await response.arrayBuffer();
180+
const alg = integrity.split('-')[0];
181+
//TODO: error handling when a hasher is passed that is not supporting the required algorithm according to the spec
182+
const hashBuffer = await (this.userConfig.hasher as Hasher)(
183+
arrayBuffer,
184+
alg,
185+
);
186+
const integrityHash = integrity.split('-')[1];
187+
const hash = Array.from(new Uint8Array(hashBuffer))
188+
.map((byte) => byte.toString(16).padStart(2, '0'))
189+
.join('');
190+
if (hash !== integrityHash) {
191+
throw new Error(
192+
`Integrity check for ${url} failed: is ${hash}, but expected ${integrityHash}`,
183193
);
184-
const integrityHash = integrity.split('-')[1];
185-
const hash = Array.from(new Uint8Array(hashBuffer))
186-
.map((byte) => byte.toString(16).padStart(2, '0'))
187-
.join('');
188-
if (hash !== integrityHash) {
189-
throw new Error(
190-
`Integrity check for ${url} failed: is ${hash}, but expected ${integrityHash}`,
191-
);
192-
}
193194
}
194195
}
195196

@@ -198,7 +199,10 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
198199
* @param url
199200
* @returns
200201
*/
201-
private async fetch<T>(url: string, integrity?: string): Promise<T> {
202+
private async fetchWithIntegrity(
203+
url: string,
204+
integrity?: string,
205+
): Promise<unknown> {
202206
try {
203207
const response = await fetch(url, {
204208
signal: AbortSignal.timeout(this.userConfig.timeout ?? 10000),
@@ -210,7 +214,9 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
210214
);
211215
}
212216
await this.validateIntegrity(response.clone(), url, integrity);
213-
return response.json() as Promise<T>;
217+
const data = await response.json();
218+
219+
return data;
214220
} catch (error) {
215221
if ((error as Error).name === 'TimeoutError') {
216222
throw new Error(`Request to ${url} timed out`);
@@ -228,7 +234,10 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
228234
private async fetchVct(
229235
result: VerificationResult,
230236
): Promise<ResolvedTypeMetadata | undefined> {
231-
const typeMetadataFormat = await this.fetchSingleVct(result);
237+
const typeMetadataFormat = await this.fetchSingleVct(
238+
result.payload.vct,
239+
result.payload['vct#integrity'],
240+
);
232241

233242
if (!typeMetadataFormat) return undefined;
234243

@@ -405,12 +414,7 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
405414
// Mark this VCT as visited
406415
visitedVcts.add(parentTypeMetadata.extends);
407416

408-
// Fetch the type metadata
409-
const fetcher: VcTFetcher =
410-
this.userConfig.vctFetcher ??
411-
((uri, integrity) => this.fetch(uri, integrity));
412-
413-
const extendedTypeMetadata = await fetcher(
417+
const extendedTypeMetadata = await this.fetchSingleVct(
414418
parentTypeMetadata.extends,
415419
parentTypeMetadata['extends#integrity'],
416420
);
@@ -459,59 +463,30 @@ export class SDJwtVcInstance extends SDJwtInstance<SdJwtVcPayload> {
459463
}
460464

461465
/**
462-
* Fetches VCT Metadata of the SD-JWT-VC. Returns the type metadata format. If the SD-JWT-VC does not contain a vct claim, an error is thrown.
466+
* Fetches and verifies the VCT Metadata for a VCT value.
463467
* @param result
464468
* @returns
465469
*/
466470
private async fetchSingleVct(
467-
result: VerificationResult,
471+
vct: string,
472+
integrity?: string,
468473
): Promise<TypeMetadataFormat | undefined> {
469-
if (!result.payload.vct) {
470-
throw new SDJWTException('vct claim is required');
471-
}
472-
473-
if (result.header?.vctm) {
474-
return this.fetchVctFromHeader(result.payload.vct, result);
475-
}
476-
477-
const fetcher: VcTFetcher =
474+
const fetcher =
478475
this.userConfig.vctFetcher ??
479-
((uri, integrity) => this.fetch(uri, integrity));
480-
return fetcher(result.payload.vct, result.payload['vct#integrity']);
481-
}
482-
483-
/**
484-
* Fetches VCT Metadata from the header of the SD-JWT-VC. Returns the type metadata format. If the SD-JWT-VC does not contain a vct claim, an error is thrown.
485-
* @param result
486-
* @param
487-
*/
488-
private async fetchVctFromHeader(
489-
vct: string,
490-
result: VerificationResult,
491-
): Promise<TypeMetadataFormat> {
492-
const vctmHeader = result.header?.vctm;
493-
494-
if (!vctmHeader || !Array.isArray(vctmHeader)) {
495-
throw new Error('vctm claim in SD JWT header is invalid');
496-
}
497-
498-
const typeMetadataFormat = (vctmHeader as unknown[])
499-
.map((vctm) => {
500-
if (!(typeof vctm === 'string')) {
501-
throw new Error('vctm claim in SD JWT header is invalid');
502-
}
476+
((uri, integrity) => this.fetchWithIntegrity(uri, integrity));
503477

504-
return JSON.parse(base64urlDecode(vctm));
505-
})
506-
.find((typeMetadataFormat) => {
507-
return typeMetadataFormat.vct === vct;
508-
});
478+
// Data may be undefined
479+
const data = await fetcher(vct, integrity);
480+
if (!data) return undefined;
509481

510-
if (!typeMetadataFormat) {
511-
throw new Error('could not find VCT Metadata in JWT header');
482+
const validated = TypeMetadataFormatSchema.safeParse(data);
483+
if (!validated.success) {
484+
throw new SDJWTException(
485+
`Invalid VCT type metadata for vct '${vct}':\n${z.prettifyError(validated.error)}`,
486+
);
512487
}
513488

514-
return typeMetadataFormat;
489+
return validated.data;
515490
}
516491

517492
/**

0 commit comments

Comments
 (0)