Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,31 @@ 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,
});
```

#### 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 Expand Up @@ -312,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)
Expand Down
14 changes: 9 additions & 5 deletions src/shc/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

/**
Expand Down
21 changes: 21 additions & 0 deletions src/shc/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,24 @@ 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'
}
}

export class SHCRevokedError extends SHCError {
constructor(message: string) {
super(message, 'SHC_REVOKED')
this.name = 'SHCRevokedError'
}
}
9 changes: 2 additions & 7 deletions src/shc/issuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { JWSProcessor } from './jws/jws-processor.js'
import { SHC } from './shc.js'
import type {
FHIRBundle,
Issuer,
SHCConfig,
SHCConfigParams,
SHCJWT,
Expand Down Expand Up @@ -80,13 +79,9 @@ export class SHCIssuer {
* });
* ```
*/
async issue(
fhirBundle: FHIRBundle,
config: VerifiableCredentialParams = {},
issuerInfo: Issuer[] = []
): Promise<SHC> {
async issue(fhirBundle: FHIRBundle, config: VerifiableCredentialParams = {}): Promise<SHC> {
const jws = await this.createJWS(fhirBundle, config)
return new SHC(jws, fhirBundle, issuerInfo)
return new SHC(jws, fhirBundle)
}

/**
Expand Down
51 changes: 40 additions & 11 deletions src/shc/reader.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
// SHCReader class
import { importJWK } from 'jose'
import { FileFormatError, QRCodeError, SHCError, VerificationError } from './errors.js'
import { Directory } from './directory.js'
import {
FileFormatError,
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'

/**
Expand Down Expand Up @@ -50,12 +53,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,12 +162,31 @@ export class SHCReader {
const vc: VerifiableCredential = { vc: payload.vc }
this.vcProcessor.validate(vc)

// Step 4: Return the original FHIR Bundle
let issuerInfo: Issuer[] = []
// Step 4: Get the issuer info from a provided directory
// or from the VCI snapshot
let directory: Directory | null = null
if (this.config.issuerDirectory) {
issuerInfo = this.config.issuerDirectory.getIssuerInfo()
directory = this.config.issuerDirectory
} else if (this.config.useVciDirectory) {
directory = await Directory.fromVCI()
}
return new SHC(jws, originalBundle, issuerInfo)

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)
} catch (error) {
if (error instanceof SHCError) {
throw error
Expand Down
14 changes: 2 additions & 12 deletions src/shc/shc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
) {}

/**
Expand All @@ -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.
*
Expand Down
9 changes: 9 additions & 0 deletions src/shc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface VerifiableCredential {
/** The FHIR Bundle containing medical data. */
fhirBundle: FHIRBundle
}
rid?: string
}
}

Expand Down Expand Up @@ -150,6 +151,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'],
},
],
},
],
}
Loading