diff --git a/README.md b/README.md index fc666ad..97b5119 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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. diff --git a/src/shc/directory.ts b/src/shc/directory.ts index f57ea65..a3300f4 100644 --- a/src/shc/directory.ts +++ b/src/shc/directory.ts @@ -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 @@ -16,15 +23,22 @@ export class Directory { * * @param issuerInfo - Array of issuer entries (see {@link Issuer}) */ - constructor(private issuerInfo: Issuer[]) {} + constructor(private issuers: Map) {} /** - * 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 { + return this.issuers + } + + /** + * Get an issuer by its `iss` identifier. + */ + getIssuerByIss(iss: string): Issuer | undefined { + return this.issuers.get(iss) } /** @@ -53,31 +67,97 @@ export class Directory { return Directory.fromJSON(vciDirectoryJson) } + private static buildIssuerKeys(keys: IssuerKey[]): Map { + const keysMap = new Map() + 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 { + 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 || 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() + 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() + Array.from(mergedDirectory.entries()).forEach(([iss, { keys, crls }]) => { + issuersMap.set(iss, { + iss, + keys: Directory.buildIssuerKeys(keys), + crls: Directory.buildIssuerCrls(crls!), + }) + }) + return new Directory(issuersMap) } /** @@ -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` @@ -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) diff --git a/src/shc/errors.ts b/src/shc/errors.ts index 577662f..7e4558d 100644 --- a/src/shc/errors.ts +++ b/src/shc/errors.ts @@ -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' + } +} 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/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 7d40735..42c3af1 100644 --- a/src/shc/reader.ts +++ b/src/shc/reader.ts @@ -1,16 +1,20 @@ // 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 { 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' -import type { - Issuer, - SHCReaderConfig, - SHCReaderConfigParams, - VerifiableCredential, -} from './types.js' +import type { SHCReaderConfig, SHCReaderConfigParams, VerifiableCredential } from './types.js' import { VerifiableCredentialProcessor } from './vc.js' /** @@ -50,12 +54,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() @@ -130,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 from issuer JWKS URL: ${error}` + ) + } + } + + // If all else fails, resolve public key via issuer JWKS URL, based on JWS header/payload if (!publicKeyToUse) { publicKeyToUse = await this.resolvePublicKeyFromJWKS(jws) } @@ -152,12 +185,35 @@ export class SHCReader { const vc: VerifiableCredential = { vc: payload.vc } this.vcProcessor.validate(vc) - // Step 4: Return the original FHIR Bundle - let issuerInfo: Issuer[] = [] - if (this.config.issuerDirectory) { - issuerInfo = this.config.issuerDirectory.getIssuerInfo() + // 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 + if (issuer && vcRid) { + const kid = await deriveKidFromPublicKey(publicKeyToUse) + 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 has no associated timestamp, it's revoked + throw new SHCRevokedError('This SHC has been 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 + } + } } - return new SHC(jws, originalBundle, issuerInfo) + + // Step 5: Return the original FHIR Bundle + return new SHC(jws, originalBundle) } catch (error) { if (error instanceof SHCError) { throw error @@ -167,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 JWK: missing 'iss' in payload") + } + if (!header.kid || typeof header.kid !== 'string') { + throw new VerificationError("Cannot resolve JWK: 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 @@ -174,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/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 f72d8e0..1ea98ec 100644 --- a/src/shc/types.ts +++ b/src/shc/types.ts @@ -30,6 +30,8 @@ export interface VerifiableCredential { /** The FHIR Bundle containing medical data. */ fhirBundle: FHIRBundle } + /** Optional revocation identifier */ + rid: string | null } } @@ -150,6 +152,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 } /** @@ -189,6 +199,11 @@ export interface VerifiableCredentialParams { * - `https://smarthealth.cards#laboratory` */ includeAdditionalTypes?: string[] + + /** + * An optional revocation identifier to include in the credential + */ + rid?: string } /** @@ -307,9 +322,14 @@ 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 + /** + * Version to identify duplicate keys. When a duplicate key is present, + * the one with the highest crlVersion will be used + */ + crlVersion?: number } /** @@ -328,7 +348,9 @@ export interface IssuerCrl { /** Monotonic counter for CRL updates. */ ctr: number /** List of revoked resource ids (rids). */ - rids: string[] + rids: Set + /** Map of revoked resource ids to their revocation timestamps. */ + ridsTimestamps: Map } /** @@ -341,10 +363,40 @@ export interface IssuerCrl { export interface Issuer { /** Issuer base URL (the `iss` claim value). */ iss: string - /** Array of known JWK descriptors for the issuer. */ + /** JWK descriptors for the issuer. */ + keys: Map + /** CRL entries for revoked resource ids. */ + crls: Map +} + +/** + * 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 */ + iss: string + } + /** Array of JWK descriptors returned from the issuer's JWKS endpoint. */ keys: IssuerKey[] - /** Optional array of CRL entries for revoked resource ids. */ - crls?: IssuerCrl[] + /** Optional CRL entries published alongside the directory. */ + crls?: IssuerCrlJSON[] } /** @@ -355,14 +407,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/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/helpers.ts b/test/helpers.ts index f0fa0b7..12e4830 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -110,3 +110,86 @@ export function decodeQRFromDataURL(dataURL: string): string | null { return 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: [ + { + 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..08bc01f 100644 --- a/test/shc/directory.test.ts +++ b/test/shc/directory.test.ts @@ -1,102 +1,27 @@ 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() + 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') + expect(crls1.get('kid-2-simple')!.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! @@ -178,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.getIssuerInfo() - expect(issuers).toHaveLength(3) + const issuers = directory.getIssuers() + 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([]) + expect(missing.keys).toHaveLength(0) + expect(missing.crls).toHaveLength(0) - 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([]) + expect(invalid.keys).toHaveLength(0) + expect(invalid.crls).toHaveLength(0) - 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 () => { @@ -243,13 +173,13 @@ 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]! + const issuer = [...issuers.values()][0]! 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) @@ -308,19 +238,17 @@ 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) - 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) expect(issuer1.crls).toHaveLength(1) - expect(issuer2).toBeDefined() expect(issuer2.keys).toHaveLength(1) expect(issuer2.crls).toHaveLength(1) @@ -338,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) => { @@ -354,7 +340,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) @@ -373,11 +359,101 @@ 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) ;(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) + }) + + 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) + }) }) 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 6e0b896..441de79 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, @@ -8,14 +8,18 @@ import { type SHCConfig, SHCIssuer, SHCReader, + SHCReaderConfigError, type SHCReaderConfigParams, + SHCRevokedError, SignatureVerificationError, } from '@/index' import { Directory } from '@/shc/directory' import { + buildTestJwkData, createInvalidBundle, createValidFHIRBundle, decodeQRFromDataURL, + SAMPLE_DIRECTORY_JSON, testPrivateKeyJWK, testPrivateKeyPKCS8, testPublicKeyJWK, @@ -58,69 +62,13 @@ 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 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 () => { @@ -211,6 +159,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()) @@ -220,6 +172,261 @@ describe('SHC', () => { expect(verifiedBundle).toEqual(validBundle) }) + 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 () => jwks, + }) + } + + if (url === `${ISS_URL}/.well-known/crl/${kid}.json`) { + return Promise.resolve({ + ok: true, + json: async () => ({ + kid: kid, + method: 'rid', + ctr: 1, + rids: [], + }), + }) + } + + 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({ + 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 using JWKS if directory public key fails', 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 () => jwks, + }) + } + + 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 () => ({ + 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', + }, + ], + }, + ], + }), + }) + } + + return Promise.resolve({ ok: false, status: 404 }) + }) + + ;(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) + }) + + it('should throw SHCRevokedError if the SHC is revoked without timestamp', async () => { + const { jwk, kid } = await buildTestJwkData() + + const directory = Directory.fromJSON({ + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + }, + keys: [{ ...jwk, kid }], + crls: [ + { + kid, + method: 'rid', + ctr: 1, + rids: ['revoked-1'], + }, + ], + }, + ], + }) + const readerWithDirectory = new SHCReader({ + 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 { jwk, kid } = await buildTestJwkData() + + const revocationTimestamp = new Date('2023-11-19T12:00:00Z').getTime() / 1000 + + const directory = Directory.fromJSON({ + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + }, + keys: [{ ...jwk, kid }], + crls: [ + { + kid, + method: 'rid', + ctr: 1, + rids: [`revoked-1.${revocationTimestamp}`], + }, + ], + }, + ], + }) + const readerWithDirectory = new SHCReader({ + 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 { jwk, kid } = await buildTestJwkData() + + const revocationTimestamp = new Date('2023-10-14T12:00:00Z').getTime() / 1000 + + const directory = Directory.fromJSON({ + issuerInfo: [ + { + issuer: { + iss: 'https://example.com/issuer', + }, + keys: [{ ...jwk, kid }], + crls: [ + { + kid, + method: 'rid', + ctr: 1, + rids: [`revoked-1.${revocationTimestamp}`], + }, + ], + }, + ], + }) + const readerWithDirectory = new SHCReader({ + 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