Reference source · 0.1.0-dev.11
archive.ts
src/archive.ts · Download original file
import { subset } from './visibility.ts';
import { sign, verify, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
import type { Credential, Identity, SignedStatus } from './identity.ts';
import { TrustStore, publicKey } from './identity.ts';
import { b64, unb64, jsonBytes, parseObject, exact, id, integer, fingerprint, requireThat, ProtocolError } from './core.ts';
import { RESOURCE_TYPES, validateClinical, resourceId } from './resource-profile.ts';
import type { ClinicalVersion } from './clinical-store.ts';
import { verifyHandoff } from './migration-proof.ts';
import type { Handoff } from './migration-proof.ts';
export const ARCHIVE_LIMIT = 512 * 1024;
export type Archive = { payload: string; signature: string; source_credential: Credential; status_evidence: SignedStatus };
export type ArchiveBody = { v: '0.1'; profile: 'oprp-reference-r4-patient-labs' | 'oprp-reference-r4-expanded'; archive_id: string; source: string; patient: string; created_at: number; credential_digest: string;
resources: { resource: Record<string, any>; version: number; deleted?: true; digest: string }[]; clinical_history?: ClinicalVersion[];
receipts: { id: string; app: string; operation_id: string; request: Record<string, any>; digest: string; result: Record<string, any>; actor: string | null }[];
retained_events: Record<string, any>[]; audits: Record<string, any>[]; prior_archives: Archive[];
coverage: { complete_record: true; complete_history: false; retry_receipts: true }; handoffs?: Handoff[]; content_digest: string };
export type EncryptedBackup = { v: '0.1'; suite: 'OPRP-BACKUP-A256GCM'; nonce: string; ciphertext: string; tag: string };
export function archiveDigest(body: Omit<ArchiveBody, 'content_digest'> | ArchiveBody): string {
const { content_digest: ignored, ...content } = body as ArchiveBody;
return fingerprint(jsonBytes(content));
}
export function signArchive(body: Omit<ArchiveBody, 'content_digest'>, identity: Identity, credential: Credential, status: SignedStatus): Archive {
const payload = b64(jsonBytes({ ...body, content_digest: archiveDigest(body) }));
requireThat(unb64(payload).length <= ARCHIVE_LIMIT, 'archive_too_large');
return { payload, signature: b64(sign(null, Buffer.from(`OPRP-ARCHIVE-0.1.${payload}`), identity.signingPrivate)), source_credential: credential, status_evidence: status };
}
function resource(resource: Record<string, any>, patient: string): void {
requireThat(resource && typeof resource === 'object' && !Array.isArray(resource), 'invalid_archive_resource'); requireThat(!subset(resource),'incomplete_resource'); id(resource.id);
if (resource.resourceType === 'Patient') requireThat(resource.id === patient, 'wrong_archive_patient');
else if (RESOURCE_TYPES.includes(resource.resourceType)) validateClinical(resource, patient);
else requireThat(resource.resourceType === 'Observation' && resource.subject?.reference === `Patient/${patient}` && resource.category?.some((category: any) => category.coding?.some((coding: any) => coding.system === 'http://terminology.hl7.org/CodeSystem/observation-category' && coding.code === 'laboratory')), 'unsupported_archive_resource');
// The reference Host has no attachment storage. Never imply that an external binary was backed up.
const checkAttachments = (value: any): void => {
if (!value || typeof value !== 'object') return;
for (const [key, nested] of Object.entries(value)) {
requireThat(key !== 'photo' && key !== 'derivedFrom' && !key.toLowerCase().endsWith('attachment'), 'unsupported_archive_attachment');
checkAttachments(nested);
}
};
checkAttachments(resource);
}
export function verifyArchive(archive: Archive, trust: TrustStore, now: number, expectedPatient?: string, depth = 0): ArchiveBody {
requireThat(depth <= 4, 'archive_lineage_too_deep'); exact(archive, ['payload', 'signature', 'source_credential', 'status_evidence']);
requireThat(typeof archive.payload === 'string' && archive.payload.length <= Math.ceil(ARCHIVE_LIMIT * 4 / 3), 'archive_too_large');
const body = parseObject(unb64(archive.payload)) as ArchiveBody;
exact(body, ['v','profile','archive_id','source','patient','created_at','credential_digest','resources','receipts','retained_events','audits','prior_archives','coverage','content_digest', ...(body.handoffs !== undefined ? ['handoffs'] : []), ...(body.clinical_history !== undefined ? ['clinical_history'] : [])]);
[body.archive_id, body.source, body.patient].forEach(id); integer(body.created_at);
requireThat(body.v === '0.1' && ['oprp-reference-r4-patient-labs','oprp-reference-r4-expanded'].includes(body.profile) && body.created_at <= now && (!expectedPatient || body.patient === expectedPatient), 'wrong_archive_profile_or_patient');
requireThat(body.credential_digest === fingerprint(jsonBytes(archive.source_credential)), 'invalid_archive_credential');
// Historical status is evidence at creation time, never a replacement for the live status cache.
const source = trust.validate(archive.source_credential, body.created_at, body.source, 'host', archive.status_evidence);
requireThat(verify(null, Buffer.from(`OPRP-ARCHIVE-0.1.${archive.payload}`), publicKey(source.signingKey, 'signing'), unb64(archive.signature, 3309)), 'invalid_archive_signature');
requireThat(body.content_digest === archiveDigest(body), 'invalid_archive_digest');
exact(body.coverage, ['complete_record','complete_history','retry_receipts']);
requireThat(body.coverage.complete_record === true && body.coverage.complete_history === false && body.coverage.retry_receipts === true, 'unsupported_archive_coverage');
for (const list of [body.resources, body.receipts, body.retained_events, body.audits, body.prior_archives]) requireThat(Array.isArray(list) && list.length <= 1000, 'invalid_archive');
const members = new Set<string>(); let patients = 0;
for (const entry of body.resources) {
exact(entry, ['resource','version','digest', ...(entry.deleted !== undefined ? ['deleted'] : [])]);
requireThat(entry.deleted === undefined || entry.deleted === true && RESOURCE_TYPES.includes(entry.resource.resourceType), 'invalid_archive_resource'); integer(entry.version); requireThat(entry.version > 0, 'invalid_archive_version');
resource(entry.resource, body.patient);
const key = `${entry.resource.resourceType}/${entry.resource.id}`; requireThat(!members.has(key), 'duplicate_archive_resource'); members.add(key);
if (entry.resource.resourceType === 'Patient') patients++;
else if (entry.resource.resourceType === 'Observation') requireThat(entry.version === 1, 'unsupported_archive_version');
else requireThat(body.profile === 'oprp-reference-r4-expanded' && entry.resource.meta?.versionId === String(entry.version), 'unsupported_archive_version');
requireThat(entry.digest === fingerprint(jsonBytes({ resource: entry.resource, version: entry.version, ...(entry.deleted ? {deleted:true} : {}) })), 'invalid_archive_digest');
}
requireThat(patients === 1, 'missing_archive_patient');
const clinical = body.resources.filter(entry => RESOURCE_TYPES.includes(entry.resource.resourceType));
requireThat(body.profile === 'oprp-reference-r4-expanded' ? clinical.length > 0 && Array.isArray(body.clinical_history) : !clinical.length && body.clinical_history === undefined, 'wrong_archive_profile_or_patient');
const history = new Map<string, ClinicalVersion>();
if (body.clinical_history !== undefined) {
requireThat(body.clinical_history.length <= 1000, 'invalid_archive_history');
for (const entry of body.clinical_history) {
exact(entry,['resource','version','deleted','provenance']); integer(entry.version);
requireThat(!subset(entry.resource),'incomplete_resource');validateClinical(entry.resource,body.patient); resourceId(entry.resource.id);
requireThat(entry.version > 0 && typeof entry.deleted === 'boolean' && entry.resource.meta?.versionId === String(entry.version), 'invalid_archive_history');
exact(entry.provenance,['app','actor','accepted_at','operation_id']); id(entry.provenance.app); id(entry.provenance.operation_id); integer(entry.provenance.accepted_at);
requireThat(entry.provenance.accepted_at <= body.created_at && (entry.provenance.actor === null || typeof entry.provenance.actor === 'string'), 'invalid_archive_history');
if (entry.provenance.actor !== null) id(entry.provenance.actor);
const key=`${entry.resource.resourceType}/${entry.resource.id}/${entry.version}`;
requireThat(!history.has(key) && members.has(`${entry.resource.resourceType}/${entry.resource.id}`), 'invalid_archive_history'); history.set(key,entry);
}
for (const entry of clinical) {
requireThat(entry.version <= 1000, 'invalid_archive_history');
for (let version=1;version<=entry.version;version++) {
const saved=history.get(`${entry.resource.resourceType}/${entry.resource.id}/${version}`);
requireThat(saved && (version===entry.version || !saved.deleted), 'invalid_archive_history');
}
const current=history.get(`${entry.resource.resourceType}/${entry.resource.id}/${entry.version}`)!;
requireThat(current.deleted===!!entry.deleted && fingerprint(jsonBytes(current.resource))===fingerprint(jsonBytes(entry.resource)), 'invalid_archive_history');
requireThat(![...history.keys()].some(key=>key.startsWith(`${entry.resource.resourceType}/${entry.resource.id}/`) && Number(key.split('/')[2])>entry.version), 'invalid_archive_history');
}
}
const receiptIds = new Set<string>();
for (const receipt of body.receipts) {
exact(receipt, ['id','app','operation_id','request','digest','result','actor']); id(receipt.app); id(receipt.operation_id);
requireThat(receipt.actor === null || typeof receipt.actor === 'string', 'invalid_archive_receipt'); if (receipt.actor !== null) id(receipt.actor);
requireThat(receipt.id === fingerprint(jsonBytes([receipt.app, body.patient, receipt.operation_id])) && !receiptIds.has(receipt.id), 'invalid_archive_receipt'); receiptIds.add(receipt.id);
exact(receipt.request, ['operation','patient','resource','base_version']);
requireThat(receipt.request.patient === body.patient && ['create_lab','update_demographics','create_resource','update_resource','delete_resource'].includes(receipt.request.operation) && receipt.digest === fingerprint(jsonBytes(receipt.request)), 'invalid_archive_receipt');
requireThat(receipt.result.status === 'ok', 'invalid_archive_receipt');
if (['create_resource','update_resource','delete_resource'].includes(receipt.request.operation)) {
exact(receipt.result,['status','resource_type','resource_id','version','deleted']);
integer(receipt.result.version); resourceId(receipt.result.resource_id);
requireThat(receipt.request.resource?.resourceType===receipt.result.resource_type && (receipt.request.operation==='create_resource' ? receipt.request.resource.id===undefined && receipt.request.base_version===null && receipt.result.version===1 : receipt.request.resource.id===receipt.result.resource_id && Number.isSafeInteger(receipt.request.base_version) && receipt.result.version===receipt.request.base_version+1), 'invalid_archive_receipt');
requireThat(receipt.result.deleted===(receipt.request.operation==='delete_resource'), 'invalid_archive_receipt');
const accepted=history.get(`${receipt.result.resource_type}/${receipt.result.resource_id}/${receipt.result.version}`);
requireThat(accepted && accepted.deleted===receipt.result.deleted && accepted.provenance.app===receipt.app && accepted.provenance.operation_id===receipt.operation_id, 'invalid_archive_receipt');
if (!receipt.result.deleted) validateClinical(receipt.request.resource,body.patient);
continue;
}
resource(receipt.result.resource, body.patient);
requireThat(members.has(`${receipt.result.resource.resourceType}/${receipt.result.resource.id}`), 'invalid_archive_receipt');
if (receipt.request.operation === 'update_demographics') {
exact(receipt.result, ['status','version','resource']); integer(receipt.request.base_version); integer(receipt.result.version);
requireThat(receipt.result.resource.resourceType === 'Patient' && receipt.result.version === receipt.request.base_version + 1, 'invalid_archive_receipt');
} else { exact(receipt.result, ['status','resource']); requireThat(receipt.result.resource.resourceType === 'Observation' && receipt.request.base_version === null, 'invalid_archive_receipt'); }
}
const events = new Set<string>(); let previousSequence = 0;
for (const event of body.retained_events) {
exact(event, ['sequence','event_id','patient','resource_type','resource_id','version','kind','resource']); id(event.event_id); integer(event.sequence); integer(event.version);
requireThat(event.patient === body.patient && event.sequence > previousSequence && !events.has(event.event_id) && ['created','updated','deleted'].includes(event.kind) && event.version > 0, 'invalid_archive_event'); events.add(event.event_id); previousSequence = event.sequence;
if (event.kind === 'deleted') { exact(event.resource,['resourceType','id']); requireThat(body.profile === 'oprp-reference-r4-expanded' && RESOURCE_TYPES.includes(event.resource_type), 'invalid_archive_event'); } else resource(event.resource, body.patient); requireThat(event.resource_type === event.resource.resourceType && event.resource_id === event.resource.id && members.has(`${event.resource_type}/${event.resource_id}`), 'invalid_archive_event');
}
for (const audit of body.audits) {
exact(audit, ['id','at','app','actor','patient','operation','outcome']); integer(audit.id); integer(audit.at); id(audit.app);
requireThat(audit.patient === body.patient && audit.at <= body.created_at && typeof audit.operation === 'string' && typeof audit.outcome === 'string' && (audit.actor === null || typeof audit.actor === 'string'), 'invalid_archive_audit');
}
for (const prior of body.prior_archives) verifyArchive(prior, trust, now, body.patient, depth + 1);
if (body.handoffs !== undefined) {
requireThat(Array.isArray(body.handoffs) && body.handoffs.length <= 4, 'invalid_archive_handoff');
for (const handoff of body.handoffs) {
const verified = verifyHandoff(handoff, trust, now);
requireThat(verified.plan.patient === body.patient && verified.plan.destination === body.source && body.prior_archives.some(prior => fingerprint(jsonBytes(prior)) === verified.archiveDigest), 'invalid_archive_handoff');
}
}
return body;
}
const backupAAD = jsonBytes({ v: '0.1', suite: 'OPRP-BACKUP-A256GCM' });
export function encryptBackup(archive: Archive, recoveryKey = randomBytes(32)): { backup: EncryptedBackup; recoveryKey: Buffer } {
requireThat(recoveryKey.length === 32, 'invalid_recovery_key'); const plain = jsonBytes(archive); requireThat(plain.length <= 800000, 'archive_too_large');
const nonce = randomBytes(12), cipher = createCipheriv('aes-256-gcm', recoveryKey, nonce); cipher.setAAD(backupAAD);
try { return { backup: { v: '0.1', suite: 'OPRP-BACKUP-A256GCM', nonce: b64(nonce), ciphertext: b64(Buffer.concat([cipher.update(plain), cipher.final()])), tag: b64(cipher.getAuthTag()) }, recoveryKey: Buffer.from(recoveryKey) }; }
finally { plain.fill(0); }
}
export function decryptBackup(backup: EncryptedBackup, recoveryKey: Buffer): Archive {
exact(backup, ['v','suite','nonce','ciphertext','tag']); requireThat(backup.v === '0.1' && backup.suite === 'OPRP-BACKUP-A256GCM' && recoveryKey.length === 32, 'invalid_backup');
requireThat(typeof backup.ciphertext === 'string' && backup.ciphertext.length <= 1100000, 'archive_too_large');
const decipher = createDecipheriv('aes-256-gcm', recoveryKey, unb64(backup.nonce, 12)); decipher.setAAD(backupAAD); decipher.setAuthTag(unb64(backup.tag, 16));
let plain: Buffer | undefined;
try { plain = Buffer.concat([decipher.update(unb64(backup.ciphertext)), decipher.final()]); return parseObject(plain) as Archive; }
catch { throw new ProtocolError('invalid_backup'); } finally { plain?.fill(0); }
}