From 3cbcd897369e7ca8f7819964342168efbd07d529 Mon Sep 17 00:00:00 2001 From: rvlb Date: Thu, 11 Dec 2025 15:52:50 -0300 Subject: [PATCH 01/10] Integrate VCI snapshot with SHCReader --- README.md | 66 +++++++++++++++++++++++++++++++++ src/shc/errors.ts | 14 +++++++ src/shc/reader.ts | 24 +++++++++++- src/shc/types.ts | 8 ++++ test/helpers.ts | 75 +++++++++++++++++++++++++++++++++++++ test/shc/directory.test.ts | 76 +------------------------------------- test/shc/shc.test.ts | 57 ++++++++++++++++++++++++++++ 7 files changed, 243 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index fc666ad..c831d19 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,72 @@ const healthCardFromQR = await reader.fromQRNumeric(qrNumericStrings); console.log('Bundle from QR:', await healthCardFromQR.asBundle()); ``` +#### Usage with the VCI Directory Snapshot + +The library can optionally consult the VCI directory snapshot to obtain a canonical collection of issuer metadata (JWKS and CRLs). This is useful when you want an authoritative, ready-made source of issuer keys without assembling or maintaining your own `Directory` object. In order to bundle it directly into your `SHCReader` object, you may provide the `useVciDirectory: true` parameter to its constructor. + +Example usage: + +```typescript +import { SHCIssuer, SHCReader } from 'kill-the-clipboard'; + +const issuer = new SHCIssuer({ + issuer: 'https://your-healthcare-org.com', + privateKey: privateKeyPKCS8String, // ES256 private key in PKCS#8 format + publicKey: publicKeySPKIString, // ES256 public key in SPKI format +}); + +const reader = new SHCReader({ + publicKey: publicKeySPKIString, // ES256 public key in SPKI format + useVciDirectory: true, +}); + +// Create FHIR Bundle +const fhirBundle = { + resourceType: 'Bundle', + type: 'collection', + entry: [ + { + fullUrl: 'https://example.org/fhir/Patient/123', + resource: { + resourceType: 'Patient', + id: '123', + name: [{ family: 'Doe', given: ['John'] }], + birthDate: '1990-01-01', + }, + }, + { + fullUrl: 'https://example.org/fhir/Immunization/456', + resource: { + resourceType: 'Immunization', + id: '456', + status: 'completed', + vaccineCode: { + coding: [{ + system: 'http://hl7.org/fhir/sid/cvx', + code: '207', + display: 'COVID-19 vaccine', + }], + }, + patient: { reference: 'Patient/123' }, + occurrenceDateTime: '2023-01-15', + }, + }, + ], +}; + +// Issue a new SMART Health Card +const healthCard = await issuer.issue(fhirBundle); + +// Verify and read the health card +const verifiedHealthCard = await reader.fromJWS(healthCard.asJWS()); +console.log('VCI Directory issuers info:', verifiedHealthCard.getIssuerInfo()) +``` + +#### Usage with a generic Directory object + +Optionally, you may also provide your own `Directory` object to the `SHCReader` constructor, through the `issuerDirectory` parameter. For more information on how to build a `Directory` instance, please check the [Directories section](#directories). + ### SMART Health Links Quick Start SHLs enable encrypted, link-based sharing of health information. The flow involves: diff --git a/src/shc/errors.ts b/src/shc/errors.ts index 577662f..69cea36 100644 --- a/src/shc/errors.ts +++ b/src/shc/errors.ts @@ -157,3 +157,17 @@ export class CredentialValidationError extends SHCError { this.name = 'CredentialValidationError' } } + +/** + * Error thrown when SHCReader configuration is invalid. + * + * @public + * @group SHC + * @category Errors + */ +export class SHCReaderConfigError extends SHCError { + constructor(message: string) { + super(message, 'INVALID_CONFIGURATION') + this.name = 'SHCReaderConfigError' + } +} diff --git a/src/shc/reader.ts b/src/shc/reader.ts index 7d40735..7b1bb47 100644 --- a/src/shc/reader.ts +++ b/src/shc/reader.ts @@ -1,6 +1,13 @@ // SHCReader class import { importJWK } from 'jose' -import { FileFormatError, QRCodeError, SHCError, VerificationError } from './errors.js' +import { Directory } from './directory.js' +import { + FileFormatError, + QRCodeError, + SHCError, + SHCReaderConfigError, + VerificationError, +} from './errors.js' import { FHIRBundleProcessor } from './fhir/bundle-processor.js' import { JWSProcessor } from './jws/jws-processor.js' import { QRCodeGenerator } from './qr/qr-code-generator.js' @@ -50,12 +57,19 @@ export class SHCReader { * ``` */ constructor(config: SHCReaderConfigParams) { + if (config.issuerDirectory && config.useVciDirectory) { + throw new SHCReaderConfigError( + 'SHCReader configuration error: Cannot specify both issuerDirectory and useVciDirectory' + ) + } + this.config = { ...config, enableQROptimization: config.enableQROptimization ?? true, strictReferences: config.strictReferences ?? true, verifyExpiration: config.verifyExpiration ?? true, issuerDirectory: config.issuerDirectory ?? null, + useVciDirectory: config.useVciDirectory ?? false, } this.bundleProcessor = new FHIRBundleProcessor() @@ -152,11 +166,17 @@ export class SHCReader { const vc: VerifiableCredential = { vc: payload.vc } this.vcProcessor.validate(vc) - // Step 4: Return the original FHIR Bundle + // Step 4: Get the issuer info from a provided directory + // or from the VCI snapshot let issuerInfo: Issuer[] = [] if (this.config.issuerDirectory) { issuerInfo = this.config.issuerDirectory.getIssuerInfo() + } else if (this.config.useVciDirectory) { + const vciDirectory = await Directory.fromVCI() + issuerInfo = vciDirectory.getIssuerInfo() } + + // Step 5: Return the original FHIR Bundle return new SHC(jws, originalBundle, issuerInfo) } catch (error) { if (error instanceof SHCError) { diff --git a/src/shc/types.ts b/src/shc/types.ts index f72d8e0..25f487a 100644 --- a/src/shc/types.ts +++ b/src/shc/types.ts @@ -150,6 +150,14 @@ export interface SHCReaderConfigParams { * @defaultValue `null` */ issuerDirectory?: Directory | null + + /** + * Whether to consult the VCI directory to resolve issuer metadata (JWKS and related information). + * When `true`, the reader will attempt to resolve keys from the VCI directory during verification. + * When `false`, no automatic VCI directory lookup will be performed. + * @defaultValue `false` + */ + useVciDirectory?: boolean } /** diff --git a/test/helpers.ts b/test/helpers.ts index f0fa0b7..f4838a6 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -110,3 +110,78 @@ export function decodeQRFromDataURL(dataURL: string): string | null { return null } } + +export const SAMPLE_DIRECTORY_JSON = { + directory: 'https://example.com/keystore/directory.json', + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + name: 'Example Issuer 1', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-1-simple', + }, + { + kty: 'EC', + kid: 'kid-2-simple', + }, + ], + crls: [ + { + kid: 'kid-2-simple', + method: 'rid', + ctr: 1, + rids: ['revoked-1'], + }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer2', + name: 'Example Issuer 2', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-A-simple', + }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer3', + name: 'Example Issuer 3', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-C-simple', + }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer4', + name: 'Example Issuer 4', + website: 'https://example.com/issuer4', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-D-simple', + }, + ], + crls: [ + { + kid: 'kid-D-simple', + method: 'rid', + ctr: 1, + rids: ['revoked-2'], + }, + ], + }, + ], +} diff --git a/test/shc/directory.test.ts b/test/shc/directory.test.ts index b70f067..d728307 100644 --- a/test/shc/directory.test.ts +++ b/test/shc/directory.test.ts @@ -1,81 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Directory } from '../../src/shc/directory' import type { DirectoryJSON } from '../../src/shc/types' - -const SAMPLE_DIRECTORY_JSON = { - directory: 'https://example.com/keystore/directory.json', - issuerInfo: [ - { - issuer: { - iss: 'https://example.com/issuer', - name: 'Example Issuer 1', - }, - keys: [ - { - kty: 'EC', - kid: 'kid-1-simple', - }, - { - kty: 'EC', - kid: 'kid-2-simple', - }, - ], - crls: [ - { - kid: 'kid-2-simple', - method: 'rid', - ctr: 1, - rids: ['revoked-1'], - }, - ], - }, - { - issuer: { - iss: 'https://example.com/issuer2', - name: 'Example Issuer 2', - }, - keys: [ - { - kty: 'EC', - kid: 'kid-A-simple', - }, - ], - }, - { - issuer: { - iss: 'https://example.com/issuer3', - name: 'Example Issuer 3', - }, - keys: [ - { - kty: 'EC', - kid: 'kid-C-simple', - }, - ], - }, - { - issuer: { - iss: 'https://example.com/issuer4', - name: 'Example Issuer 4', - website: 'https://example.com/issuer4', - }, - keys: [ - { - kty: 'EC', - kid: 'kid-D-simple', - }, - ], - crls: [ - { - kid: 'kid-D-simple', - method: 'rid', - ctr: 1, - rids: ['revoked-2'], - }, - ], - }, - ], -} +import { SAMPLE_DIRECTORY_JSON } from '../helpers' function assertDirectoryFromSampleJson(directory: Directory) { const issuers = directory.getIssuerInfo() diff --git a/test/shc/shc.test.ts b/test/shc/shc.test.ts index 6e0b896..65df755 100644 --- a/test/shc/shc.test.ts +++ b/test/shc/shc.test.ts @@ -8,6 +8,7 @@ import { type SHCConfig, SHCIssuer, SHCReader, + SHCReaderConfigError, type SHCReaderConfigParams, SignatureVerificationError, } from '@/index' @@ -16,6 +17,7 @@ import { createInvalidBundle, createValidFHIRBundle, decodeQRFromDataURL, + SAMPLE_DIRECTORY_JSON, testPrivateKeyJWK, testPrivateKeyPKCS8, testPublicKeyJWK, @@ -123,6 +125,61 @@ describe('SHC', () => { expect(verifiedHealthCard.getIssuerInfo()).toEqual(directory.getIssuerInfo()) }) + it('should fetch the VCI directory and bundle issuerInfo into SHC', async () => { + const { importPKCS8, importSPKI } = await import('jose') + + const privateKeyCrypto = await importPKCS8(testPrivateKeyPKCS8, 'ES256') + const publicKeyCrypto = await importSPKI(testPublicKeySPKI, 'ES256') + + const configWithCryptoKeys: SHCConfig = { + issuer: 'https://example.com/issuer', + privateKey: privateKeyCrypto, + publicKey: publicKeyCrypto, + expirationTime: null, + enableQROptimization: false, + strictReferences: true, + } + const issuerWithCryptoKeys = new SHCIssuer(configWithCryptoKeys) + + const healthCard = await issuerWithCryptoKeys.issue(validBundle) + const jws = healthCard.asJWS() + + const readerWithDirectory = new SHCReader({ + publicKey: publicKeyCrypto, + enableQROptimization: false, + strictReferences: true, + useVciDirectory: true, + }) + + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('vci_snapshot.json')) { + return Promise.resolve({ + ok: true, + json: async () => SAMPLE_DIRECTORY_JSON, + }) + } + + return Promise.resolve({ ok: false, status: 404 }) + }) + + ;(globalThis as any).fetch = fetchMock + const vciDirectory = await Directory.fromVCI() + const verifiedHealthCard = await readerWithDirectory.fromJWS(jws) + ;(globalThis as any).fetch = originalFetch + + expect(verifiedHealthCard.getIssuerInfo()).toEqual(vciDirectory.getIssuerInfo()) + }) + + it('should raise SHCReaderConfigError if both issuerDirectory and useVciDirectory are set', async () => { + expect(() => { + new SHCReader({ + useVciDirectory: true, + issuerDirectory: Directory.fromJSON(SAMPLE_DIRECTORY_JSON), + }) + }).toThrow(SHCReaderConfigError) + }) + it('should issue SMART Health Card with CryptoKey objects', async () => { const { importPKCS8, importSPKI } = await import('jose') From c3ab0255db28e9cd805a7792bf8e14581337aaee Mon Sep 17 00:00:00 2001 From: rvlb Date: Fri, 12 Dec 2025 21:18:53 -0300 Subject: [PATCH 02/10] Handle SHC revoke --- README.md | 43 +-------- src/shc/directory.ts | 14 ++- src/shc/errors.ts | 7 ++ src/shc/issuer.ts | 9 +- src/shc/reader.ts | 31 +++--- src/shc/shc.ts | 14 +-- src/shc/types.ts | 1 + test/shc/directory.test.ts | 12 +-- test/shc/shc.test.ts | 189 +++++++++++++++---------------------- 9 files changed, 126 insertions(+), 194 deletions(-) diff --git a/README.md b/README.md index c831d19..1419e65 100644 --- a/README.md +++ b/README.md @@ -165,47 +165,6 @@ const reader = new SHCReader({ publicKey: publicKeySPKIString, // ES256 public key in SPKI format useVciDirectory: true, }); - -// Create FHIR Bundle -const fhirBundle = { - resourceType: 'Bundle', - type: 'collection', - entry: [ - { - fullUrl: 'https://example.org/fhir/Patient/123', - resource: { - resourceType: 'Patient', - id: '123', - name: [{ family: 'Doe', given: ['John'] }], - birthDate: '1990-01-01', - }, - }, - { - fullUrl: 'https://example.org/fhir/Immunization/456', - resource: { - resourceType: 'Immunization', - id: '456', - status: 'completed', - vaccineCode: { - coding: [{ - system: 'http://hl7.org/fhir/sid/cvx', - code: '207', - display: 'COVID-19 vaccine', - }], - }, - patient: { reference: 'Patient/123' }, - occurrenceDateTime: '2023-01-15', - }, - }, - ], -}; - -// Issue a new SMART Health Card -const healthCard = await issuer.issue(fhirBundle); - -// Verify and read the health card -const verifiedHealthCard = await reader.fromJWS(healthCard.asJWS()); -console.log('VCI Directory issuers info:', verifiedHealthCard.getIssuerInfo()) ``` #### Usage with a generic Directory object @@ -378,7 +337,7 @@ import { Directory } from 'kill-the-clipboard' // top-level async context or inside an async function try { const directory = await Directory.fromVCI() - const issuers = directory.getIssuerInfo() + const issuers = directory.getIssuers() console.log('Loaded issuers:', issuers.length) } catch (err) { console.error('Failed to load VCI Directory:', err) diff --git a/src/shc/directory.ts b/src/shc/directory.ts index f57ea65..802bb7e 100644 --- a/src/shc/directory.ts +++ b/src/shc/directory.ts @@ -16,15 +16,19 @@ export class Directory { * * @param issuerInfo - Array of issuer entries (see {@link Issuer}) */ - constructor(private issuerInfo: Issuer[]) {} + constructor(private issuers: Issuer[]) {} /** - * Return the internal issuer info array. + * Return the internal issuers array. * - * @returns Array of issuer info objects + * @returns Array of `Issuer` objects */ - getIssuerInfo(): Issuer[] { - return this.issuerInfo + getIssuers(): Issuer[] { + return this.issuers + } + + getIssuerByIss(iss: string): Issuer | undefined { + return this.issuers.find(issuer => issuer.iss === iss) } /** diff --git a/src/shc/errors.ts b/src/shc/errors.ts index 69cea36..6a50739 100644 --- a/src/shc/errors.ts +++ b/src/shc/errors.ts @@ -171,3 +171,10 @@ export class SHCReaderConfigError extends SHCError { this.name = 'SHCReaderConfigError' } } + +export class SHCRevokedError extends SHCError { + constructor(message: string) { + super(message, 'SHC_REVOKED') + this.name = 'SHCRevokedError' + } +} diff --git a/src/shc/issuer.ts b/src/shc/issuer.ts index 2c1a1ec..3d3da8a 100644 --- a/src/shc/issuer.ts +++ b/src/shc/issuer.ts @@ -5,7 +5,6 @@ import { JWSProcessor } from './jws/jws-processor.js' import { SHC } from './shc.js' import type { FHIRBundle, - Issuer, SHCConfig, SHCConfigParams, SHCJWT, @@ -80,13 +79,9 @@ export class SHCIssuer { * }); * ``` */ - async issue( - fhirBundle: FHIRBundle, - config: VerifiableCredentialParams = {}, - issuerInfo: Issuer[] = [] - ): Promise { + async issue(fhirBundle: FHIRBundle, config: VerifiableCredentialParams = {}): Promise { const jws = await this.createJWS(fhirBundle, config) - return new SHC(jws, fhirBundle, issuerInfo) + return new SHC(jws, fhirBundle) } /** diff --git a/src/shc/reader.ts b/src/shc/reader.ts index 7b1bb47..9a22fa1 100644 --- a/src/shc/reader.ts +++ b/src/shc/reader.ts @@ -6,18 +6,14 @@ import { QRCodeError, SHCError, SHCReaderConfigError, + SHCRevokedError, VerificationError, } from './errors.js' import { FHIRBundleProcessor } from './fhir/bundle-processor.js' import { JWSProcessor } from './jws/jws-processor.js' import { QRCodeGenerator } from './qr/qr-code-generator.js' import { SHC } from './shc.js' -import type { - Issuer, - SHCReaderConfig, - SHCReaderConfigParams, - VerifiableCredential, -} from './types.js' +import type { SHCReaderConfig, SHCReaderConfigParams, VerifiableCredential } from './types.js' import { VerifiableCredentialProcessor } from './vc.js' /** @@ -168,16 +164,29 @@ export class SHCReader { // Step 4: Get the issuer info from a provided directory // or from the VCI snapshot - let issuerInfo: Issuer[] = [] + let directory: Directory | null = null if (this.config.issuerDirectory) { - issuerInfo = this.config.issuerDirectory.getIssuerInfo() + directory = this.config.issuerDirectory } else if (this.config.useVciDirectory) { - const vciDirectory = await Directory.fromVCI() - issuerInfo = vciDirectory.getIssuerInfo() + directory = await Directory.fromVCI() + } + + if (directory) { + const issuer = directory.getIssuerByIss(payload.iss) + const issuerCrls = issuer?.crls + const vcRid = payload.vc.rid + if (issuerCrls && vcRid) { + // TODO: we must also handle the case where the SHC is set to be revoked + issuerCrls.forEach(crl => { + if (crl.rids.includes(vcRid)) { + throw new SHCRevokedError('This SHC has been revoked') + } + }) + } } // Step 5: Return the original FHIR Bundle - return new SHC(jws, originalBundle, issuerInfo) + return new SHC(jws, originalBundle) } catch (error) { if (error instanceof SHCError) { throw error diff --git a/src/shc/shc.ts b/src/shc/shc.ts index e1fcfca..72de478 100644 --- a/src/shc/shc.ts +++ b/src/shc/shc.ts @@ -2,7 +2,7 @@ import { FHIRBundleProcessor } from './fhir/bundle-processor.js' import { QRCodeGenerator } from './qr/qr-code-generator.js' -import type { FHIRBundle, Issuer, QRCodeConfigParams } from './types.js' +import type { FHIRBundle, QRCodeConfigParams } from './types.js' /** * Represents an issued SMART Health Card with various output formats. @@ -15,8 +15,7 @@ import type { FHIRBundle, Issuer, QRCodeConfigParams } from './types.js' export class SHC { constructor( private readonly jws: string, - private readonly originalBundle: FHIRBundle, - private readonly issuerInfo: Issuer[] = [] + private readonly originalBundle: FHIRBundle ) {} /** @@ -37,15 +36,6 @@ export class SHC { return this.originalBundle } - /** - * Return the issuer metadata associated with this health card. - * - * @returns Array of issuer objects (may be empty) - */ - getIssuerInfo(): Issuer[] { - return this.issuerInfo - } - /** * Generate QR code data URLs from the health card. * diff --git a/src/shc/types.ts b/src/shc/types.ts index 25f487a..39ae894 100644 --- a/src/shc/types.ts +++ b/src/shc/types.ts @@ -30,6 +30,7 @@ export interface VerifiableCredential { /** The FHIR Bundle containing medical data. */ fhirBundle: FHIRBundle } + rid?: string } } diff --git a/test/shc/directory.test.ts b/test/shc/directory.test.ts index d728307..b54f72e 100644 --- a/test/shc/directory.test.ts +++ b/test/shc/directory.test.ts @@ -4,7 +4,7 @@ import type { DirectoryJSON } from '../../src/shc/types' import { SAMPLE_DIRECTORY_JSON } from '../helpers' function assertDirectoryFromSampleJson(directory: Directory) { - const issuers = directory.getIssuerInfo() + const issuers = directory.getIssuers() expect(issuers).toHaveLength(4) const issuer1 = issuers[0]! @@ -110,7 +110,7 @@ describe('Directory', () => { } const directory = Directory.fromJSON(directoryJson as DirectoryJSON) - const issuers = directory.getIssuerInfo() + const issuers = directory.getIssuers() expect(issuers).toHaveLength(3) const missing = issuers.find(i => i.iss === 'https://missing.example/issuer')! @@ -169,7 +169,7 @@ describe('Directory', () => { const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined) const directory = await Directory.fromURLs([ISS_URL]) - const issuers = directory.getIssuerInfo() + const issuers = directory.getIssuers() expect(issuers).toHaveLength(1) const issuer = issuers[0]! expect(issuer.iss).toEqual(ISS_URL) @@ -234,7 +234,7 @@ describe('Directory', () => { const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined) const directory = await Directory.fromURLs([ISS_URL, ISS_URL2, ISS_URL3]) - const issuers = directory.getIssuerInfo() + const issuers = directory.getIssuers() // issuer3 jwks fetch will throw and be caught; only issuer1 and issuer2 should be present expect(issuers).toHaveLength(2) @@ -280,7 +280,7 @@ describe('Directory', () => { const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined) const directory = await Directory.fromURLs([ISS_URL]) - const issuers = directory.getIssuerInfo() + const issuers = directory.getIssuers() expect(issuers).toHaveLength(0) expect(debugSpy).toHaveBeenCalledTimes(1) @@ -299,7 +299,7 @@ describe('Directory', () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const directory = await Directory.fromURLs([ISS_URL]) - const issuers = directory.getIssuerInfo() + const issuers = directory.getIssuers() expect(issuers).toHaveLength(0) expect(errorSpy).toHaveBeenCalledTimes(1) diff --git a/test/shc/shc.test.ts b/test/shc/shc.test.ts index 65df755..c697e85 100644 --- a/test/shc/shc.test.ts +++ b/test/shc/shc.test.ts @@ -60,117 +60,6 @@ describe('SHC', () => { expect(jws.split('.')).toHaveLength(3) }) - it('should bundle issuerInfo into SHC when reader created with a directory', async () => { - const { importPKCS8, importSPKI } = await import('jose') - - const privateKeyCrypto = await importPKCS8(testPrivateKeyPKCS8, 'ES256') - const publicKeyCrypto = await importSPKI(testPublicKeySPKI, 'ES256') - - const configWithCryptoKeys: SHCConfig = { - issuer: 'https://example.com/issuer', - privateKey: privateKeyCrypto, - publicKey: publicKeyCrypto, - expirationTime: null, - enableQROptimization: false, - strictReferences: true, - } - const issuerWithCryptoKeys = new SHCIssuer(configWithCryptoKeys) - - const healthCard = await issuerWithCryptoKeys.issue(validBundle) - const jws = healthCard.asJWS() - - const ISS_URL = 'https://example.com/issuer' - const originalFetch = globalThis.fetch - const fetchMock = vi.fn().mockImplementation((url: string) => { - if (url === `${ISS_URL}/.well-known/jwks.json`) { - return Promise.resolve({ - ok: true, - json: async () => ({ - keys: [ - { - kid: 'kid1', - kty: 'EC', - }, - ], - }), - }) - } - - if (url === `${ISS_URL}/.well-known/crl/kid1.json`) { - return Promise.resolve({ - ok: true, - json: async () => ({ - kid: 'kid1', - method: 'rid', - ctr: 1, - rids: ['imrevoked'], - }), - }) - } - - return Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) - }) - ;(globalThis as any).fetch = fetchMock - const directory = await Directory.fromURLs([ISS_URL]) - ;(globalThis as any).fetch = originalFetch - - const readerWithDirectory = new SHCReader({ - publicKey: publicKeyCrypto, - enableQROptimization: false, - strictReferences: true, - issuerDirectory: directory, - }) - - const verifiedHealthCard = await readerWithDirectory.fromJWS(jws) - expect(verifiedHealthCard.getIssuerInfo()).toEqual(directory.getIssuerInfo()) - }) - - it('should fetch the VCI directory and bundle issuerInfo into SHC', async () => { - const { importPKCS8, importSPKI } = await import('jose') - - const privateKeyCrypto = await importPKCS8(testPrivateKeyPKCS8, 'ES256') - const publicKeyCrypto = await importSPKI(testPublicKeySPKI, 'ES256') - - const configWithCryptoKeys: SHCConfig = { - issuer: 'https://example.com/issuer', - privateKey: privateKeyCrypto, - publicKey: publicKeyCrypto, - expirationTime: null, - enableQROptimization: false, - strictReferences: true, - } - const issuerWithCryptoKeys = new SHCIssuer(configWithCryptoKeys) - - const healthCard = await issuerWithCryptoKeys.issue(validBundle) - const jws = healthCard.asJWS() - - const readerWithDirectory = new SHCReader({ - publicKey: publicKeyCrypto, - enableQROptimization: false, - strictReferences: true, - useVciDirectory: true, - }) - - const originalFetch = globalThis.fetch - const fetchMock = vi.fn().mockImplementation((url: string) => { - if (url.includes('vci_snapshot.json')) { - return Promise.resolve({ - ok: true, - json: async () => SAMPLE_DIRECTORY_JSON, - }) - } - - return Promise.resolve({ ok: false, status: 404 }) - }) - - ;(globalThis as any).fetch = fetchMock - const vciDirectory = await Directory.fromVCI() - const verifiedHealthCard = await readerWithDirectory.fromJWS(jws) - ;(globalThis as any).fetch = originalFetch - - expect(verifiedHealthCard.getIssuerInfo()).toEqual(vciDirectory.getIssuerInfo()) - }) - it('should raise SHCReaderConfigError if both issuerDirectory and useVciDirectory are set', async () => { expect(() => { new SHCReader({ @@ -277,6 +166,84 @@ describe('SHC', () => { expect(verifiedBundle).toEqual(validBundle) }) + it('should verify SHC when reader created with a directory', async () => { + const healthCard = await issuer.issue(validBundle) + + const ISS_URL = 'https://example.com/issuer' + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === `${ISS_URL}/.well-known/jwks.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ + keys: [ + { + kid: 'kid1', + kty: 'EC', + }, + ], + }), + }) + } + + if (url === `${ISS_URL}/.well-known/crl/kid1.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ + kid: 'kid1', + method: 'rid', + ctr: 1, + rids: ['imrevoked'], + }), + }) + } + + return Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) + }) + ;(globalThis as any).fetch = fetchMock + const directory = await Directory.fromURLs([ISS_URL]) + ;(globalThis as any).fetch = originalFetch + + const readerWithDirectory = new SHCReader({ + ...readerConfig, + issuerDirectory: directory, + }) + + const verifiedHealthCard = await readerWithDirectory.fromJWS(healthCard.asJWS()) + const verifiedBundle = await verifiedHealthCard.asBundle() + + expect(verifiedBundle).toBeDefined() + expect(verifiedBundle).toEqual(validBundle) + }) + + it('should verify SHC when reader is created with the VCI snapshot directory', async () => { + const healthCard = await issuer.issue(validBundle) + const readerWithDirectory = new SHCReader({ + ...readerConfig, + useVciDirectory: true, + }) + + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('vci_snapshot.json')) { + return Promise.resolve({ + ok: true, + json: async () => SAMPLE_DIRECTORY_JSON, + }) + } + + return Promise.resolve({ ok: false, status: 404 }) + }) + + ;(globalThis as any).fetch = fetchMock + const verifiedHealthCard = await readerWithDirectory.fromJWS(healthCard.asJWS()) + ;(globalThis as any).fetch = originalFetch + + const verifiedBundle = await verifiedHealthCard.asBundle() + expect(verifiedBundle).toBeDefined() + expect(verifiedBundle).toEqual(validBundle) + }) + it('should throw error for invalid JWS', async () => { await expect(reader.fromJWS('invalid.jws.signature')).rejects.toThrow( SignatureVerificationError From d801d107be97c7062634d268f4c9cd87cec335a0 Mon Sep 17 00:00:00 2001 From: rvlb Date: Mon, 15 Dec 2025 12:02:04 -0300 Subject: [PATCH 03/10] Improve performance when checking for revoked SHCs and update tests --- README.md | 9 +-------- src/shc/directory.ts | 40 ++++++++++++++++++++++---------------- src/shc/errors.ts | 7 +++++++ src/shc/reader.ts | 2 +- src/shc/types.ts | 32 ++++++++++++++++++------------ test/shc/directory.test.ts | 38 ++++++++++++++++++++---------------- test/shc/shc.test.ts | 19 ++++++++++++++++-- 7 files changed, 90 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 1419e65..627f66e 100644 --- a/README.md +++ b/README.md @@ -148,21 +148,14 @@ console.log('Bundle from QR:', await healthCardFromQR.asBundle()); #### Usage with the VCI Directory Snapshot -The library can optionally consult the VCI directory snapshot to obtain a canonical collection of issuer metadata (JWKS and CRLs). This is useful when you want an authoritative, ready-made source of issuer keys without assembling or maintaining your own `Directory` object. In order to bundle it directly into your `SHCReader` object, you may provide the `useVciDirectory: true` parameter to its constructor. +The library can optionally consult the VCI directory snapshot to obtain a canonical collection of issuer metadata (JWKS and CRLs). This is useful when you want an authoritative, ready-made source of issuer keys without assembling or maintaining your own `Directory` object. In order to bundle it directly into your `SHCReader` object, you may provide the `useVciDirectory: true` parameter to its constructor. When using this, you don't need to provide a public key, as it'll also be fetched automatically. Example usage: ```typescript import { SHCIssuer, SHCReader } from 'kill-the-clipboard'; -const issuer = new SHCIssuer({ - issuer: 'https://your-healthcare-org.com', - privateKey: privateKeyPKCS8String, // ES256 private key in PKCS#8 format - publicKey: publicKeySPKIString, // ES256 public key in SPKI format -}); - const reader = new SHCReader({ - publicKey: publicKeySPKIString, // ES256 public key in SPKI format useVciDirectory: true, }); ``` diff --git a/src/shc/directory.ts b/src/shc/directory.ts index 802bb7e..3b29ea6 100644 --- a/src/shc/directory.ts +++ b/src/shc/directory.ts @@ -1,4 +1,4 @@ -import type { DirectoryJSON, Issuer, IssuerCrl, IssuerKey } from './types' +import type { DirectoryJSON, Issuer, IssuerCrlJSON, IssuerJSON, IssuerKey } from './types' /** * Directory is a lightweight representation of issuer metadata used by @@ -16,19 +16,19 @@ export class Directory { * * @param issuerInfo - Array of issuer entries (see {@link Issuer}) */ - constructor(private issuers: Issuer[]) {} + constructor(private issuers: Map) {} /** * Return the internal issuers array. * * @returns Array of `Issuer` objects */ - getIssuers(): Issuer[] { + getIssuers(): Map { return this.issuers } getIssuerByIss(iss: string): Issuer | undefined { - return this.issuers.find(issuer => issuer.iss === iss) + return this.issuers.get(iss) } /** @@ -61,27 +61,33 @@ export class Directory { * Build a Directory from a parsed JSON object matching the published * directory schema. * - * This method is defensive: if `issuer.iss` is missing or not a string it - * will be coerced to an empty string; if `keys` or `crls` are not arrays - * they will be treated as empty arrays. - * * @param directoryJson - The JSON object to convert into a Directory * @returns A new {@link Directory} instance * @example * const directory = Directory.fromJSON(parsedJson) */ static fromJSON(directoryJson: DirectoryJSON): Directory { - const data: Issuer[] = directoryJson.issuerInfo.map(({ issuer, keys, crls }) => { - const iss = typeof issuer?.iss === 'string' ? issuer.iss : '' + const issuersMap = new Map() + directoryJson.issuerInfo.forEach(({ issuer, keys, crls }) => { + const iss = typeof issuer?.iss === 'string' ? issuer.iss : undefined + if (!iss) { + console.warn('Skipping issuer with missing "iss" field') + return + } const validKeys = Array.isArray(keys) ? keys : [] - const validCrls = Array.isArray(crls) ? crls : [] - return { + const validCrls = Array.isArray(crls) + ? crls.map(({ rids, ...crls }) => ({ + ...crls, + rids: new Set(Array.isArray(rids) ? rids : []), + })) + : [] + issuersMap.set(iss, { iss, keys: validKeys, crls: validCrls, - } + }) }) - return new Directory(data) + return new Directory(issuersMap) } /** @@ -107,12 +113,12 @@ export class Directory { try { for (const issUrl of issUrls) { - const issuerInfo = { + const issuerInfo: IssuerJSON = { issuer: { iss: issUrl, }, keys: [] as IssuerKey[], - crls: [] as IssuerCrl[], + crls: [] as IssuerCrlJSON[], } const jwksUrl = `${issUrl}/.well-known/jwks.json` @@ -134,7 +140,7 @@ export class Directory { continue } const crl = await crlResponse.json() - if (crl) issuerInfo.crls.push(crl) + if (crl) issuerInfo.crls!.push(crl) } directoryJson.issuerInfo.push(issuerInfo) diff --git a/src/shc/errors.ts b/src/shc/errors.ts index 6a50739..7e4558d 100644 --- a/src/shc/errors.ts +++ b/src/shc/errors.ts @@ -172,6 +172,13 @@ export class SHCReaderConfigError extends SHCError { } } +/** + * Error thrown when SHC that's being read has been revoked. + * + * @public + * @group SHC + * @category Errors + */ export class SHCRevokedError extends SHCError { constructor(message: string) { super(message, 'SHC_REVOKED') diff --git a/src/shc/reader.ts b/src/shc/reader.ts index 9a22fa1..b5ece21 100644 --- a/src/shc/reader.ts +++ b/src/shc/reader.ts @@ -178,7 +178,7 @@ export class SHCReader { if (issuerCrls && vcRid) { // TODO: we must also handle the case where the SHC is set to be revoked issuerCrls.forEach(crl => { - if (crl.rids.includes(vcRid)) { + if (crl.rids.has(vcRid)) { throw new SHCRevokedError('This SHC has been revoked') } }) diff --git a/src/shc/types.ts b/src/shc/types.ts index 39ae894..671028b 100644 --- a/src/shc/types.ts +++ b/src/shc/types.ts @@ -30,6 +30,7 @@ export interface VerifiableCredential { /** The FHIR Bundle containing medical data. */ fhirBundle: FHIRBundle } + /** Optional revoked resource id */ rid?: string } } @@ -337,7 +338,7 @@ export interface IssuerCrl { /** Monotonic counter for CRL updates. */ ctr: number /** List of revoked resource ids (rids). */ - rids: string[] + rids: Set } /** @@ -353,7 +354,23 @@ export interface Issuer { /** Array of known JWK descriptors for the issuer. */ keys: IssuerKey[] /** Optional array of CRL entries for revoked resource ids. */ - crls?: IssuerCrl[] + crls: IssuerCrl[] +} + +export interface IssuerCrlJSON extends Omit { + /** List of revoked resource ids (rids). */ + rids: string[] +} + +export interface IssuerJSON { + issuer: { + /** Issuer base URL */ + iss: string + } + /** Array of JWK descriptors returned from the issuer's JWKS endpoint. */ + keys: IssuerKey[] + /** Optional CRL entries published alongside the directory. */ + crls?: IssuerCrlJSON[] } /** @@ -364,14 +381,5 @@ export interface Issuer { * @category Types */ export interface DirectoryJSON { - issuerInfo: { - issuer: { - /** Issuer base URL */ - iss: string - } - /** Array of JWK descriptors returned from the issuer's JWKS endpoint. */ - keys: IssuerKey[] - /** Optional CRL entries published alongside the directory. */ - crls?: IssuerCrl[] - }[] + issuerInfo: IssuerJSON[] } diff --git a/test/shc/directory.test.ts b/test/shc/directory.test.ts index b54f72e..7942014 100644 --- a/test/shc/directory.test.ts +++ b/test/shc/directory.test.ts @@ -7,22 +7,21 @@ function assertDirectoryFromSampleJson(directory: Directory) { const issuers = directory.getIssuers() expect(issuers).toHaveLength(4) - const issuer1 = issuers[0]! - expect(issuer1.iss).toEqual('https://example.com/issuer') + const issuer1 = directory.getIssuerByIss('https://example.com/issuer')! expect(issuer1.keys).toHaveLength(2) const crls1 = issuer1.crls! expect(crls1).toHaveLength(1) expect(crls1[0]!.kid).toEqual('kid-2-simple') - const issuer2 = issuers.find(i => i.iss === 'https://example.com/issuer2')! + const issuer2 = directory.getIssuerByIss('https://example.com/issuer2')! expect(issuer2).toBeDefined() expect(issuer2.keys).toHaveLength(1) - const issuer3 = issuers.find(i => i.iss === 'https://example.com/issuer3')! + const issuer3 = directory.getIssuerByIss('https://example.com/issuer3')! expect(issuer3).toBeDefined() expect(issuer3.keys).toHaveLength(1) - const issuer4 = issuers.find(i => i.iss === 'https://example.com/issuer4')! + const issuer4 = directory.getIssuerByIss('https://example.com/issuer4')! expect(issuer4).toBeDefined() expect(issuer4.keys).toHaveLength(1) const crls4 = issuer4.crls! @@ -104,29 +103,34 @@ describe('Directory', () => { iss: 123 as any, name: 'NonString Issuer', }, - // keys and crls omitted + }, + { + issuer: { + iss: '', + name: 'EmptyString Issuer', + }, }, ], } + const debugSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const directory = Directory.fromJSON(directoryJson as DirectoryJSON) const issuers = directory.getIssuers() - expect(issuers).toHaveLength(3) + expect(issuers).toHaveLength(2) - const missing = issuers.find(i => i.iss === 'https://missing.example/issuer')! + const missing = directory.getIssuerByIss('https://missing.example/issuer')! expect(missing).toBeDefined() expect(missing.keys).toEqual([]) expect(missing.crls).toEqual([]) - const invalid = issuers.find(i => i.iss === 'https://invalid.example/issuer')! + const invalid = directory.getIssuerByIss('https://invalid.example/issuer')! expect(invalid).toBeDefined() expect(invalid.keys).toEqual([]) expect(invalid.crls).toEqual([]) - const nonstring = issuers.find(i => i.iss === '')! - expect(nonstring).toBeDefined() - expect(nonstring.keys).toEqual([]) - expect(nonstring.crls).toEqual([]) + expect(debugSpy).toHaveBeenCalledTimes(2) + expect(debugSpy).toHaveBeenCalledWith('Skipping issuer with missing "iss" field') }) it('should create a directory from a list of issuer urls and fetch jwks and crls', async () => { @@ -171,7 +175,7 @@ describe('Directory', () => { const directory = await Directory.fromURLs([ISS_URL]) const issuers = directory.getIssuers() expect(issuers).toHaveLength(1) - const issuer = issuers[0]! + const issuer = [...issuers.values()][0]! expect(issuer.iss).toEqual(ISS_URL) // Only one CRL should be collected (kid1 failed) expect(issuer.crls).toHaveLength(1) @@ -238,9 +242,9 @@ describe('Directory', () => { // issuer3 jwks fetch will throw and be caught; only issuer1 and issuer2 should be present expect(issuers).toHaveLength(2) - const issuer1 = issuers.find(i => i.iss === ISS_URL)! - const issuer2 = issuers.find(i => i.iss === ISS_URL2)! - const issuer3 = issuers.find(i => i.iss === ISS_URL3) + const issuer1 = directory.getIssuerByIss(ISS_URL)! + const issuer2 = directory.getIssuerByIss(ISS_URL2)! + const issuer3 = directory.getIssuerByIss(ISS_URL3) expect(issuer1).toBeDefined() expect(issuer1.keys).toHaveLength(2) diff --git a/test/shc/shc.test.ts b/test/shc/shc.test.ts index c697e85..1a794fa 100644 --- a/test/shc/shc.test.ts +++ b/test/shc/shc.test.ts @@ -219,9 +219,17 @@ describe('SHC', () => { it('should verify SHC when reader is created with the VCI snapshot directory', async () => { const healthCard = await issuer.issue(validBundle) const readerWithDirectory = new SHCReader({ - ...readerConfig, + // As we are using the VCI snapshot, we don't + // need to provide the publicKey useVciDirectory: true, }) + const jws = healthCard.asJWS() + + const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') + const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') + const jwk = await exportJWK(keyObj) + const kid = await calculateJwkThumbprint(jwk) + const jwks = { keys: [{ ...jwk, kid }] } const originalFetch = globalThis.fetch const fetchMock = vi.fn().mockImplementation((url: string) => { @@ -232,11 +240,18 @@ describe('SHC', () => { }) } + if (url.includes('/.well-known/jwks.json')) { + return Promise.resolve({ + ok: true, + json: async () => jwks, + }) + } + return Promise.resolve({ ok: false, status: 404 }) }) ;(globalThis as any).fetch = fetchMock - const verifiedHealthCard = await readerWithDirectory.fromJWS(healthCard.asJWS()) + const verifiedHealthCard = await readerWithDirectory.fromJWS(jws) ;(globalThis as any).fetch = originalFetch const verifiedBundle = await verifiedHealthCard.asBundle() From 78d8216066ddd57f72e55fa0b1c8d5b0b4c75422 Mon Sep 17 00:00:00 2001 From: rvlb Date: Mon, 15 Dec 2025 15:12:06 -0300 Subject: [PATCH 04/10] Implement revocation check flow --- src/shc/directory.ts | 40 ++++++++++++++++++++++++++++-------- src/shc/jws/helpers.ts | 23 +++++++++++++++++++++ src/shc/jws/index.ts | 2 ++ src/shc/jws/jws-processor.ts | 27 ++---------------------- src/shc/reader.ts | 21 +++++++++++++------ src/shc/types.ts | 3 ++- test/shc/directory.test.ts | 12 +++++------ 7 files changed, 82 insertions(+), 46 deletions(-) create mode 100644 src/shc/jws/helpers.ts diff --git a/src/shc/directory.ts b/src/shc/directory.ts index 3b29ea6..457a99a 100644 --- a/src/shc/directory.ts +++ b/src/shc/directory.ts @@ -1,4 +1,11 @@ -import type { DirectoryJSON, Issuer, IssuerCrlJSON, IssuerJSON, IssuerKey } from './types' +import type { + DirectoryJSON, + Issuer, + IssuerCrl, + IssuerCrlJSON, + IssuerJSON, + IssuerKey, +} from './types' /** * Directory is a lightweight representation of issuer metadata used by @@ -75,16 +82,33 @@ export class Directory { return } const validKeys = Array.isArray(keys) ? keys : [] - const validCrls = Array.isArray(crls) - ? crls.map(({ rids, ...crls }) => ({ - ...crls, - rids: new Set(Array.isArray(rids) ? rids : []), - })) - : [] + + const crlsMap = new Map() + if (Array.isArray(crls)) { + crls.forEach(({ rids, ...crl }) => { + const ridsSet = new Set() + const ridsTimestamps = new Map() + rids?.forEach(rid => { + const [rawRid, timestamp] = rid.split('.', 2) + if (rawRid) { + ridsSet.add(rawRid) + if (timestamp) { + ridsTimestamps.set(rawRid, timestamp) + } + } + }) + const issuerCrl: IssuerCrl = { + ...crl, + rids: ridsSet, + ridsTimestamps, + } + crlsMap.set(crl.kid, issuerCrl) + }) + } issuersMap.set(iss, { iss, keys: validKeys, - crls: validCrls, + crls: crlsMap, }) }) return new Directory(issuersMap) diff --git a/src/shc/jws/helpers.ts b/src/shc/jws/helpers.ts new file mode 100644 index 0000000..ef22c85 --- /dev/null +++ b/src/shc/jws/helpers.ts @@ -0,0 +1,23 @@ +import { calculateJwkThumbprint, exportJWK, importJWK, importSPKI } from 'jose' + +/** + * Derives RFC7638 JWK Thumbprint (base64url-encoded SHA-256) from a public key to use as kid + */ +export async function deriveKidFromPublicKey( + publicKey: CryptoKey | Uint8Array | string | JsonWebKey +): Promise { + let keyObj: CryptoKey | Uint8Array + if (typeof publicKey === 'string') { + keyObj = await importSPKI(publicKey, 'ES256') + } else if (publicKey && typeof publicKey === 'object' && 'kty' in publicKey) { + // JsonWebKey object + keyObj = await importJWK(publicKey, 'ES256') + } else { + keyObj = publicKey as CryptoKey | Uint8Array + } + + const jwk = await exportJWK(keyObj) + // calculateJwkThumbprint defaults to SHA-256 and returns base64url string in jose v5 + const kid = await calculateJwkThumbprint(jwk) + return kid +} diff --git a/src/shc/jws/index.ts b/src/shc/jws/index.ts index 08efd00..6a7ad27 100644 --- a/src/shc/jws/index.ts +++ b/src/shc/jws/index.ts @@ -1,2 +1,4 @@ // JWS module barrel export + +export { deriveKidFromPublicKey } from './helpers.js' export { JWSProcessor } from './jws-processor.js' diff --git a/src/shc/jws/jws-processor.ts b/src/shc/jws/jws-processor.ts index f616708..4907933 100644 --- a/src/shc/jws/jws-processor.ts +++ b/src/shc/jws/jws-processor.ts @@ -2,10 +2,8 @@ import { base64url, CompactSign, - calculateJwkThumbprint, compactVerify, decodeProtectedHeader, - exportJWK, importJWK, importPKCS8, importSPKI, @@ -19,6 +17,7 @@ import { SignatureVerificationError, } from '../errors.js' import type { SHCJWT } from '../types.js' +import { deriveKidFromPublicKey } from './helpers.js' /** * Handles JWT/JWS signing and verification with ES256 algorithm. @@ -51,7 +50,7 @@ export class JWSProcessor { this.validateJWTPayload(payload) // Derive kid from public key - const kid = await this.deriveKidFromPublicKey(publicKey) + const kid = await deriveKidFromPublicKey(publicKey) // Protected header per SMART Health Cards const header: { alg: 'ES256'; kid: string; zip?: 'DEF' } = { @@ -94,28 +93,6 @@ export class JWSProcessor { } } - /** - * Derives RFC7638 JWK Thumbprint (base64url-encoded SHA-256) from a public key to use as kid - */ - private async deriveKidFromPublicKey( - publicKey: CryptoKey | Uint8Array | string | JsonWebKey - ): Promise { - let keyObj: CryptoKey | Uint8Array - if (typeof publicKey === 'string') { - keyObj = await importSPKI(publicKey, 'ES256') - } else if (publicKey && typeof publicKey === 'object' && 'kty' in publicKey) { - // JsonWebKey object - keyObj = await importJWK(publicKey, 'ES256') - } else { - keyObj = publicKey as CryptoKey | Uint8Array - } - - const jwk = await exportJWK(keyObj) - // calculateJwkThumbprint defaults to SHA-256 and returns base64url string in jose v5 - const kid = await calculateJwkThumbprint(jwk) - return kid - } - /** * Verifies a JWS and returns the decoded payload. * diff --git a/src/shc/reader.ts b/src/shc/reader.ts index b5ece21..c9026cd 100644 --- a/src/shc/reader.ts +++ b/src/shc/reader.ts @@ -10,6 +10,7 @@ import { VerificationError, } from './errors.js' import { FHIRBundleProcessor } from './fhir/bundle-processor.js' +import { deriveKidFromPublicKey } from './jws/helpers.js' import { JWSProcessor } from './jws/jws-processor.js' import { QRCodeGenerator } from './qr/qr-code-generator.js' import { SHC } from './shc.js' @@ -173,15 +174,23 @@ export class SHCReader { if (directory) { const issuer = directory.getIssuerByIss(payload.iss) - const issuerCrls = issuer?.crls const vcRid = payload.vc.rid - if (issuerCrls && vcRid) { - // TODO: we must also handle the case where the SHC is set to be revoked - issuerCrls.forEach(crl => { - if (crl.rids.has(vcRid)) { + if (vcRid) { + const kid = await deriveKidFromPublicKey(publicKeyToUse) + const crl = issuer?.crls.get(kid) + if (crl && crl.rids.has(vcRid)) { + const revocationTimestamp = crl.ridsTimestamps.get(vcRid) + if (!revocationTimestamp) { + // If the rid is present but has no timestamp, consider it revoked throw new SHCRevokedError('This SHC has been revoked') } - }) + // If the issuanceDate happened before the + // revocation timestamp, consider it revoked + const issuanceDateTimestamp = String(payload.nbf).split('.')[0] + if (BigInt(issuanceDateTimestamp!) <= BigInt(revocationTimestamp)) { + throw new SHCRevokedError('This SHC has been revoked') + } + } } } diff --git a/src/shc/types.ts b/src/shc/types.ts index 671028b..a799692 100644 --- a/src/shc/types.ts +++ b/src/shc/types.ts @@ -339,6 +339,7 @@ export interface IssuerCrl { ctr: number /** List of revoked resource ids (rids). */ rids: Set + ridsTimestamps: Map } /** @@ -354,7 +355,7 @@ export interface Issuer { /** Array of known JWK descriptors for the issuer. */ keys: IssuerKey[] /** Optional array of CRL entries for revoked resource ids. */ - crls: IssuerCrl[] + crls: Map } export interface IssuerCrlJSON extends Omit { diff --git a/test/shc/directory.test.ts b/test/shc/directory.test.ts index 7942014..c2b4004 100644 --- a/test/shc/directory.test.ts +++ b/test/shc/directory.test.ts @@ -11,7 +11,7 @@ function assertDirectoryFromSampleJson(directory: Directory) { expect(issuer1.keys).toHaveLength(2) const crls1 = issuer1.crls! expect(crls1).toHaveLength(1) - expect(crls1[0]!.kid).toEqual('kid-2-simple') + expect(crls1.get('kid-2-simple')!.kid).toEqual('kid-2-simple') const issuer2 = directory.getIssuerByIss('https://example.com/issuer2')! expect(issuer2).toBeDefined() @@ -121,13 +121,13 @@ describe('Directory', () => { const missing = directory.getIssuerByIss('https://missing.example/issuer')! expect(missing).toBeDefined() - expect(missing.keys).toEqual([]) - expect(missing.crls).toEqual([]) + expect(missing.keys).toHaveLength(0) + expect(missing.crls).toHaveLength(0) const invalid = directory.getIssuerByIss('https://invalid.example/issuer')! expect(invalid).toBeDefined() - expect(invalid.keys).toEqual([]) - expect(invalid.crls).toEqual([]) + expect(invalid.keys).toHaveLength(0) + expect(invalid.crls).toHaveLength(0) expect(debugSpy).toHaveBeenCalledTimes(2) expect(debugSpy).toHaveBeenCalledWith('Skipping issuer with missing "iss" field') @@ -179,7 +179,7 @@ describe('Directory', () => { expect(issuer.iss).toEqual(ISS_URL) // Only one CRL should be collected (kid1 failed) expect(issuer.crls).toHaveLength(1) - expect(issuer.crls![0]!.kid).toEqual('kid2') + expect(issuer.crls.get('kid2')!.kid).toEqual('kid2') // Both keys should be present expect(issuer.keys).toHaveLength(2) From 5c7e82d44456bc0e6b51825de6a608acb5360092 Mon Sep 17 00:00:00 2001 From: rvlb Date: Mon, 15 Dec 2025 18:12:21 -0300 Subject: [PATCH 05/10] Add SHC revocation tests --- README.md | 2 +- src/shc/directory.ts | 7 +++ src/shc/reader.ts | 15 +++-- src/shc/types.ts | 26 +++++++- src/shc/vc.ts | 1 + test/shc/shc.test.ts | 138 ++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 179 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 627f66e..35c8a7d 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ The library can optionally consult the VCI directory snapshot to obtain a canoni Example usage: ```typescript -import { SHCIssuer, SHCReader } from 'kill-the-clipboard'; +import { SHCReader } from 'kill-the-clipboard'; const reader = new SHCReader({ useVciDirectory: true, diff --git a/src/shc/directory.ts b/src/shc/directory.ts index 457a99a..3d63c84 100644 --- a/src/shc/directory.ts +++ b/src/shc/directory.ts @@ -85,10 +85,17 @@ export class Directory { const crlsMap = new Map() if (Array.isArray(crls)) { + // We need to process the raw CRLs data from the directory JSON + // to convert them into the apprpriate format that's used in the + // Directory class, as the former stores them as an Array and we + // store them internally as a Map in the latter. crls.forEach(({ rids, ...crl }) => { const ridsSet = new Set() const ridsTimestamps = new Map() rids?.forEach(rid => { + // The rid may be stored using a "[rid].[revocation_timestamp]" + // format in the CRL, so we need to split and store that data in + // order to validate if a SHC is revoked in a more performatic flow const [rawRid, timestamp] = rid.split('.', 2) if (rawRid) { ridsSet.add(rawRid) diff --git a/src/shc/reader.ts b/src/shc/reader.ts index c9026cd..899f268 100644 --- a/src/shc/reader.ts +++ b/src/shc/reader.ts @@ -172,24 +172,29 @@ export class SHCReader { directory = await Directory.fromVCI() } + // If there's a directory, we can check if the SHC is revoked + // based on the issuer's CRLs. if (directory) { const issuer = directory.getIssuerByIss(payload.iss) const vcRid = payload.vc.rid - if (vcRid) { + if (issuer && vcRid) { const kid = await deriveKidFromPublicKey(publicKeyToUse) - const crl = issuer?.crls.get(kid) + const crl = issuer.crls.get(kid) + // If the CRL contains the rid, the SHC might + // have been revoked if (crl && crl.rids.has(vcRid)) { const revocationTimestamp = crl.ridsTimestamps.get(vcRid) if (!revocationTimestamp) { - // If the rid is present but has no timestamp, consider it revoked + // If the rid has no associated timestamp, it's revoked throw new SHCRevokedError('This SHC has been revoked') } - // If the issuanceDate happened before the - // revocation timestamp, consider it revoked + // If the SHC was issued before the revocation timestamp, it's revoked const issuanceDateTimestamp = String(payload.nbf).split('.')[0] if (BigInt(issuanceDateTimestamp!) <= BigInt(revocationTimestamp)) { throw new SHCRevokedError('This SHC has been revoked') } + // If it has been issued after the revocation timestamp, + // it's valid and no further action is required } } } diff --git a/src/shc/types.ts b/src/shc/types.ts index a799692..e379ed5 100644 --- a/src/shc/types.ts +++ b/src/shc/types.ts @@ -30,8 +30,8 @@ export interface VerifiableCredential { /** The FHIR Bundle containing medical data. */ fhirBundle: FHIRBundle } - /** Optional revoked resource id */ - rid?: string + /** Optional revocation identifier */ + rid: string | null } } @@ -199,6 +199,11 @@ export interface VerifiableCredentialParams { * - `https://smarthealth.cards#laboratory` */ includeAdditionalTypes?: string[] + + /** + * An optional revocation identifier to include in the credential + */ + rid?: string } /** @@ -339,6 +344,7 @@ export interface IssuerCrl { ctr: number /** List of revoked resource ids (rids). */ rids: Set + /** Map of revoked resource ids to their revocation timestamps. */ ridsTimestamps: Map } @@ -358,11 +364,25 @@ export interface Issuer { crls: Map } -export interface IssuerCrlJSON extends Omit { +/** + * Public issuer CRL JSON shape used in published directory files. + * + * @public + * @group SHC + * @category Types + */ +export interface IssuerCrlJSON extends Omit { /** List of revoked resource ids (rids). */ rids: string[] } +/** + * Public issuer metadata JSON shape used in published directory files. + * + * @public + * @group SHC + * @category Types + */ export interface IssuerJSON { issuer: { /** Issuer base URL */ diff --git a/src/shc/vc.ts b/src/shc/vc.ts index 264cdf6..f6ab1de 100644 --- a/src/shc/vc.ts +++ b/src/shc/vc.ts @@ -32,6 +32,7 @@ export class VerifiableCredentialProcessor { fhirVersion: fhirVersion, fhirBundle: fhirBundle, }, + rid: config.rid || null, }, } diff --git a/test/shc/shc.test.ts b/test/shc/shc.test.ts index 1a794fa..8b53ecb 100644 --- a/test/shc/shc.test.ts +++ b/test/shc/shc.test.ts @@ -1,5 +1,5 @@ // biome-ignore-all lint/suspicious/noExplicitAny: The test needs to use `any` to test error cases -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BundleValidationError, type FHIRBundle, @@ -10,6 +10,7 @@ import { SHCReader, SHCReaderConfigError, type SHCReaderConfigParams, + SHCRevokedError, SignatureVerificationError, } from '@/index' import { Directory } from '@/shc/directory' @@ -157,6 +158,10 @@ describe('SHC', () => { }) describe('verification with SHCReader', () => { + afterEach(() => { + vi.useRealTimers() + }) + it('should verify a valid SMART Health Card', async () => { const healthCard = await issuer.issue(validBundle) const verifiedHealthCard = await reader.fromJWS(healthCard.asJWS()) @@ -259,6 +264,137 @@ describe('SHC', () => { expect(verifiedBundle).toEqual(validBundle) }) + it('should throw SHCRevokedError if the SHC is revoked without timestamp', async () => { + const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') + const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') + const jwk = await exportJWK(keyObj) + const kid = await calculateJwkThumbprint(jwk) + + const directory = Directory.fromJSON({ + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + }, + keys: [ + { + kty: 'EC', + kid, + }, + ], + crls: [ + { + kid, + method: 'rid', + ctr: 1, + rids: ['revoked-1'], + }, + ], + }, + ], + }) + const readerWithDirectory = new SHCReader({ + ...readerConfig, + issuerDirectory: directory, + }) + + const healthCard = await issuer.issue(validBundle, { rid: 'revoked-1' }) + const jws = healthCard.asJWS() + + await expect(readerWithDirectory.fromJWS(jws)).rejects.toThrow(SHCRevokedError) + }) + + it('should throw SHCRevokedError if the SHC is revoked through a timestamped rid', async () => { + vi.setSystemTime(new Date('2023-10-28T12:00:00Z')) + + const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') + const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') + const jwk = await exportJWK(keyObj) + const kid = await calculateJwkThumbprint(jwk) + + const revocationTimestamp = new Date('2023-11-19T12:00:00Z').getTime() / 1000 + + const directory = Directory.fromJSON({ + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + }, + keys: [ + { + kty: 'EC', + kid, + }, + ], + crls: [ + { + kid, + method: 'rid', + ctr: 1, + rids: [`revoked-1.${revocationTimestamp}`], + }, + ], + }, + ], + }) + const readerWithDirectory = new SHCReader({ + ...readerConfig, + issuerDirectory: directory, + }) + + const healthCard = await issuer.issue(validBundle, { rid: 'revoked-1' }) + const jws = healthCard.asJWS() + + await expect(readerWithDirectory.fromJWS(jws)).rejects.toThrow(SHCRevokedError) + }) + + it('should not throw SHCRevokedError if the SHC was issued after the timestamped rid', async () => { + vi.setSystemTime(new Date('2023-10-28T12:00:00Z')) + + const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') + const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') + const jwk = await exportJWK(keyObj) + const kid = await calculateJwkThumbprint(jwk) + + const revocationTimestamp = new Date('2023-10-14T12:00:00Z').getTime() / 1000 + + const directory = Directory.fromJSON({ + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + }, + keys: [ + { + kty: 'EC', + kid, + }, + ], + crls: [ + { + kid, + method: 'rid', + ctr: 1, + rids: [`revoked-1.${revocationTimestamp}`], + }, + ], + }, + ], + }) + const readerWithDirectory = new SHCReader({ + ...readerConfig, + issuerDirectory: directory, + }) + + const healthCard = await issuer.issue(validBundle, { rid: 'revoked-1' }) + // Even through the rid matches, the SHC was issued after the revocation timestamp + const verifiedHealthCard = await readerWithDirectory.fromJWS(healthCard.asJWS()) + + const verifiedBundle = await verifiedHealthCard.asBundle() + expect(verifiedBundle).toBeDefined() + expect(verifiedBundle).toEqual(validBundle) + }) + it('should throw error for invalid JWS', async () => { await expect(reader.fromJWS('invalid.jws.signature')).rejects.toThrow( SignatureVerificationError From 30371ef85bc44e441881715f8dd329bdf0adf5b4 Mon Sep 17 00:00:00 2001 From: rvlb Date: Tue, 16 Dec 2025 10:52:51 -0300 Subject: [PATCH 06/10] Fix public key flow when using directories --- src/shc/directory.ts | 13 ++- src/shc/reader.ts | 86 ++++++++++++++----- src/shc/types.ts | 8 +- test/helpers.ts | 8 ++ test/shc/directory.test.ts | 2 - test/shc/jwks-fetching.test.ts | 12 +-- test/shc/jws-processor.test.ts | 9 +- test/shc/shc.test.ts | 152 ++++++++++++++++++++------------- 8 files changed, 191 insertions(+), 99 deletions(-) diff --git a/src/shc/directory.ts b/src/shc/directory.ts index 3d63c84..f519e17 100644 --- a/src/shc/directory.ts +++ b/src/shc/directory.ts @@ -34,6 +34,9 @@ export class Directory { return this.issuers } + /** + * Get an issuer by its `iss` identifier. + */ getIssuerByIss(iss: string): Issuer | undefined { return this.issuers.get(iss) } @@ -81,7 +84,13 @@ export class Directory { console.warn('Skipping issuer with missing "iss" field') return } - const validKeys = Array.isArray(keys) ? keys : [] + + const keysMap = new Map() + if (Array.isArray(keys)) { + keys.forEach(key => { + keysMap.set(key.kid, key) + }) + } const crlsMap = new Map() if (Array.isArray(crls)) { @@ -114,7 +123,7 @@ export class Directory { } issuersMap.set(iss, { iss, - keys: validKeys, + keys: keysMap, crls: crlsMap, }) }) diff --git a/src/shc/reader.ts b/src/shc/reader.ts index 899f268..9c39ae6 100644 --- a/src/shc/reader.ts +++ b/src/shc/reader.ts @@ -141,11 +141,33 @@ export class SHCReader { * @throws {@link CredentialValidationError} If verifiable credential validation fails * @throws {@link JWSError} If JWS processing fails * @throws {@link VerificationError} For unexpected errors during verification or JWKS resolution + * @throws {@link SHCRevokedError} If the SMART Health Card has been revoked */ async fromJWS(jws: string): Promise { try { - // Resolve public key if not provided via issuer JWKS based on JWS header/payload + // Check if a directory was provided to the reader + let directory: Directory | null = null + if (this.config.issuerDirectory) { + directory = this.config.issuerDirectory + } else if (this.config.useVciDirectory) { + directory = await Directory.fromVCI() + } + + // First we try to get the public key from the config let publicKeyToUse = this.config.publicKey + + // If there's no public key in the config, resolve it from the directory + if (!publicKeyToUse && directory) { + try { + publicKeyToUse = await this.resolvePublicKeyFromDirectory(jws, directory) + } catch (error) { + console.warn( + `Failed to resolve public key from directory, will try to resolve from JWKS: ${error}` + ) + } + } + + // If all else fails, resolve public key via issuer JWKS based on JWS header/payload if (!publicKeyToUse) { publicKeyToUse = await this.resolvePublicKeyFromJWKS(jws) } @@ -163,17 +185,8 @@ export class SHCReader { const vc: VerifiableCredential = { vc: payload.vc } this.vcProcessor.validate(vc) - // Step 4: Get the issuer info from a provided directory - // or from the VCI snapshot - let directory: Directory | null = null - if (this.config.issuerDirectory) { - directory = this.config.issuerDirectory - } else if (this.config.useVciDirectory) { - directory = await Directory.fromVCI() - } - - // If there's a directory, we can check if the SHC is revoked - // based on the issuer's CRLs. + // Step 4: If there's a directory, we can check if the SHC + // is revoked based on the issuer's CRLs. if (directory) { const issuer = directory.getIssuerByIss(payload.iss) const vcRid = payload.vc.rid @@ -210,6 +223,46 @@ export class SHCReader { } } + /** + * Obtains the JWS header and payload without signature verification. + * @throws {@link VerificationError} when the key cannot be resolved + */ + private async parseUnverifiedJWS(jws: string) { + // Decode without verification to obtain header.kid and payload.iss + const { header, payload } = await this.jwsProcessor.parseUnverified(jws) + + if (!payload.iss || typeof payload.iss !== 'string') { + throw new VerificationError("Cannot resolve JWKS: missing 'iss' in payload") + } + if (!header.kid || typeof header.kid !== 'string') { + throw new VerificationError("Cannot resolve JWKS: missing 'kid' in JWS header") + } + + return { header, payload } + } + + /** + * Resolves the public key for a JWS using the information contained on a directory instance. + * @throws {@link VerificationError} when the key cannot be resolved + */ + private async resolvePublicKeyFromDirectory( + jws: string, + directory: Directory + ): Promise { + // Decode without verification to obtain header.kid and payload.iss + const { header, payload } = await this.parseUnverifiedJWS(jws) + const issuer = directory.getIssuerByIss(payload.iss) + if (!issuer) { + throw new VerificationError(`Issuer not found in directory for iss: ${payload.iss}`) + } + // From parseUnverifiedJWS we already ensured header.kid is present + const matching = issuer.keys.get(header.kid!) + if (!matching) { + throw new VerificationError(`No matching key found in issuer for kid '${header.kid}'`) + } + return await importJWK(matching as JsonWebKey, 'ES256') + } + /** * Resolves the public key for a JWS using the issuer's well-known JWKS endpoint when no key is provided. * @throws {@link VerificationError} when the key cannot be resolved @@ -217,14 +270,7 @@ export class SHCReader { private async resolvePublicKeyFromJWKS(jws: string): Promise { try { // Decode without verification to obtain header.kid and payload.iss - const { header, payload } = await this.jwsProcessor.parseUnverified(jws) - - if (!payload.iss || typeof payload.iss !== 'string') { - throw new VerificationError("Cannot resolve JWKS: missing 'iss' in payload") - } - if (!header.kid || typeof header.kid !== 'string') { - throw new VerificationError("Cannot resolve JWKS: missing 'kid' in JWS header") - } + const { header, payload } = await this.parseUnverifiedJWS(jws) // Build JWKS URL from issuer origin const jwksUrl = `${payload.iss.replace(/\/$/, '')}/.well-known/jwks.json` diff --git a/src/shc/types.ts b/src/shc/types.ts index e379ed5..e56ae0a 100644 --- a/src/shc/types.ts +++ b/src/shc/types.ts @@ -322,7 +322,7 @@ export type QRCodeConfig = Required */ export interface IssuerKey { /** Key type (e.g. 'EC' or 'RSA'). */ - kty: string + kty?: string /** Key ID used to identify the key in JWKS responses. */ kid: string } @@ -358,9 +358,9 @@ export interface IssuerCrl { export interface Issuer { /** Issuer base URL (the `iss` claim value). */ iss: string - /** Array of known JWK descriptors for the issuer. */ - keys: IssuerKey[] - /** Optional array of CRL entries for revoked resource ids. */ + /** JWK descriptors for the issuer. */ + keys: Map + /** CRL entries for revoked resource ids. */ crls: Map } diff --git a/test/helpers.ts b/test/helpers.ts index f4838a6..12e4830 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -111,6 +111,14 @@ export function decodeQRFromDataURL(dataURL: string): string | null { } } +export const buildTestJwkData = async () => { + const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') + const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') + const jwk = await exportJWK(keyObj) + const kid = await calculateJwkThumbprint(jwk) + return { jwk, kid } +} + export const SAMPLE_DIRECTORY_JSON = { directory: 'https://example.com/keystore/directory.json', issuerInfo: [ diff --git a/test/shc/directory.test.ts b/test/shc/directory.test.ts index c2b4004..4401d8a 100644 --- a/test/shc/directory.test.ts +++ b/test/shc/directory.test.ts @@ -246,11 +246,9 @@ describe('Directory', () => { const issuer2 = directory.getIssuerByIss(ISS_URL2)! const issuer3 = directory.getIssuerByIss(ISS_URL3) - expect(issuer1).toBeDefined() expect(issuer1.keys).toHaveLength(2) expect(issuer1.crls).toHaveLength(1) - expect(issuer2).toBeDefined() expect(issuer2.keys).toHaveLength(1) expect(issuer2.crls).toHaveLength(1) diff --git a/test/shc/jwks-fetching.test.ts b/test/shc/jwks-fetching.test.ts index a6af5f2..b5a0b06 100644 --- a/test/shc/jwks-fetching.test.ts +++ b/test/shc/jwks-fetching.test.ts @@ -1,7 +1,12 @@ // biome-ignore-all lint/suspicious/noExplicitAny: The test needs to use `any` to mock the fetch function import { describe, expect, it, vi } from 'vitest' import { SHCIssuer, SHCReader, VerificationError } from '@/index' -import { createValidFHIRBundle, testPrivateKeyPKCS8, testPublicKeySPKI } from '../helpers' +import { + buildTestJwkData, + createValidFHIRBundle, + testPrivateKeyPKCS8, + testPublicKeySPKI, +} from '../helpers' describe('JWKS fetching for SHCReader', () => { it('fetches issuer JWKS and verifies using matching kid when publicKey is omitted', async () => { @@ -17,10 +22,7 @@ describe('JWKS fetching for SHCReader', () => { const healthCard = await issuer.issue(createValidFHIRBundle()) const jws = healthCard.asJWS() - const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') - const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') - const jwk = await exportJWK(keyObj) - const kid = await calculateJwkThumbprint(jwk) + const { jwk, kid } = await buildTestJwkData() const jwks = { keys: [{ ...jwk, kid }] } const originalFetch = globalThis.fetch diff --git a/test/shc/jws-processor.test.ts b/test/shc/jws-processor.test.ts index 72672a8..a76e09f 100644 --- a/test/shc/jws-processor.test.ts +++ b/test/shc/jws-processor.test.ts @@ -12,6 +12,7 @@ import { VerifiableCredentialProcessor, } from '@/index' import { + buildTestJwkData, createValidFHIRBundle, testPrivateKeyJWK, testPrivateKeyPKCS8, @@ -51,15 +52,11 @@ describe('JWSProcessor', () => { const parts = jws.split('.') expect(parts).toHaveLength(3) - const { decodeProtectedHeader, importSPKI, exportJWK, calculateJwkThumbprint } = await import( - 'jose' - ) + const { decodeProtectedHeader } = await import('jose') const header = decodeProtectedHeader(jws) expect(header.alg).toBe('ES256') - const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') - const jwk = await exportJWK(keyObj) - const expectedKid = await calculateJwkThumbprint(jwk) + const { jwk, kid: expectedKid } = await buildTestJwkData() expect(header.kid).toBe(expectedKid) const verified = await processor.verify(jws, testPublicKeySPKI) expect(verified.iss).toBe(validJWTPayload.iss) diff --git a/test/shc/shc.test.ts b/test/shc/shc.test.ts index 8b53ecb..441de79 100644 --- a/test/shc/shc.test.ts +++ b/test/shc/shc.test.ts @@ -15,6 +15,7 @@ import { } from '@/index' import { Directory } from '@/shc/directory' import { + buildTestJwkData, createInvalidBundle, createValidFHIRBundle, decodeQRFromDataURL, @@ -174,31 +175,27 @@ describe('SHC', () => { it('should verify SHC when reader created with a directory', async () => { const healthCard = await issuer.issue(validBundle) + const { jwk, kid } = await buildTestJwkData() + const jwks = { keys: [{ ...jwk, kid }] } + const ISS_URL = 'https://example.com/issuer' const originalFetch = globalThis.fetch const fetchMock = vi.fn().mockImplementation((url: string) => { if (url === `${ISS_URL}/.well-known/jwks.json`) { return Promise.resolve({ ok: true, - json: async () => ({ - keys: [ - { - kid: 'kid1', - kty: 'EC', - }, - ], - }), + json: async () => jwks, }) } - if (url === `${ISS_URL}/.well-known/crl/kid1.json`) { + if (url === `${ISS_URL}/.well-known/crl/${kid}.json`) { return Promise.resolve({ ok: true, json: async () => ({ - kid: 'kid1', + kid: kid, method: 'rid', ctr: 1, - rids: ['imrevoked'], + rids: [], }), }) } @@ -210,7 +207,6 @@ describe('SHC', () => { ;(globalThis as any).fetch = originalFetch const readerWithDirectory = new SHCReader({ - ...readerConfig, issuerDirectory: directory, }) @@ -221,34 +217,97 @@ describe('SHC', () => { expect(verifiedBundle).toEqual(validBundle) }) - it('should verify SHC when reader is created with the VCI snapshot directory', async () => { + it('should verify SHC using JWKS if directory public key fails', async () => { const healthCard = await issuer.issue(validBundle) - const readerWithDirectory = new SHCReader({ - // As we are using the VCI snapshot, we don't - // need to provide the publicKey - useVciDirectory: true, - }) - const jws = healthCard.asJWS() - const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') - const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') - const jwk = await exportJWK(keyObj) - const kid = await calculateJwkThumbprint(jwk) + const { jwk, kid } = await buildTestJwkData() const jwks = { keys: [{ ...jwk, kid }] } + const ISS_URL = 'https://example.com/issuer' const originalFetch = globalThis.fetch const fetchMock = vi.fn().mockImplementation((url: string) => { - if (url.includes('vci_snapshot.json')) { + if (url === `${ISS_URL}/.well-known/jwks.json`) { return Promise.resolve({ ok: true, - json: async () => SAMPLE_DIRECTORY_JSON, + json: async () => jwks, }) } - if (url.includes('/.well-known/jwks.json')) { + return Promise.resolve({ ok: false, status: 404, json: async () => ({}) }) + }) + + const directory = Directory.fromJSON(SAMPLE_DIRECTORY_JSON) + + const readerWithDirectory = new SHCReader({ + issuerDirectory: directory, + }) + + const debugSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const jws = healthCard.asJWS() + ;(globalThis as any).fetch = fetchMock + const verifiedHealthCard = await readerWithDirectory.fromJWS(jws) + ;(globalThis as any).fetch = originalFetch + const verifiedBundle = await verifiedHealthCard.asBundle() + + expect(verifiedBundle).toBeDefined() + expect(verifiedBundle).toEqual(validBundle) + + expect(debugSpy).toHaveBeenCalledTimes(1) + }) + + it('should verify SHC when reader is created with the VCI snapshot directory', async () => { + const healthCard = await issuer.issue(validBundle) + const readerWithDirectory = new SHCReader({ + useVciDirectory: true, + }) + const jws = healthCard.asJWS() + + const { jwk, kid } = await buildTestJwkData() + + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url.includes('vci_snapshot.json')) { return Promise.resolve({ ok: true, - json: async () => jwks, + json: async () => ({ + directory: 'https://example.com/keystore/directory.json', + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + name: 'Example Issuer 1', + }, + keys: [ + { ...jwk, kid }, + { + kty: 'EC', + kid: 'kid-2-simple', + }, + ], + crls: [ + { + kid: 'kid-2-simple', + method: 'rid', + ctr: 1, + rids: ['revoked-1'], + }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer2', + name: 'Example Issuer 2', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-A-simple', + }, + ], + }, + ], + }), }) } @@ -265,10 +324,7 @@ describe('SHC', () => { }) it('should throw SHCRevokedError if the SHC is revoked without timestamp', async () => { - const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') - const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') - const jwk = await exportJWK(keyObj) - const kid = await calculateJwkThumbprint(jwk) + const { jwk, kid } = await buildTestJwkData() const directory = Directory.fromJSON({ issuerInfo: [ @@ -276,12 +332,7 @@ describe('SHC', () => { issuer: { iss: 'https://example.com/issuer', }, - keys: [ - { - kty: 'EC', - kid, - }, - ], + keys: [{ ...jwk, kid }], crls: [ { kid, @@ -294,7 +345,6 @@ describe('SHC', () => { ], }) const readerWithDirectory = new SHCReader({ - ...readerConfig, issuerDirectory: directory, }) @@ -307,10 +357,7 @@ describe('SHC', () => { it('should throw SHCRevokedError if the SHC is revoked through a timestamped rid', async () => { vi.setSystemTime(new Date('2023-10-28T12:00:00Z')) - const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') - const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') - const jwk = await exportJWK(keyObj) - const kid = await calculateJwkThumbprint(jwk) + const { jwk, kid } = await buildTestJwkData() const revocationTimestamp = new Date('2023-11-19T12:00:00Z').getTime() / 1000 @@ -320,12 +367,7 @@ describe('SHC', () => { issuer: { iss: 'https://example.com/issuer', }, - keys: [ - { - kty: 'EC', - kid, - }, - ], + keys: [{ ...jwk, kid }], crls: [ { kid, @@ -338,7 +380,6 @@ describe('SHC', () => { ], }) const readerWithDirectory = new SHCReader({ - ...readerConfig, issuerDirectory: directory, }) @@ -351,10 +392,7 @@ describe('SHC', () => { it('should not throw SHCRevokedError if the SHC was issued after the timestamped rid', async () => { vi.setSystemTime(new Date('2023-10-28T12:00:00Z')) - const { importSPKI, exportJWK, calculateJwkThumbprint } = await import('jose') - const keyObj = await importSPKI(testPublicKeySPKI, 'ES256') - const jwk = await exportJWK(keyObj) - const kid = await calculateJwkThumbprint(jwk) + const { jwk, kid } = await buildTestJwkData() const revocationTimestamp = new Date('2023-10-14T12:00:00Z').getTime() / 1000 @@ -364,12 +402,7 @@ describe('SHC', () => { issuer: { iss: 'https://example.com/issuer', }, - keys: [ - { - kty: 'EC', - kid, - }, - ], + keys: [{ ...jwk, kid }], crls: [ { kid, @@ -382,7 +415,6 @@ describe('SHC', () => { ], }) const readerWithDirectory = new SHCReader({ - ...readerConfig, issuerDirectory: directory, }) From 23bbfe32910d453e72ba5a84f9535422f6d82090 Mon Sep 17 00:00:00 2001 From: rvlb Date: Tue, 16 Dec 2025 11:01:11 -0300 Subject: [PATCH 07/10] Update readme --- README.md | 81 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 35c8a7d..97b5119 100644 --- a/README.md +++ b/README.md @@ -146,23 +146,65 @@ const healthCardFromQR = await reader.fromQRNumeric(qrNumericStrings); console.log('Bundle from QR:', await healthCardFromQR.asBundle()); ``` +### Usage with Directories + +A `Directory` is a lightweight, serializable collection of issuer metadata used by this library: each entry contains an issuer URL, its JWK descriptors (public keys) and optional Card Revocation List (CRL) entries. + +It provides a local/cached source of JWKs + CRL data so verification and lookup code can resolve public keys and revocation information without hitting network endpoints repeatedly, and without requiring that information to be passed in the reader configuration. + +It can be built through the following methods: + +1. Build from a JSON manifest: `Directory.fromJSON` +2. Build by fetching from issuer endpoints: `Directory.fromURLs` +3. Build by loading the VCI snapshot: `Directory.fromVCI` + +The directory object can be passed down to the SHCReader, doing so will add another step to the health card verification process to check if the health card has been revoked. + +Example usage: + +```typescript +import { SHCReader, Directory } from 'kill-the-clipboard'; + +// This examples considers a SHC instance (healthCard) has already been issued + +// Build a directory instance +const directory = await Directory.fromURLs(['https://your-healthcare-org.com']) + +// Configure reader for verification, providing the directory instance +const reader = new SHCReader({ + // No public key is required, as the directory will already have the necessary data + issuerDirectory: directory, +}); + +// Verify and read the health card +const verifiedHealthCard = await reader.fromJWS(healthCard.asJWS()); +const verifiedBundle = await verifiedHealthCard.asBundle(); +console.log('Verified FHIR Bundle:', verifiedBundle); +``` + #### Usage with the VCI Directory Snapshot -The library can optionally consult the VCI directory snapshot to obtain a canonical collection of issuer metadata (JWKS and CRLs). This is useful when you want an authoritative, ready-made source of issuer keys without assembling or maintaining your own `Directory` object. In order to bundle it directly into your `SHCReader` object, you may provide the `useVciDirectory: true` parameter to its constructor. When using this, you don't need to provide a public key, as it'll also be fetched automatically. +The VCI Directory Snapshot is a set of verifiable issuers data that can be used to validate a `SHC` without the necessity of providing a custom directory instance to the `SHCReader`. The VCI itself is a coalition of public and private organizations that provide those informations make it publicly available to be consumed. + +In order to use this, you should provide the `useVciDirectory` parameter when creating the reader instance. Since it'll also go through the directory usage flow from the previous section, it's also not necessary to provide a public key in the configuration. Example usage: ```typescript import { SHCReader } from 'kill-the-clipboard'; +// This examples considers a SHC instance (healthCard) has already been issued + const reader = new SHCReader({ + // No external directory or public key is required useVciDirectory: true, }); -``` - -#### Usage with a generic Directory object -Optionally, you may also provide your own `Directory` object to the `SHCReader` constructor, through the `issuerDirectory` parameter. For more information on how to build a `Directory` instance, please check the [Directories section](#directories). +// Verify and read the health card +const verifiedHealthCard = await reader.fromJWS(healthCard.asJWS()); +const verifiedBundle = await verifiedHealthCard.asBundle(); +console.log('Verified FHIR Bundle:', verifiedBundle); +``` ### SMART Health Links Quick Start @@ -308,35 +350,6 @@ console.log('Resolved FHIR resources:', resolved.fhirResources); This example above demonstrates the complete lifecycle: SHL generation, content addition, manifest builder persistence in server-side database, manifest serving, and client-side resolution. In a real application, you would implement persistence for the manifest builder state and serve the manifest endpoint from your backend server. -### Directories -A `Directory` is a lightweight, serializable collection of issuer metadata used by this library: each entry contains an issuer URL, its JWK descriptors (public keys) and optional CRL entries (revoked resource ids). - -It provides a local/cached source of JWKS + CRL data so verification and lookup code can resolve public keys and revocation information without hitting network endpoints repeatedly. - -It can be built through the following methods: - -1. Build from a JSON manifest: `Directory.fromJSON` -2. Build by fetching from issuer endpoints: `Directory.fromURLs` -3. Build by loading the VCI snapshot: `Directory.fromVCI` - -#### Building a Directory from VCI Snapshot -`Directory.fromVCI` is a convenience helper that fetches the VCI Directory snapshot published by The Commons Project and returns a Directory instance built from that snapshot. - -Use it when you want a quick, canonical directory of issuer metadata (JWKS + CRLs) without manually assembling or maintaining a directory JSON. - -```typescript -import { Directory } from 'kill-the-clipboard' - -// top-level async context or inside an async function -try { - const directory = await Directory.fromVCI() - const issuers = directory.getIssuers() - console.log('Loaded issuers:', issuers.length) -} catch (err) { - console.error('Failed to load VCI Directory:', err) -} -``` - ### Error Handling The library provides granular error handling with specific error codes for different failure scenarios. Check each method documentation for the specific errors that can be thrown. From c3d01d63c915a5aeb16a3272a6cfb29af938ea97 Mon Sep 17 00:00:00 2001 From: rvlb Date: Tue, 16 Dec 2025 14:01:37 -0300 Subject: [PATCH 08/10] Add tests to handle duplicate data --- src/shc/directory.ts | 92 +++++++++++++++++++--------------- test/shc/directory.test.ts | 100 +++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 39 deletions(-) diff --git a/src/shc/directory.ts b/src/shc/directory.ts index f519e17..2d51b35 100644 --- a/src/shc/directory.ts +++ b/src/shc/directory.ts @@ -67,6 +67,53 @@ export class Directory { return Directory.fromJSON(vciDirectoryJson) } + private static buildIssuerKeys(keys: IssuerKey[]): Map { + const keysMap = new Map() + if (Array.isArray(keys)) { + keys.forEach(key => { + keysMap.set(key.kid, key) + }) + } + return keysMap + } + + private static buildIssuerCrls(crls: IssuerCrlJSON[]): Map { + const crlsMap = new Map() + if (Array.isArray(crls)) { + // We need to process the raw CRLs data from the directory JSON + // to convert them into the apprpriate format that's used in the + // Directory class, as the former stores them as an Array and we + // store them internally as a Map in the latter. + crls.forEach(({ rids, ...crl }) => { + const ridsSet = new Set() + const ridsTimestamps = new Map() + rids?.forEach(rid => { + // The rid may be stored using a "[rid].[revocation_timestamp]" + // format in the CRL, so we need to split and store that data in + // order to validate if a SHC is revoked in a more performatic flow + const [rawRid, timestamp] = rid.split('.', 2) + if (rawRid) { + ridsSet.add(rawRid) + if (timestamp) { + ridsTimestamps.set(rawRid, timestamp) + } + } + }) + const issuerCrl: IssuerCrl = { + ...crl, + rids: ridsSet, + ridsTimestamps, + } + // Check for duplicate CRL and only keep the one with highest ctr + const existingCrl = crlsMap.get(crl.kid) + if (!existingCrl || crl.ctr > existingCrl.ctr) { + crlsMap.set(crl.kid, issuerCrl) + } + }) + } + return crlsMap + } + /** * Build a Directory from a parsed JSON object matching the published * directory schema. @@ -85,46 +132,10 @@ export class Directory { return } - const keysMap = new Map() - if (Array.isArray(keys)) { - keys.forEach(key => { - keysMap.set(key.kid, key) - }) - } - - const crlsMap = new Map() - if (Array.isArray(crls)) { - // We need to process the raw CRLs data from the directory JSON - // to convert them into the apprpriate format that's used in the - // Directory class, as the former stores them as an Array and we - // store them internally as a Map in the latter. - crls.forEach(({ rids, ...crl }) => { - const ridsSet = new Set() - const ridsTimestamps = new Map() - rids?.forEach(rid => { - // The rid may be stored using a "[rid].[revocation_timestamp]" - // format in the CRL, so we need to split and store that data in - // order to validate if a SHC is revoked in a more performatic flow - const [rawRid, timestamp] = rid.split('.', 2) - if (rawRid) { - ridsSet.add(rawRid) - if (timestamp) { - ridsTimestamps.set(rawRid, timestamp) - } - } - }) - const issuerCrl: IssuerCrl = { - ...crl, - rids: ridsSet, - ridsTimestamps, - } - crlsMap.set(crl.kid, issuerCrl) - }) - } issuersMap.set(iss, { iss, - keys: keysMap, - crls: crlsMap, + keys: Directory.buildIssuerKeys(keys), + crls: Directory.buildIssuerCrls(crls || []), }) }) return new Directory(issuersMap) @@ -151,8 +162,11 @@ export class Directory { issuerInfo: [], } + // Ensure we only ignore duplicate issuer URLs + const uniqueIssUrls = new Set(issUrls) + try { - for (const issUrl of issUrls) { + for (const issUrl of uniqueIssUrls) { const issuerInfo: IssuerJSON = { issuer: { iss: issUrl, diff --git a/test/shc/directory.test.ts b/test/shc/directory.test.ts index 4401d8a..1664e15 100644 --- a/test/shc/directory.test.ts +++ b/test/shc/directory.test.ts @@ -266,6 +266,64 @@ describe('Directory', () => { ;(globalThis as any).fetch = originalFetch }) + it('should create a directory from duplicate issuer urls and fetch jwks and crls once per unique issuer', async () => { + const ISS_URL2 = 'https://example.org/issuer2' + const originalFetch = globalThis.fetch + const fetchMock = vi.fn().mockImplementation((url: string) => { + // issuer 1 jwks and crl + if (url === `${ISS_URL}/.well-known/jwks.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ + keys: [ + { kid: 'kid1', kty: 'EC' }, + { kid: 'kid2', kty: 'EC' }, + ], + }), + }) + } + if (url === `${ISS_URL}/.well-known/crl/kid2.json`) { + return Promise.resolve({ ok: true, json: async () => ({ kid: 'kid2' }) }) + } + + // issuer 2 jwks and crl + if (url === `${ISS_URL2}/.well-known/jwks.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ keys: [{ kid: 'kidA', kty: 'EC' }] }), + }) + } + if (url === `${ISS_URL2}/.well-known/crl/kidA.json`) { + return Promise.resolve({ ok: true, json: async () => ({ kid: 'kidA' }) }) + } + + return Promise.resolve({ ok: false, status: 404 }) + }) + ;(globalThis as any).fetch = fetchMock + + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined) + + const directory = await Directory.fromURLs([ISS_URL, ISS_URL2, ISS_URL]) + const issuers = directory.getIssuers() + expect(issuers).toHaveLength(2) + + const issuer1 = directory.getIssuerByIss(ISS_URL)! + const issuer2 = directory.getIssuerByIss(ISS_URL2)! + + expect(issuer1.keys).toHaveLength(2) + expect(issuer1.crls).toHaveLength(1) + + expect(issuer2.keys).toHaveLength(1) + expect(issuer2.crls).toHaveLength(1) + + expect(debugSpy).toHaveBeenCalledTimes(1) + expect(debugSpy).toHaveBeenCalledWith( + `Failed to fetch crl at ${ISS_URL}/.well-known/crl/kid1.json with status 404, skipping key.` + ) + + ;(globalThis as any).fetch = originalFetch + }) + it('should handle jwks fetch failure gracefully and return empty directory', async () => { const originalFetch = globalThis.fetch const fetchMock = vi.fn().mockImplementation((url: string) => { @@ -308,4 +366,46 @@ describe('Directory', () => { ;(globalThis as any).fetch = originalFetch }) + + it('should handle CRLs with duplicate kids and keep the one with highest ctr', async () => { + const directoryJson: DirectoryJSON = { + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-1-simple', + }, + { + kty: 'EC', + kid: 'kid-2-simple', + }, + ], + crls: [ + { + kid: 'kid-2-simple', + method: 'rid', + ctr: 2, + rids: [], + }, + { + kid: 'kid-2-simple', + method: 'rid', + ctr: 1, + rids: ['revoked-1'], + }, + ], + }, + ], + } + + const directory = Directory.fromJSON(directoryJson) + const issuer = directory.getIssuerByIss('https://example.com/issuer')! + expect(issuer.crls).toHaveLength(1) + const crl = issuer.crls.get('kid-2-simple')! + expect(crl.ctr).toEqual(2) + }) }) From a2efc8be1e74efb2ad81cac95286a0867599e8d0 Mon Sep 17 00:00:00 2001 From: rvlb Date: Tue, 16 Dec 2025 18:07:11 -0300 Subject: [PATCH 09/10] Handle duplicate issuers --- src/shc/directory.ts | 27 +++++++++++++++++---- src/shc/types.ts | 5 ++++ test/shc/directory.test.ts | 48 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/shc/directory.ts b/src/shc/directory.ts index 2d51b35..a3300f4 100644 --- a/src/shc/directory.ts +++ b/src/shc/directory.ts @@ -71,7 +71,11 @@ export class Directory { const keysMap = new Map() if (Array.isArray(keys)) { keys.forEach(key => { - keysMap.set(key.kid, key) + // Check for duplicate keys and only keep the one with highest crlVersion + const existingKey = keysMap.get(key.kid) + if (!existingKey || (key.crlVersion || 0) > (existingKey.crlVersion || 0)) { + keysMap.set(key.kid, key) + } }) } return keysMap @@ -106,7 +110,7 @@ export class Directory { } // Check for duplicate CRL and only keep the one with highest ctr const existingCrl = crlsMap.get(crl.kid) - if (!existingCrl || crl.ctr > existingCrl.ctr) { + if (!existingCrl || (crl.ctr || 0) > (existingCrl.ctr || 0)) { crlsMap.set(crl.kid, issuerCrl) } }) @@ -124,18 +128,33 @@ export class Directory { * const directory = Directory.fromJSON(parsedJson) */ static fromJSON(directoryJson: DirectoryJSON): Directory { - const issuersMap = new Map() + // Pre-process the directory in order to look for duplicate issuers + // and combine their keys and crls + const mergedDirectory = new Map() directoryJson.issuerInfo.forEach(({ issuer, keys, crls }) => { const iss = typeof issuer?.iss === 'string' ? issuer.iss : undefined if (!iss) { console.warn('Skipping issuer with missing "iss" field') return } + if (mergedDirectory.has(iss)) { + mergedDirectory.get(iss)!.keys.push(...(keys || [])) + mergedDirectory.get(iss)!.crls!.push(...(crls || [])) + } else { + mergedDirectory.set(iss, { + issuer: { iss }, + keys: keys || [], + crls: crls || [], + }) + } + }) + const issuersMap = new Map() + Array.from(mergedDirectory.entries()).forEach(([iss, { keys, crls }]) => { issuersMap.set(iss, { iss, keys: Directory.buildIssuerKeys(keys), - crls: Directory.buildIssuerCrls(crls || []), + crls: Directory.buildIssuerCrls(crls!), }) }) return new Directory(issuersMap) diff --git a/src/shc/types.ts b/src/shc/types.ts index e56ae0a..1ea98ec 100644 --- a/src/shc/types.ts +++ b/src/shc/types.ts @@ -325,6 +325,11 @@ export interface IssuerKey { kty?: string /** Key ID used to identify the key in JWKS responses. */ kid: string + /** + * Version to identify duplicate keys. When a duplicate key is present, + * the one with the highest crlVersion will be used + */ + crlVersion?: number } /** diff --git a/test/shc/directory.test.ts b/test/shc/directory.test.ts index 1664e15..08bc01f 100644 --- a/test/shc/directory.test.ts +++ b/test/shc/directory.test.ts @@ -408,4 +408,52 @@ describe('Directory', () => { const crl = issuer.crls.get('kid-2-simple')! expect(crl.ctr).toEqual(2) }) + + it('should merge data from duplicate issuers', async () => { + const directoryJson: DirectoryJSON = { + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-1-simple', + }, + { + kty: 'EC', + kid: 'kid-2-simple', + crlVersion: 2, + }, + ], + }, + { + issuer: { + iss: 'https://example.com/issuer', + }, + keys: [ + { + kty: 'EC', + kid: 'kid-2-simple', + }, + ], + crls: [ + { + kid: 'kid-2-simple', + method: 'rid', + ctr: 1, + rids: ['revoked-1'], + }, + ], + }, + ], + } + const directory = Directory.fromJSON(directoryJson) + expect(directory.getIssuers()).toHaveLength(1) + const issuer = directory.getIssuerByIss('https://example.com/issuer')! + expect(issuer.keys).toHaveLength(2) + expect(issuer.keys.get('kid-2-simple')!.crlVersion).toBe(2) + expect(issuer.crls).toHaveLength(1) + }) }) From 739672879c300201e36d6534c70ded2ab12797a3 Mon Sep 17 00:00:00 2001 From: rvlb Date: Wed, 17 Dec 2025 13:50:24 -0300 Subject: [PATCH 10/10] Update naming on error message and comments --- src/shc/reader.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shc/reader.ts b/src/shc/reader.ts index 9c39ae6..42c3af1 100644 --- a/src/shc/reader.ts +++ b/src/shc/reader.ts @@ -162,12 +162,12 @@ export class SHCReader { publicKeyToUse = await this.resolvePublicKeyFromDirectory(jws, directory) } catch (error) { console.warn( - `Failed to resolve public key from directory, will try to resolve from JWKS: ${error}` + `Failed to resolve public key from directory, will try to resolve from from issuer JWKS URL: ${error}` ) } } - // If all else fails, resolve public key via issuer JWKS based on JWS header/payload + // If all else fails, resolve public key via issuer JWKS URL, based on JWS header/payload if (!publicKeyToUse) { publicKeyToUse = await this.resolvePublicKeyFromJWKS(jws) } @@ -232,10 +232,10 @@ export class SHCReader { const { header, payload } = await this.jwsProcessor.parseUnverified(jws) if (!payload.iss || typeof payload.iss !== 'string') { - throw new VerificationError("Cannot resolve JWKS: missing 'iss' in payload") + throw new VerificationError("Cannot resolve JWK: missing 'iss' in payload") } if (!header.kid || typeof header.kid !== 'string') { - throw new VerificationError("Cannot resolve JWKS: missing 'kid' in JWS header") + throw new VerificationError("Cannot resolve JWK: missing 'kid' in JWS header") } return { header, payload }