Skip to content
Merged
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
89 changes: 60 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,66 @@ 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 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,
});

// 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

SHLs enable encrypted, link-based sharing of health information. The flow involves:
Expand Down Expand Up @@ -290,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.getIssuerInfo()
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.
Expand Down
129 changes: 106 additions & 23 deletions src/shc/directory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { DirectoryJSON, Issuer, IssuerCrl, IssuerKey } from './types'
import type {
DirectoryJSON,
Issuer,
IssuerCrl,
IssuerCrlJSON,
IssuerJSON,
IssuerKey,
} from './types'

/**
* Directory is a lightweight representation of issuer metadata used by
Expand All @@ -16,15 +23,22 @@ export class Directory {
*
* @param issuerInfo - Array of issuer entries (see {@link Issuer})
*/
constructor(private issuerInfo: Issuer[]) {}
constructor(private issuers: Map<string, 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(): Map<string, Issuer> {
return this.issuers
}

/**
* Get an issuer by its `iss` identifier.
*/
getIssuerByIss(iss: string): Issuer | undefined {
return this.issuers.get(iss)
}

/**
Expand Down Expand Up @@ -53,31 +67,97 @@ export class Directory {
return Directory.fromJSON(vciDirectoryJson)
}

private static buildIssuerKeys(keys: IssuerKey[]): Map<string, IssuerKey> {
const keysMap = new Map<string, IssuerKey>()
if (Array.isArray(keys)) {
keys.forEach(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
}

private static buildIssuerCrls(crls: IssuerCrlJSON[]): Map<string, IssuerCrl> {
const crlsMap = new Map<string, IssuerCrl>()
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<string>()
const ridsTimestamps = new Map<string, string>()
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 || 0) > (existingCrl.ctr || 0)) {
crlsMap.set(crl.kid, issuerCrl)
}
})
}
return crlsMap
}

/**
* 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 validKeys = Array.isArray(keys) ? keys : []
const validCrls = Array.isArray(crls) ? crls : []
return {
iss,
keys: validKeys,
crls: validCrls,
// Pre-process the directory in order to look for duplicate issuers
// and combine their keys and crls
const mergedDirectory = new Map<string, IssuerJSON>()
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 || [],
})
}
})
return new Directory(data)

const issuersMap = new Map<string, Issuer>()
Array.from(mergedDirectory.entries()).forEach(([iss, { keys, crls }]) => {
issuersMap.set(iss, {
iss,
keys: Directory.buildIssuerKeys(keys),
crls: Directory.buildIssuerCrls(crls!),
})
})
return new Directory(issuersMap)
}

/**
Expand All @@ -101,14 +181,17 @@ export class Directory {
issuerInfo: [],
}

// Ensure we only ignore duplicate issuer URLs
const uniqueIssUrls = new Set(issUrls)

try {
for (const issUrl of issUrls) {
const issuerInfo = {
for (const issUrl of uniqueIssUrls) {
const issuerInfo: IssuerJSON = {
issuer: {
iss: issUrl,
},
keys: [] as IssuerKey[],
crls: [] as IssuerCrl[],
crls: [] as IssuerCrlJSON[],
}

const jwksUrl = `${issUrl}/.well-known/jwks.json`
Expand All @@ -130,7 +213,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)
Expand Down
28 changes: 28 additions & 0 deletions src/shc/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,31 @@ 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'
}
}

/**
* 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')
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
23 changes: 23 additions & 0 deletions src/shc/jws/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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
}
2 changes: 2 additions & 0 deletions src/shc/jws/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
// JWS module barrel export

export { deriveKidFromPublicKey } from './helpers.js'
export { JWSProcessor } from './jws-processor.js'
Loading