diff --git a/CHANGELOG.md b/CHANGELOG.md index a12e7e1..a30ac7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project (loosely) adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 2.0.0 - 2025-XX-XX +## 2.0.0 - 2025-05-28 + +### Added + +- `extractEmbeddedTypeMetadata()` utility function to extract and decode Type Metadata documents embedded in the `vctm` unprotected header of SD-JWT VCs +- `fetchTypeMetadataFromUrl()` utility function to fetch and optionally verify Type Metadata from URLs specified in the `vct` claim, with integrity validation support ### Changed diff --git a/README.md b/README.md index 4b5ec21..511007d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This is an implementation of [SD-JWT VC (I-D version 01)](https://drafts.oauth.net/oauth-sd-jwt-vc/draft-ietf-oauth-sd-jwt-vc.html) in Typescript. It provides a higher-level interface on top of the [@meeco/sd-jwt](https://github.com/Meeco/sd-jwt) library to create the compliant SD-JWT VCs. -**Note on `typ` header (as of v2.0.0 / Unreleased):** +**Note on `typ` header (as of v2.0.0):** - The `typ` header for issued SD-JWT VCs is `dc+sd-jwt`. - The `Verifier` will accept both `vc+sd-jwt` and `dc+sd-jwt`. diff --git a/src/types.ts b/src/types.ts index 6c57e3f..c0ddc0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,3 +78,35 @@ export const ReservedJWTClaimKeys: CreateSDJWTPayloadKeys[] = [ 'nbf', 'exp', ]; + +/** + * Represents the structure of a Type Metadata document as defined in the SD-JWT VC specification (Section 6.2). + */ +export interface TypeMetadata { + vct?: string; + name?: string; + description?: string; + extends?: string; // URI + display?: Array>; + claims?: Array>; + /** + * OPTIONAL. An embedded JSON Schema document describing the structure of the Verifiable Credential. + * MUST NOT be used if schema_uri is present. + */ + schema?: Record; + /** + * OPTIONAL. A URL pointing to a JSON Schema document. + * MUST NOT be used if schema is present. + */ + schema_uri?: string; + + /** + * OPTIONAL. integrity metadata for vct, extends, schema_uri, and similar URIs. + * Value MUST be an "integrity metadata" string per W3C.SRI. + */ + 'schema_uri#integrity'?: string; + 'vct#integrity'?: string; + 'extends#integrity'?: string; + + [key: string]: any; +} diff --git a/src/util.spec.ts b/src/util.spec.ts index 466c5ac..48dd9ac 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -1,6 +1,7 @@ -import { JWK, decodeJWT } from '@meeco/sd-jwt'; +import { decodeJWT, JWK, Hasher as SDJWTHasher, SDJWTPayload } from '@meeco/sd-jwt'; +import * as crypto from 'crypto'; import { SDJWTVCError } from './errors'; -import { getIssuerPublicKeyFromWellKnownURI } from './util'; +import { extractEmbeddedTypeMetadata, fetchTypeMetadataFromUrl, getIssuerPublicKeyFromWellKnownURI } from './util'; describe('getIssuerPublicKeyFromIss', () => { const sdJwtVC = @@ -294,3 +295,431 @@ describe('getIssuerPublicKeyFromIss', () => { expect(fetch).toHaveBeenCalledWith(jwtIssuerWellKnownFallbackUrl); }); }); + +describe('extractEmbeddedTypeMetadata', () => { + it('should return null if vctm header is not present', () => { + const sdJwtVC = 'eyJhbGciOiJFZERTQSJ9.eyJpc3MiOiJ0ZXN0LWlzc3VlciJ9.c2lnbmF0dXJl'; // No vctm + expect(extractEmbeddedTypeMetadata(sdJwtVC)).toBeNull(); + }); + + it('should return null for a malformed JWS header', () => { + const sdJwtVC = 'malformedJWS.payload.signature'; + expect(extractEmbeddedTypeMetadata(sdJwtVC)).toBeNull(); + }); + + it('should throw an error if vctm is present but not an array', () => { + const sdJwtVC = 'eyJ2Y3RtIjoibm90LWFuLWFycmF5In0.eyJpc3MiOiJ0ZXN0LWlzc3VlciJ9.c2lnbmF0dXJl'; + expect(() => extractEmbeddedTypeMetadata(sdJwtVC)).toThrow( + new SDJWTVCError('vctm in unprotected header must be an array'), + ); + }); + + it('should throw an error if vctm contains invalid base64url data', () => { + // JWS with unprotected header: { "vctm": ["invalid-b64url!"] } + // Header: eyJ2Y3RtIjpbImludmFsaWQtYjY0dXJsISJdfQ (base64url of {"vctm":["invalid-b64url!"]}) + const sdJwtVC = 'eyJ2Y3RtIjpbImludmFsaWQtYjY0dXJsISJdfQ.eyJpc3MiOiJ0ZXN0LWlzc3VlciJ9.c2lnbmF0dXJl'; + expect(() => extractEmbeddedTypeMetadata(sdJwtVC)).toThrow( + new SDJWTVCError('Failed to decode base64url vctm entry: invalid-b64url!. Error: Invalid base64url string'), + ); + }); + + it('should correctly decode and return type metadata documents', () => { + const doc1 = { type: 'doc1', data: 'test1' }; + const doc2 = { type: 'doc2', data: 'test2' }; + const encodedDoc1 = Buffer.from(JSON.stringify(doc1)).toString('base64url'); + const encodedDoc2 = Buffer.from(JSON.stringify(doc2)).toString('base64url'); + + const headerWithVctm = { vctm: [encodedDoc1, encodedDoc2] }; + const base64UrlEncodedHeader = Buffer.from(JSON.stringify(headerWithVctm)).toString('base64url'); + const sdJwtVC = `${base64UrlEncodedHeader}.eyJpc3MiOiJ0ZXN0LWlzc3VlciJ9.c2lnbmF0dXJl`; + + const result = extractEmbeddedTypeMetadata(sdJwtVC); + expect(result).toEqual([doc1, doc2]); + }); + + it('should return empty array if vctm is an empty array', () => { + // JWS with unprotected header: { "vctm": [] } + // Header: eyJ2Y3RtIjpbXX0 (base64url of {"vctm":[]}) + const sdJwtVC = 'eyJ2Y3RtIjpbXX0.eyJpc3MiOiJ0ZXN0LWlzc3VlciJ9.c2lnbmF0dXJl'; + const result = extractEmbeddedTypeMetadata(sdJwtVC); + expect(result).toEqual([]); + }); +}); + +describe('fetchTypeMetadataFromUrl', () => { + const mockPayloadBase: SDJWTPayload = { + iss: 'https://issuer.example.com', + sub: 'user123', + iat: Date.now(), + exp: Date.now() + 3600000, + }; + + const mockTypeMetadata = { + vct: 'https://betelgeuse.example.com/education_credential', + name: 'Betelgeuse Education Credential - Preliminary Version', + description: "This is our development version of the education credential. Don't panic.", + extends: 'https://galaxy.example.com/galactic-education-credential-0.9', + 'extends#integrity': 'sha256-9cLlJNXN-TsMk-PmKjZ5t0WRL5ca_xGgX3c1VLmXfh-WRL5', + display: [ + { + lang: 'en-US', + name: 'Betelgeuse Education Credential', + description: 'An education credential for all carbon-based life forms on Betelgeusians', + rendering: { + simple: { + logo: { + uri: 'https://betelgeuse.example.com/public/education-logo.png', + 'uri#integrity': 'sha256-LmXfh-9cLlJNXN-TsMk-PmKjZ5t0WRL5ca_xGgX3c1V', + alt_text: 'Betelgeuse Ministry of Education logo', + }, + background_color: '#12107c', + text_color: '#FFFFFF', + }, + svg_templates: [ + { + uri: 'https://betelgeuse.example.com/public/credential-english.svg', + 'uri#integrity': 'sha256-8cLlJNXN-TsMk-PmKjZ5t0WRL5ca_xGgX3c1VLmXfh-9c', + properties: { + orientation: 'landscape', + color_scheme: 'light', + contrast: 'high', + }, + }, + ], + }, + }, + { + lang: 'de-DE', + name: 'Betelgeuse-Bildungsnachweis', + rendering: { + simple: { + logo: { + uri: 'https://betelgeuse.example.com/public/education-logo-de.png', + 'uri#integrity': 'sha256-LmXfh-9cLlJNXN-TsMk-PmKjZ5t0WRL5ca_xGgX3c1V', + alt_text: 'Logo des Betelgeusischen Bildungsministeriums', + }, + background_color: '#12107c', + text_color: '#FFFFFF', + }, + svg_templates: [ + { + uri: 'https://betelgeuse.example.com/public/credential-german.svg', + 'uri#integrity': 'sha256-8cLlJNXN-TsMk-PmKjZ5t0WRL5ca_xGgX3c1VLmXfh-9c', + properties: { + orientation: 'landscape', + color_scheme: 'light', + contrast: 'high', + }, + }, + ], + }, + }, + ], + claims: [ + { + path: ['name'], + display: [ + { + lang: 'de-DE', + label: 'Vor- und Nachname', + description: 'Der Name des Studenten', + }, + { + lang: 'en-US', + label: 'Name', + description: 'The name of the student', + }, + ], + sd: 'allowed', + }, + { + path: ['address'], + display: [ + { + lang: 'de-DE', + label: 'Adresse', + description: 'Adresse zum Zeitpunkt des Abschlusses', + }, + { + lang: 'en-US', + label: 'Address', + description: 'Address at the time of graduation', + }, + ], + sd: 'always', + }, + { + path: ['address', 'street_address'], + display: [ + { + lang: 'de-DE', + label: 'Straße', + }, + { + lang: 'en-US', + label: 'Street Address', + }, + ], + sd: 'always', + svg_id: 'address_street_address', + }, + { + path: ['degrees', null], + display: [ + { + lang: 'de-DE', + label: 'Abschluss', + description: 'Der Abschluss des Studenten', + }, + { + lang: 'en-US', + label: 'Degree', + description: 'Degree earned by the student', + }, + ], + sd: 'allowed', + }, + ], + schema_uri: 'https://exampleuniversity.com/public/credential-schema-0.9', + 'schema_uri#integrity': 'sha256-o984vn819a48ui1llkwPmKjZ5t0WRL5ca_xGgX3c1VLmXfh', + }; + + const mockHasher: SDJWTHasher = jest.fn(); + const mockHashAlgo = 'sha256'; + + beforeEach(() => { + jest.resetAllMocks(); + (global as any).fetch = jest.fn(); + + (mockHasher as jest.Mock).mockImplementation((data: string): string => { + return crypto.createHash(mockHashAlgo).update(data).digest('base64url'); + }); + }); + + it('should return null if vct is not a string', async () => { + const payload = { ...mockPayloadBase, vct: 12345 as any }; + const result = await fetchTypeMetadataFromUrl(payload); + expect(result).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return null if vct is not an HTTPS URL', async () => { + const payload = { ...mockPayloadBase, vct: 'http://example.com/metadata' }; + const result = await fetchTypeMetadataFromUrl(payload); + expect(result).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return null if vct is not a valid URL', async () => { + const payload = { ...mockPayloadBase, vct: 'https://invalid url' }; // relies on util.isValidUrl + const result = await fetchTypeMetadataFromUrl(payload); + expect(result).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should fetch and return type metadata for a valid vct URL', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const payload = { ...mockPayloadBase, vct: vctUrl }; + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify(mockTypeMetadata)), + }); + + const result = await fetchTypeMetadataFromUrl(payload); + expect(result).toEqual(mockTypeMetadata); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should throw error if fetching metadata fails (network error)', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const payload = { ...mockPayloadBase, vct: vctUrl }; + (global as any).fetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect(fetchTypeMetadataFromUrl(payload)).rejects.toThrow( + new SDJWTVCError(`Failed to fetch Type Metadata from ${vctUrl}: 404 Not Found`), + ); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should throw error if fetched content is not valid JSON', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const payload = { ...mockPayloadBase, vct: vctUrl }; + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve('this is not json'), + }); + + await expect(fetchTypeMetadataFromUrl(payload)).rejects.toThrow(SDJWTVCError); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should perform integrity check if vct#integrity and hasher are provided', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const contentHash = mockHashAlgo + '-' + mockHasher(rawContent); + + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': contentHash }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); + + const result = await fetchTypeMetadataFromUrl(payload, { hasher: mockHasher }); + expect(result).toEqual(mockTypeMetadata); + expect(mockHasher).toHaveBeenCalledWith(rawContent); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should perform integrity check with algorithm prefix in vct#integrity', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const contentHash = mockHashAlgo + '-' + mockHasher(rawContent); + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': contentHash }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); + + const result = await fetchTypeMetadataFromUrl(payload, { hasher: mockHasher }); + expect(result).toEqual(mockTypeMetadata); + expect(mockHasher).toHaveBeenCalledWith(rawContent); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should throw SDJWTVCError if integrity check fails', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + // mockHasher will produce a specific hash for rawContent. + // wrongHash is different, so the check should fail. + const wrongHash = 'sha265-totally-different-hash-value'; + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': wrongHash }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); + + await expect(fetchTypeMetadataFromUrl(payload, { hasher: mockHasher })).rejects.toThrow(SDJWTVCError); + expect(mockHasher).toHaveBeenCalledWith(rawContent); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should throw SDJWTVCError if integrity check fails with prefixed hash', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const calculatedCorrectHash = mockHasher(rawContent); + const wrongHashClaim = 'sha256-totally-different-hash-value'; + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': wrongHashClaim }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); + + await expect(fetchTypeMetadataFromUrl(payload, { hasher: mockHasher })).rejects.toThrow( + `Type Metadata integrity check failed for ${vctUrl}. Expected hash totally-different-hash-value (derived from ${wrongHashClaim}), got ${calculatedCorrectHash}.`, + ); + expect(mockHasher).toHaveBeenCalledWith(rawContent); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should not perform integrity check if hasher is not provided, even if vct#integrity is present', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const contentHash = mockHashAlgo + '-' + mockHasher(rawContent); + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': contentHash }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); + + const result = await fetchTypeMetadataFromUrl(payload); + expect(result).toEqual(mockTypeMetadata); + expect(mockHasher).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should not perform integrity check if vct#integrity is not present, even if hasher is provided', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const payload = { ...mockPayloadBase, vct: vctUrl }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); + + const result = await fetchTypeMetadataFromUrl(payload, { hasher: mockHasher }); + expect(result).toEqual(mockTypeMetadata); + expect(mockHasher).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should re-throw SDJWTVCError if integrity check itself throws it (e.g. hasher misbehaves)', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const integrityClaim = 'sha256-somehash'; + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaim }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + + (mockHasher as jest.Mock).mockImplementationOnce(() => { + throw new SDJWTVCError('Deliberate SDJWTVCError from hasher'); + }); + + await expect(fetchTypeMetadataFromUrl(payload, { hasher: mockHasher })).rejects.toThrow( + new SDJWTVCError('Deliberate SDJWTVCError from hasher'), + ); + }); + + it('should throw error for general errors during fetch operation', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const payload = { ...mockPayloadBase, vct: vctUrl }; + + (global as any).fetch.mockRejectedValueOnce(new Error('Network failure')); + + await expect(fetchTypeMetadataFromUrl(payload)).rejects.toThrow( + new SDJWTVCError(`Error fetching Type Metadata from ${vctUrl}: Network failure`), + ); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should throw error if vct#integrity has an invalid algorithm prefix', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const unknownPrefix = 'unknown-'; + const integrityClaimWithUnknownPrefix = `${unknownPrefix}calculated-hash`; + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaimWithUnknownPrefix }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); + + await expect(fetchTypeMetadataFromUrl(payload, { hasher: mockHasher })).rejects.toThrow( + new SDJWTVCError( + `Invalid algorithm prefix in vct#integrity claim: ${integrityClaimWithUnknownPrefix}. Expected one of: sha256-, sha384-, sha512-`, + ), + ); + + expect(mockHasher).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); +}); diff --git a/src/util.ts b/src/util.ts index c9c1f47..f247c2d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,7 @@ -import { JWK, decodeJWT } from '@meeco/sd-jwt'; +import { base64decode, decodeJWT, JWK, Hasher as SDJWTHasher, SDJWTPayload } from '@meeco/sd-jwt'; +import { decodeProtectedHeader } from 'jose'; import { SDJWTVCError } from './errors.js'; -import { JWT, SD_JWT_FORMAT_SEPARATOR } from './types.js'; +import { JWT, SD_JWT_FORMAT_SEPARATOR, TypeMetadata } from './types.js'; export enum ValidTypValues { VCSDJWT = 'vc+sd-jwt', @@ -139,3 +140,111 @@ export function getIssuerPublicKeyJWK(jwks: any, kid?: string): JWK | undefined return jwks.keys[0]; } } + +/** + * Extracts and decodes Type Metadata documents embedded in the vctm unprotected header of an SD-JWT VC. + * @param sdJwtVC The SD-JWT VC string. + * @returns An array of TypeMetadata objects, or null if not present. + * @throws An error if the vctm header is present but invalid, or if decoding fails. + */ +export function extractEmbeddedTypeMetadata(sdJwtVC: JWT): TypeMetadata[] | null { + const parts = sdJwtVC.split(SD_JWT_FORMAT_SEPARATOR); + const jws = parts[0]; + + try { + const protectedHeader = decodeProtectedHeader(jws); + + // Check if header is valid and has vctm + if (!protectedHeader || typeof protectedHeader !== 'object') { + return null; + } + + const vctm = (protectedHeader as any).vctm; + if (!vctm) { + return null; + } + + if (!Array.isArray(vctm)) { + throw new SDJWTVCError('vctm in unprotected header must be an array'); + } + + return vctm.map((doc: string) => { + try { + return JSON.parse(base64decode(doc)) as TypeMetadata; + } catch (_: any) { + throw new SDJWTVCError(`Failed to decode base64url vctm entry: ${doc}. Error: Invalid base64url string`); + } + }); + } catch (e: any) { + // Re-throw specific errors, treat other errors as 'not present' + if (e instanceof SDJWTVCError) { + throw e; + } + return null; + } +} + +/** + * Fetches and optionally verifies Type Metadata from a URL specified in the vct claim. + * @param sdJwtPayload The decoded SD-JWT payload. + * @param options Optional parameters, including a hasher for integrity checking. + * @returns A Promise that resolves to the TypeMetadata object, or null if not found or invalid. + * @throws An error if integrity check fails or if fetching/parsing encounters critical issues. + */ +export async function fetchTypeMetadataFromUrl( + sdJwtPayload: SDJWTPayload, + options?: { hasher?: SDJWTHasher }, +): Promise { + const DEFAULT_ALGORITHM_PREFIXES = ['sha256-', 'sha384-', 'sha512-']; // as per https://www.w3.org/TR/sri-2/#integrity-metadata-description + + const vct = sdJwtPayload.vct; + + if (typeof vct !== 'string' || !vct.startsWith('https://') || !isValidUrl(vct)) { + // vct is not a string or not an HTTPS URL, so no metadata to fetch from here. + return null; + } + + try { + const response = await fetch(vct); + if (!response.ok) { + throw new SDJWTVCError(`Failed to fetch Type Metadata from ${vct}: ${response.status} ${response.statusText}`); + } + + const rawContent = await response.text(); + + const integrityClaimValue = sdJwtPayload['vct#integrity'] as string | undefined; + + if (integrityClaimValue && options?.hasher) { + const calculatedHash = await Promise.resolve(options.hasher(rawContent)); + + // Extract hash from integrity claim, handling algorithm prefixes properly + const matchingPrefix = DEFAULT_ALGORITHM_PREFIXES.find((prefix) => integrityClaimValue.startsWith(prefix)); + + if (!matchingPrefix) { + throw new SDJWTVCError( + `Invalid algorithm prefix in vct#integrity claim: ${integrityClaimValue}. Expected one of: ${DEFAULT_ALGORITHM_PREFIXES.join(', ')}`, + ); + } + + const expectedHash = integrityClaimValue.substring(matchingPrefix.length); + + if (calculatedHash !== expectedHash) { + throw new SDJWTVCError( + `Type Metadata integrity check failed for ${vct}. Expected hash ${expectedHash} (derived from ${integrityClaimValue}), got ${calculatedHash}.`, + ); + } + } + + try { + const typeMetadata = JSON.parse(rawContent); + return typeMetadata as TypeMetadata; + } catch (parseError: any) { + throw new SDJWTVCError(`Failed to parse Type Metadata from ${vct} as JSON: ${parseError.message}`); + } + } catch (error: any) { + if (error instanceof SDJWTVCError) { + throw error; + } + throw new SDJWTVCError(`Error fetching Type Metadata from ${vct}: ${error.message}`); + } +}