Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions src/shc/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
24 changes: 22 additions & 2 deletions src/shc/reader.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions src/shc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
75 changes: 75 additions & 0 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
],
},
],
}
76 changes: 1 addition & 75 deletions test/shc/directory.test.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
57 changes: 57 additions & 0 deletions test/shc/shc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type SHCConfig,
SHCIssuer,
SHCReader,
SHCReaderConfigError,
type SHCReaderConfigParams,
SignatureVerificationError,
} from '@/index'
Expand All @@ -16,6 +17,7 @@ import {
createInvalidBundle,
createValidFHIRBundle,
decodeQRFromDataURL,
SAMPLE_DIRECTORY_JSON,
testPrivateKeyJWK,
testPrivateKeyPKCS8,
testPublicKeyJWK,
Expand Down Expand Up @@ -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')

Expand Down