From 3f19088af32d25b8c45c15fe76697080b3392615 Mon Sep 17 00:00:00 2001 From: Vijay Shiyani Date: Wed, 28 May 2025 22:19:02 +1000 Subject: [PATCH 1/8] feat: Implement Type Metadata utility functions --- CHANGELOG.md | 7 +- src/types.ts | 25 ++++ src/util.spec.ts | 324 ++++++++++++++++++++++++++++++++++++++++++++++- src/util.ts | 100 ++++++++++++++- 4 files changed, 451 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a12e7e1..90c803c 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 + +- Extracts and decodes Type Metadata documents embedded in the `vctm` unprotected header. +- Fetches and optionally verifies Type Metadata from a URL specified in the `vct` claim. ### Changed diff --git a/src/types.ts b/src/types.ts index 6c57e3f..fa08699 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,3 +78,28 @@ 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; + 'schema_uri#integrity'?: string; + + [key: string]: any; +} diff --git a/src/util.spec.ts b/src/util.spec.ts index 466c5ac..e2e309c 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -1,6 +1,12 @@ -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, + isValidUrl, +} from './util'; describe('getIssuerPublicKeyFromIss', () => { const sdJwtVC = @@ -294,3 +300,317 @@ 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', () => { + // JWS with unprotected header: { "vctm": "not-an-array" } + // Header: eyJ2Y3RtIjoibm90LWFuLWFycmF5In0 (base64url of {"vctm":"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(SDJWTVCError); + }); + + 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'); + // Constructing the JWS with the vctm in the *unprotected* header part is tricky directly in a string literal + // as it's part of the signed JWS structure. For testing, we simulate a JWS where the + // unprotected header part (if it were separate, which it isn't in compact JWS) would contain vctm. + // The function `decodeProtectedHeader` correctly decodes the *first* part of a JWS (the protected header). + // If `vctm` is intended to be in an *unprotected* header, the `decodeJWT` from `@meeco/sd-jwt` might expose it differently, + // or the JWS needs to be constructed in a specific way (e.g., using General JWS JSON Serialization). + // For this test, we'll assume the `vctm` is part of the main JWS header, decodable by `decodeProtectedHeader`. + 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 = { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', 'CustomType'], + credentialSubject: { + degree: { + type: 'BachelorDegree', + name: 'Bachelor of Science and Arts', + }, + }, + }; + + // Define mockHasher as a synchronous function returning string + const mockHasher: SDJWTHasher = jest.fn((data: string): string => { + return crypto.createHash('sha256').update(data).digest('base64url'); + }); + + beforeEach(() => { + // Resets all mocks, including their call counts + jest.resetAllMocks(); + (global as any).fetch = jest.fn(); + }); + + 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 return null 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', + }); + + const result = await fetchTypeMetadataFromUrl(payload); + expect(result).toBeNull(); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should return null 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'), + }); + + const result = await fetchTypeMetadataFromUrl(payload); + expect(result).toBeNull(); + 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 = mockHasher(rawContent); // mockHasher is sync now + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': contentHash }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); // Clear calls from contentHash generation + + 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 = mockHasher(rawContent); // mockHasher is sync + const integrityClaim = `sha256-${contentHash}`; + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaim }; + + (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 = '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'; // Claim in JWT + 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 = mockHasher(rawContent); // Generate hash for payload + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': contentHash }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); // Clear calls from contentHash generation + + const result = await fetchTypeMetadataFromUrl(payload); // No hasher in options + expect(result).toEqual(mockTypeMetadata); + expect(mockHasher).not.toHaveBeenCalled(); // fetchTypeMetadataFromUrl should not call it + 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 }; // No vct#integrity + + (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 return null and warn for general errors during fetch operation', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const payload = { ...mockPayloadBase, vct: vctUrl }; + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + (global as any).fetch.mockRejectedValueOnce(new Error('Network failure')); + + const result = await fetchTypeMetadataFromUrl(payload); + expect(result).toBeNull(); + expect(fetch).toHaveBeenCalledWith(vctUrl); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining(`Error fetching Type Metadata from ${vctUrl}: Network failure`), + ); + consoleWarnSpy.mockRestore(); + }); +}); + +// Ensure existing isValidUrl tests are present and correct +describe('isValidUrl', () => { + it('should return true for valid URLs', () => { + expect(isValidUrl('https://example.com')).toBe(true); + expect(isValidUrl('http://localhost:3000/path?query=value#hash')).toBe(true); + expect(isValidUrl('https://sub.domain.example.co.uk/path.html')).toBe(true); + }); + + it('should return false for invalid URLs', () => { + expect(isValidUrl('not a url')).toBe(false); + expect(isValidUrl('example.com')).toBe(false); // Missing scheme + expect(isValidUrl('htp://example.com')).toBe(false); // Typo in scheme + expect(isValidUrl('https//example.com')).toBe(false); // Missing colon + expect(isValidUrl('')).toBe(false); + expect(isValidUrl(' https://example.com')).toBe(false); // Leading space + expect(isValidUrl('https://example.com/ path')).toBe(false); // Space in path + }); +}); diff --git a/src/util.ts b/src/util.ts index c9c1f47..9e35b57 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,7 @@ -import { JWK, decodeJWT } from '@meeco/sd-jwt'; +import { 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,98 @@ 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 not an array, 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) as any; + if (protectedHeader?.vctm) { + const vctm = protectedHeader.vctm; + if (!Array.isArray(vctm)) { + throw new SDJWTVCError('vctm in unprotected header must be an array'); + } + return vctm.map((doc: string) => JSON.parse(Buffer.from(doc, 'base64url').toString()) as TypeMetadata); + } + } catch (e: any) { + // If decoding the header fails, or if vctm processing fails, it implies no valid embedded metadata. + // We can treat this as 'not present' and return null, or re-throw if specific error handling is needed. + // For now, let's consider it not present if any error occurs during this process. + if (e instanceof SDJWTVCError) { + // re-throw our specific errors + throw e; + } + // Other errors (e.g., from decodeProtectedHeader for a malformed JWS) mean no valid vctm. + return null; + } + + 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 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) { + console.warn(`Failed to fetch Type Metadata from ${vct}: ${response.status} ${response.statusText}`); + return null; + } + + 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)); + + let expectedHash = integrityClaimValue; + const parts = integrityClaimValue.split('-'); + if (parts.length > 1) { + expectedHash = parts[parts.length - 1]; + } + + 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) { + console.warn(`Failed to parse Type Metadata from ${vct} as JSON: ${parseError.message}`); + return null; + } + } catch (error: any) { + if (error instanceof SDJWTVCError) { + throw error; + } + console.warn(`Error fetching Type Metadata from ${vct}: ${error.message}`); + return null; + } +} From ebaaa2565332a447269dfd6c7a998a4904bf304c Mon Sep 17 00:00:00 2001 From: Vijay Shiyani Date: Fri, 30 May 2025 12:57:23 +1000 Subject: [PATCH 2/8] refactoring utility functions --- src/util.spec.ts | 269 +++++++++++++++++++++++++++++++++++++---------- src/util.ts | 59 +++++++---- 2 files changed, 253 insertions(+), 75 deletions(-) diff --git a/src/util.spec.ts b/src/util.spec.ts index e2e309c..a09031c 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -1,12 +1,7 @@ import { decodeJWT, JWK, Hasher as SDJWTHasher, SDJWTPayload } from '@meeco/sd-jwt'; import * as crypto from 'crypto'; import { SDJWTVCError } from './errors'; -import { - extractEmbeddedTypeMetadata, - fetchTypeMetadataFromUrl, - getIssuerPublicKeyFromWellKnownURI, - isValidUrl, -} from './util'; +import { extractEmbeddedTypeMetadata, fetchTypeMetadataFromUrl, getIssuerPublicKeyFromWellKnownURI } from './util'; describe('getIssuerPublicKeyFromIss', () => { const sdJwtVC = @@ -313,8 +308,6 @@ describe('extractEmbeddedTypeMetadata', () => { }); it('should throw an error if vctm is present but not an array', () => { - // JWS with unprotected header: { "vctm": "not-an-array" } - // Header: eyJ2Y3RtIjoibm90LWFuLWFycmF5In0 (base64url of {"vctm":"not-an-array"}) const sdJwtVC = 'eyJ2Y3RtIjoibm90LWFuLWFycmF5In0.eyJpc3MiOiJ0ZXN0LWlzc3VlciJ9.c2lnbmF0dXJl'; expect(() => extractEmbeddedTypeMetadata(sdJwtVC)).toThrow( new SDJWTVCError('vctm in unprotected header must be an array'), @@ -325,7 +318,9 @@ describe('extractEmbeddedTypeMetadata', () => { // JWS with unprotected header: { "vctm": ["invalid-b64url!"] } // Header: eyJ2Y3RtIjpbImludmFsaWQtYjY0dXJsISJdfQ (base64url of {"vctm":["invalid-b64url!"]}) const sdJwtVC = 'eyJ2Y3RtIjpbImludmFsaWQtYjY0dXJsISJdfQ.eyJpc3MiOiJ0ZXN0LWlzc3VlciJ9.c2lnbmF0dXJl'; - expect(() => extractEmbeddedTypeMetadata(sdJwtVC)).toThrow(SDJWTVCError); + 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', () => { @@ -333,13 +328,7 @@ describe('extractEmbeddedTypeMetadata', () => { const doc2 = { type: 'doc2', data: 'test2' }; const encodedDoc1 = Buffer.from(JSON.stringify(doc1)).toString('base64url'); const encodedDoc2 = Buffer.from(JSON.stringify(doc2)).toString('base64url'); - // Constructing the JWS with the vctm in the *unprotected* header part is tricky directly in a string literal - // as it's part of the signed JWS structure. For testing, we simulate a JWS where the - // unprotected header part (if it were separate, which it isn't in compact JWS) would contain vctm. - // The function `decodeProtectedHeader` correctly decodes the *first* part of a JWS (the protected header). - // If `vctm` is intended to be in an *unprotected* header, the `decodeJWT` from `@meeco/sd-jwt` might expose it differently, - // or the JWS needs to be constructed in a specific way (e.g., using General JWS JSON Serialization). - // For this test, we'll assume the `vctm` is part of the main JWS header, decodable by `decodeProtectedHeader`. + const headerWithVctm = { vctm: [encodedDoc1, encodedDoc2] }; const base64UrlEncodedHeader = Buffer.from(JSON.stringify(headerWithVctm)).toString('base64url'); const sdJwtVC = `${base64UrlEncodedHeader}.eyJpc3MiOiJ0ZXN0LWlzc3VlciJ9.c2lnbmF0dXJl`; @@ -366,25 +355,144 @@ describe('fetchTypeMetadataFromUrl', () => { }; const mockTypeMetadata = { - '@context': ['https://www.w3.org/2018/credentials/v1'], - type: ['VerifiableCredential', 'CustomType'], - credentialSubject: { - degree: { - type: 'BachelorDegree', - name: 'Bachelor of Science and Arts', + 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', }; - // Define mockHasher as a synchronous function returning string - const mockHasher: SDJWTHasher = jest.fn((data: string): string => { - return crypto.createHash('sha256').update(data).digest('base64url'); - }); + const mockHasher: SDJWTHasher = jest.fn(); beforeEach(() => { - // Resets all mocks, including their call counts jest.resetAllMocks(); (global as any).fetch = jest.fn(); + + (mockHasher as jest.Mock).mockImplementation((data: string): string => { + return crypto.createHash('sha256').update(data).digest('base64url'); + }); }); it('should return null if vct is not a string', async () => { @@ -451,14 +559,15 @@ describe('fetchTypeMetadataFromUrl', () => { 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 = mockHasher(rawContent); // mockHasher is sync now + const contentHash = mockHasher(rawContent); + console.log(`Content hash for integrity check: ${contentHash}`); const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': contentHash }; (global as any).fetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(rawContent), }); - (mockHasher as jest.Mock).mockClear(); // Clear calls from contentHash generation + (mockHasher as jest.Mock).mockClear(); const result = await fetchTypeMetadataFromUrl(payload, { hasher: mockHasher }); expect(result).toEqual(mockTypeMetadata); @@ -469,7 +578,7 @@ describe('fetchTypeMetadataFromUrl', () => { 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 = mockHasher(rawContent); // mockHasher is sync + const contentHash = mockHasher(rawContent); const integrityClaim = `sha256-${contentHash}`; const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaim }; @@ -508,7 +617,7 @@ describe('fetchTypeMetadataFromUrl', () => { const vctUrl = 'https://example.com/metadata.json'; const rawContent = JSON.stringify(mockTypeMetadata); const calculatedCorrectHash = mockHasher(rawContent); - const wrongHashClaim = 'sha256-totally-different-hash-value'; // Claim in JWT + const wrongHashClaim = 'sha256-totally-different-hash-value'; const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': wrongHashClaim }; (global as any).fetch.mockResolvedValueOnce({ @@ -527,25 +636,25 @@ describe('fetchTypeMetadataFromUrl', () => { 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 = mockHasher(rawContent); // Generate hash for payload + const contentHash = 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(); // Clear calls from contentHash generation + (mockHasher as jest.Mock).mockClear(); - const result = await fetchTypeMetadataFromUrl(payload); // No hasher in options + const result = await fetchTypeMetadataFromUrl(payload); expect(result).toEqual(mockTypeMetadata); - expect(mockHasher).not.toHaveBeenCalled(); // fetchTypeMetadataFromUrl should not call it + 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 }; // No vct#integrity + const payload = { ...mockPayloadBase, vct: vctUrl }; (global as any).fetch.mockResolvedValueOnce({ ok: true, @@ -594,23 +703,75 @@ describe('fetchTypeMetadataFromUrl', () => { ); consoleWarnSpy.mockRestore(); }); -}); -// Ensure existing isValidUrl tests are present and correct -describe('isValidUrl', () => { - it('should return true for valid URLs', () => { - expect(isValidUrl('https://example.com')).toBe(true); - expect(isValidUrl('http://localhost:3000/path?query=value#hash')).toBe(true); - expect(isValidUrl('https://sub.domain.example.co.uk/path.html')).toBe(true); - }); - - it('should return false for invalid URLs', () => { - expect(isValidUrl('not a url')).toBe(false); - expect(isValidUrl('example.com')).toBe(false); // Missing scheme - expect(isValidUrl('htp://example.com')).toBe(false); // Typo in scheme - expect(isValidUrl('https//example.com')).toBe(false); // Missing colon - expect(isValidUrl('')).toBe(false); - expect(isValidUrl(' https://example.com')).toBe(false); // Leading space - expect(isValidUrl('https://example.com/ path')).toBe(false); // Space in path + it('should use custom algorithm prefixes when provided', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const contentHash = mockHasher(rawContent); + const customPrefix = 'blake2b-'; + const integrityClaim = `${customPrefix}${contentHash}`; + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaim }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); + + const result = await fetchTypeMetadataFromUrl(payload, { + hasher: mockHasher, + algorithmPrefixes: [customPrefix, 'sha256-', 'sha384-'], + }); + + expect(result).toEqual(mockTypeMetadata); + expect(mockHasher).toHaveBeenCalledWith(rawContent); + expect(fetch).toHaveBeenCalledWith(vctUrl); + }); + + it('should fallback to default algorithm prefixes when custom prefixes are not provided', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const contentHash = mockHasher(rawContent); + const integrityClaim = `sha512-${contentHash}`; // using default prefix + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaim }; + + (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 not strip prefix if it is not in the configured algorithm prefixes', async () => { + const vctUrl = 'https://example.com/metadata.json'; + const rawContent = JSON.stringify(mockTypeMetadata); + const calculatedHash = mockHasher(rawContent); + const unknownPrefix = 'unknown-'; + const integrityClaimWithUnknownPrefix = `${unknownPrefix}${calculatedHash}`; + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaimWithUnknownPrefix }; + + (global as any).fetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(rawContent), + }); + (mockHasher as jest.Mock).mockClear(); + + // This should fail because the unknown prefix won't be stripped, + // so expectedHash will be 'unknown-' but calculatedHash will be '' + await expect( + fetchTypeMetadataFromUrl(payload, { + hasher: mockHasher, + algorithmPrefixes: ['sha256-', 'sha384-', 'sha512-'], // unknown- not included + }), + ).rejects.toThrow(SDJWTVCError); + + expect(mockHasher).toHaveBeenCalledWith(rawContent); + expect(fetch).toHaveBeenCalledWith(vctUrl); }); }); diff --git a/src/util.ts b/src/util.ts index 9e35b57..deaddc1 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,4 @@ -import { decodeJWT, JWK, Hasher as SDJWTHasher, SDJWTPayload } 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, TypeMetadata } from './types.js'; @@ -145,47 +145,58 @@ export function getIssuerPublicKeyJWK(jwks: any, kid?: string): JWK | undefined * 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 not an array, or if decoding fails. + * @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) as any; - if (protectedHeader?.vctm) { - const vctm = protectedHeader.vctm; - if (!Array.isArray(vctm)) { - throw new SDJWTVCError('vctm in unprotected header must be an array'); - } - return vctm.map((doc: string) => JSON.parse(Buffer.from(doc, 'base64url').toString()) as TypeMetadata); + 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) { - // If decoding the header fails, or if vctm processing fails, it implies no valid embedded metadata. - // We can treat this as 'not present' and return null, or re-throw if specific error handling is needed. - // For now, let's consider it not present if any error occurs during this process. + // Re-throw specific errors, treat other errors as 'not present' if (e instanceof SDJWTVCError) { - // re-throw our specific errors throw e; } - // Other errors (e.g., from decodeProtectedHeader for a malformed JWS) mean no valid vctm. return null; } - - 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. + * @param options Optional parameters, including a hasher for integrity checking and algorithm prefixes. * @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 }, + options?: { hasher?: SDJWTHasher; algorithmPrefixes?: string[] }, ): Promise { + const DEFAULT_ALGORITHM_PREFIXES = ['sha256-', 'sha512-']; + const vct = sdJwtPayload.vct; if (typeof vct !== 'string' || !vct.startsWith('https://') || !isValidUrl(vct)) { @@ -207,10 +218,16 @@ export async function fetchTypeMetadataFromUrl( if (integrityClaimValue && options?.hasher) { const calculatedHash = await Promise.resolve(options.hasher(rawContent)); + // Extract hash from integrity claim, handling algorithm prefixes properly let expectedHash = integrityClaimValue; - const parts = integrityClaimValue.split('-'); - if (parts.length > 1) { - expectedHash = parts[parts.length - 1]; + + // Check for known algorithm prefixes + const algorithmPrefixes = options?.algorithmPrefixes || DEFAULT_ALGORITHM_PREFIXES; + for (const prefix of algorithmPrefixes) { + if (integrityClaimValue.startsWith(prefix)) { + expectedHash = integrityClaimValue.substring(prefix.length); + break; + } } if (calculatedHash !== expectedHash) { From 57baf604f4621bf80a2eb0510d7f0a9f7b15b9c8 Mon Sep 17 00:00:00 2001 From: Vijay Shiyani Date: Fri, 30 May 2025 13:01:17 +1000 Subject: [PATCH 3/8] updated a changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c803c..81f9496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project (loosely) adheres to [Semantic Versioning](https://semver.org/s ### Added -- Extracts and decodes Type Metadata documents embedded in the `vctm` unprotected header. -- Fetches and optionally verifies Type Metadata from a URL specified in the `vct` claim. +- Util function to extracts and decodes Type Metadata documents embedded in the `vctm` unprotected header. +- Util function to fetche and optionally verifies Type Metadata from a URL specified in the `vct` claim. ### Changed From 60ffd986b83368872b7dbaf47629c364ff91fcf7 Mon Sep 17 00:00:00 2001 From: Vijay Shiyani Date: Fri, 30 May 2025 14:30:52 +1000 Subject: [PATCH 4/8] added a comment and default algo prefix sha384 --- src/types.ts | 7 +++++++ src/util.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index fa08699..c0ddc0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,7 +99,14 @@ export interface TypeMetadata { * 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.ts b/src/util.ts index deaddc1..8328020 100644 --- a/src/util.ts +++ b/src/util.ts @@ -195,7 +195,7 @@ export async function fetchTypeMetadataFromUrl( sdJwtPayload: SDJWTPayload, options?: { hasher?: SDJWTHasher; algorithmPrefixes?: string[] }, ): Promise { - const DEFAULT_ALGORITHM_PREFIXES = ['sha256-', 'sha512-']; + const DEFAULT_ALGORITHM_PREFIXES = ['sha256-', 'sha384-', 'sha512-']; // as per https://www.w3.org/TR/sri-2/#integrity-metadata-description const vct = sdJwtPayload.vct; From da986cd1e1ea70045a8924416639f7707ac03093 Mon Sep 17 00:00:00 2001 From: Vijay Shiyani Date: Fri, 30 May 2025 15:02:48 +1000 Subject: [PATCH 5/8] refactor: remove algorithmPrefixes param and replace console.warn with errors - Remove algorithmPrefixes from fetchTypeMetadataFromUrl options - Throw SDJWTVCError instead of console.warn for better error handling - Add validation for algorithm prefix in vct#integrity claims - Update tests to reflect new error-throwing behavior - Remove related algorithmPrefixes tests --- src/util.spec.ts | 102 ++++++++++++----------------------------------- src/util.ts | 23 ++++++----- 2 files changed, 39 insertions(+), 86 deletions(-) diff --git a/src/util.spec.ts b/src/util.spec.ts index a09031c..48dd9ac 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -485,13 +485,14 @@ describe('fetchTypeMetadataFromUrl', () => { }; 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('sha256').update(data).digest('base64url'); + return crypto.createHash(mockHashAlgo).update(data).digest('base64url'); }); }); @@ -529,7 +530,7 @@ describe('fetchTypeMetadataFromUrl', () => { expect(fetch).toHaveBeenCalledWith(vctUrl); }); - it('should return null if fetching metadata fails (network error)', async () => { + 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({ @@ -538,12 +539,13 @@ describe('fetchTypeMetadataFromUrl', () => { statusText: 'Not Found', }); - const result = await fetchTypeMetadataFromUrl(payload); - expect(result).toBeNull(); + await expect(fetchTypeMetadataFromUrl(payload)).rejects.toThrow( + new SDJWTVCError(`Failed to fetch Type Metadata from ${vctUrl}: 404 Not Found`), + ); expect(fetch).toHaveBeenCalledWith(vctUrl); }); - it('should return null if fetched content is not valid JSON', async () => { + 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({ @@ -551,16 +553,15 @@ describe('fetchTypeMetadataFromUrl', () => { text: () => Promise.resolve('this is not json'), }); - const result = await fetchTypeMetadataFromUrl(payload); - expect(result).toBeNull(); + 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 = mockHasher(rawContent); - console.log(`Content hash for integrity check: ${contentHash}`); + const contentHash = mockHashAlgo + '-' + mockHasher(rawContent); + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': contentHash }; (global as any).fetch.mockResolvedValueOnce({ @@ -578,9 +579,8 @@ describe('fetchTypeMetadataFromUrl', () => { 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 = mockHasher(rawContent); - const integrityClaim = `sha256-${contentHash}`; - const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaim }; + const contentHash = mockHashAlgo + '-' + mockHasher(rawContent); + const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': contentHash }; (global as any).fetch.mockResolvedValueOnce({ ok: true, @@ -599,7 +599,7 @@ describe('fetchTypeMetadataFromUrl', () => { const rawContent = JSON.stringify(mockTypeMetadata); // mockHasher will produce a specific hash for rawContent. // wrongHash is different, so the check should fail. - const wrongHash = 'totally-different-hash-value'; + const wrongHash = 'sha265-totally-different-hash-value'; const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': wrongHash }; (global as any).fetch.mockResolvedValueOnce({ @@ -636,7 +636,7 @@ describe('fetchTypeMetadataFromUrl', () => { 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 = mockHasher(rawContent); + const contentHash = mockHashAlgo + '-' + mockHasher(rawContent); const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': contentHash }; (global as any).fetch.mockResolvedValueOnce({ @@ -688,72 +688,23 @@ describe('fetchTypeMetadataFromUrl', () => { ); }); - it('should return null and warn for general errors during fetch operation', async () => { + it('should throw error for general errors during fetch operation', async () => { const vctUrl = 'https://example.com/metadata.json'; const payload = { ...mockPayloadBase, vct: vctUrl }; - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); (global as any).fetch.mockRejectedValueOnce(new Error('Network failure')); - const result = await fetchTypeMetadataFromUrl(payload); - expect(result).toBeNull(); - expect(fetch).toHaveBeenCalledWith(vctUrl); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining(`Error fetching Type Metadata from ${vctUrl}: Network failure`), + await expect(fetchTypeMetadataFromUrl(payload)).rejects.toThrow( + new SDJWTVCError(`Error fetching Type Metadata from ${vctUrl}: Network failure`), ); - consoleWarnSpy.mockRestore(); - }); - - it('should use custom algorithm prefixes when provided', async () => { - const vctUrl = 'https://example.com/metadata.json'; - const rawContent = JSON.stringify(mockTypeMetadata); - const contentHash = mockHasher(rawContent); - const customPrefix = 'blake2b-'; - const integrityClaim = `${customPrefix}${contentHash}`; - const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaim }; - - (global as any).fetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(rawContent), - }); - (mockHasher as jest.Mock).mockClear(); - - const result = await fetchTypeMetadataFromUrl(payload, { - hasher: mockHasher, - algorithmPrefixes: [customPrefix, 'sha256-', 'sha384-'], - }); - - expect(result).toEqual(mockTypeMetadata); - expect(mockHasher).toHaveBeenCalledWith(rawContent); expect(fetch).toHaveBeenCalledWith(vctUrl); }); - it('should fallback to default algorithm prefixes when custom prefixes are not provided', async () => { + 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 contentHash = mockHasher(rawContent); - const integrityClaim = `sha512-${contentHash}`; // using default prefix - const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaim }; - - (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 not strip prefix if it is not in the configured algorithm prefixes', async () => { - const vctUrl = 'https://example.com/metadata.json'; - const rawContent = JSON.stringify(mockTypeMetadata); - const calculatedHash = mockHasher(rawContent); const unknownPrefix = 'unknown-'; - const integrityClaimWithUnknownPrefix = `${unknownPrefix}${calculatedHash}`; + const integrityClaimWithUnknownPrefix = `${unknownPrefix}calculated-hash`; const payload = { ...mockPayloadBase, vct: vctUrl, 'vct#integrity': integrityClaimWithUnknownPrefix }; (global as any).fetch.mockResolvedValueOnce({ @@ -762,16 +713,13 @@ describe('fetchTypeMetadataFromUrl', () => { }); (mockHasher as jest.Mock).mockClear(); - // This should fail because the unknown prefix won't be stripped, - // so expectedHash will be 'unknown-' but calculatedHash will be '' - await expect( - fetchTypeMetadataFromUrl(payload, { - hasher: mockHasher, - algorithmPrefixes: ['sha256-', 'sha384-', 'sha512-'], // unknown- not included - }), - ).rejects.toThrow(SDJWTVCError); + 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).toHaveBeenCalledWith(rawContent); + expect(mockHasher).toHaveBeenCalled(); expect(fetch).toHaveBeenCalledWith(vctUrl); }); }); diff --git a/src/util.ts b/src/util.ts index 8328020..f4763dc 100644 --- a/src/util.ts +++ b/src/util.ts @@ -187,13 +187,13 @@ export function extractEmbeddedTypeMetadata(sdJwtVC: JWT): TypeMetadata[] | 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 and algorithm prefixes. + * @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; algorithmPrefixes?: string[] }, + options?: { hasher?: SDJWTHasher }, ): Promise { const DEFAULT_ALGORITHM_PREFIXES = ['sha256-', 'sha384-', 'sha512-']; // as per https://www.w3.org/TR/sri-2/#integrity-metadata-description @@ -207,8 +207,7 @@ export async function fetchTypeMetadataFromUrl( try { const response = await fetch(vct); if (!response.ok) { - console.warn(`Failed to fetch Type Metadata from ${vct}: ${response.status} ${response.statusText}`); - return null; + throw new SDJWTVCError(`Failed to fetch Type Metadata from ${vct}: ${response.status} ${response.statusText}`); } const rawContent = await response.text(); @@ -222,14 +221,22 @@ export async function fetchTypeMetadataFromUrl( let expectedHash = integrityClaimValue; // Check for known algorithm prefixes - const algorithmPrefixes = options?.algorithmPrefixes || DEFAULT_ALGORITHM_PREFIXES; + const algorithmPrefixes = DEFAULT_ALGORITHM_PREFIXES; + let foundPrefix = false; for (const prefix of algorithmPrefixes) { if (integrityClaimValue.startsWith(prefix)) { expectedHash = integrityClaimValue.substring(prefix.length); + foundPrefix = true; break; } } + if (!foundPrefix) { + throw new SDJWTVCError( + `Invalid algorithm prefix in vct#integrity claim: ${integrityClaimValue}. Expected one of: ${algorithmPrefixes.join(', ')}`, + ); + } + if (calculatedHash !== expectedHash) { throw new SDJWTVCError( `Type Metadata integrity check failed for ${vct}. Expected hash ${expectedHash} (derived from ${integrityClaimValue}), got ${calculatedHash}.`, @@ -241,14 +248,12 @@ export async function fetchTypeMetadataFromUrl( const typeMetadata = JSON.parse(rawContent); return typeMetadata as TypeMetadata; } catch (parseError: any) { - console.warn(`Failed to parse Type Metadata from ${vct} as JSON: ${parseError.message}`); - return null; + throw new SDJWTVCError(`Failed to parse Type Metadata from ${vct} as JSON: ${parseError.message}`); } } catch (error: any) { if (error instanceof SDJWTVCError) { throw error; } - console.warn(`Error fetching Type Metadata from ${vct}: ${error.message}`); - return null; + throw new SDJWTVCError(`Error fetching Type Metadata from ${vct}: ${error.message}`); } } From 9e852b0941398fa1b493561b76b7594c1854d822 Mon Sep 17 00:00:00 2001 From: Vijay Shiyani Date: Fri, 30 May 2025 15:12:40 +1000 Subject: [PATCH 6/8] refactor: simplify algorithm prefix validation logic --- src/util.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/util.ts b/src/util.ts index f4763dc..f247c2d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -218,25 +218,16 @@ export async function fetchTypeMetadataFromUrl( const calculatedHash = await Promise.resolve(options.hasher(rawContent)); // Extract hash from integrity claim, handling algorithm prefixes properly - let expectedHash = integrityClaimValue; - - // Check for known algorithm prefixes - const algorithmPrefixes = DEFAULT_ALGORITHM_PREFIXES; - let foundPrefix = false; - for (const prefix of algorithmPrefixes) { - if (integrityClaimValue.startsWith(prefix)) { - expectedHash = integrityClaimValue.substring(prefix.length); - foundPrefix = true; - break; - } - } + const matchingPrefix = DEFAULT_ALGORITHM_PREFIXES.find((prefix) => integrityClaimValue.startsWith(prefix)); - if (!foundPrefix) { + if (!matchingPrefix) { throw new SDJWTVCError( - `Invalid algorithm prefix in vct#integrity claim: ${integrityClaimValue}. Expected one of: ${algorithmPrefixes.join(', ')}`, + `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}.`, From 83cbc60894b9efab19d4c502e0ea269faf7ad411 Mon Sep 17 00:00:00 2001 From: Vijay Shiyani Date: Fri, 30 May 2025 15:15:27 +1000 Subject: [PATCH 7/8] updated changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f9496..a30ac7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and this project (loosely) adheres to [Semantic Versioning](https://semver.org/s ### Added -- Util function to extracts and decodes Type Metadata documents embedded in the `vctm` unprotected header. -- Util function to fetche and optionally verifies Type Metadata from a URL specified in the `vct` claim. +- `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 From 0197b6f5e38de1c49bd9d390395f3c1a9131a540 Mon Sep 17 00:00:00 2001 From: Vijay Shiyani Date: Fri, 30 May 2025 16:55:17 +1000 Subject: [PATCH 8/8] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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`.