From 3cbcd897369e7ca8f7819964342168efbd07d529 Mon Sep 17 00:00:00 2001 From: rvlb Date: Thu, 11 Dec 2025 15:52:50 -0300 Subject: [PATCH] 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')