Reference source · 0.1.0-dev.11
migration-proof.ts
src/migration-proof.ts · Download original file
import { sign, verify } from 'node:crypto';
import type { Credential, Identity, SignedStatus, CredentialBody } from './identity.ts';
import { TrustStore, publicKey } from './identity.ts';
import { b64, unb64, jsonBytes, parseObject, exact, id, integer, fingerprint, requireThat } from './core.ts';
import { patientAddress } from './discovery.ts';
export type MigrationProof = { payload: string; signature: string; credential: Credential; status_evidence: SignedStatus };
export type TransferPlan = { v: '0.1'; kind: 'plan'; transfer_id: string; patient: string; address: string; source: string; destination: string; destination_endpoint: string;
source_signing_kid: string; source_encryption_kid: string; destination_signing_kid: string; destination_encryption_kid: string;
from_generation: number; to_generation: number; created_at: number; expires_at: number };
export type TransferReceipt = { v: '0.1'; kind: 'ready' | 'release' | 'activation' | 'abort'; transfer_id: string; plan_digest: string; created_at: number; archive_digest?: string; ready_digest?: string; release_digest?: string };
export type Handoff = { plan: MigrationProof; ready: MigrationProof; release: MigrationProof; activation: MigrationProof };
export const proofDigest = (proof: MigrationProof): string => fingerprint(jsonBytes(proof));
export function secureEndpoint(endpoint: string): void {
requireThat(typeof endpoint === 'string' && endpoint.length <= 2000, 'invalid_endpoint');
const url = new URL(endpoint); requireThat(url.protocol === 'https:' && !url.username && !url.password && !url.hash, 'invalid_endpoint');
}
export function signMigration(body: TransferPlan | TransferReceipt, identity: Identity, credential: Credential, trust: TrustStore, now: number): MigrationProof {
const signer = trust.validate(credential, now, identity.public.subject, 'host');
requireThat(signer.signingKid === identity.public.signingKid && signer.encryptionKid === identity.public.encryptionKid, 'wrong_migration_signer');
const issuer = signer.issuer;
const payload = b64(jsonBytes(body));
return { payload, signature: b64(sign(null, Buffer.from(`OPRP-MIGRATION-0.1.${payload}`), identity.signingPrivate)), credential, status_evidence: trust.statusEvidence(issuer) };
}
export function verifyMigration(proof: MigrationProof, trust: TrustStore, now: number): { body: TransferPlan | TransferReceipt; signer: CredentialBody } {
exact(proof, ['payload','signature','credential','status_evidence']); requireThat(typeof proof.payload === 'string' && proof.payload.length <= 8000, 'invalid_migration_proof');
const body = parseObject(unb64(proof.payload)) as TransferPlan | TransferReceipt;
integer(body.created_at); id(body.transfer_id); requireThat(body.v === '0.1' && body.created_at <= now, 'invalid_migration_proof');
if (body.kind === 'plan') {
exact(body, ['v','kind','transfer_id','patient','address','source','destination','destination_endpoint','source_signing_kid','source_encryption_kid','destination_signing_kid','destination_encryption_kid','from_generation','to_generation','created_at','expires_at']);
[body.patient,body.source,body.destination,body.source_signing_kid,body.source_encryption_kid,body.destination_signing_kid,body.destination_encryption_kid].forEach(id);
integer(body.from_generation); integer(body.to_generation); integer(body.expires_at);
requireThat(body.from_generation >= 1 && body.to_generation === body.from_generation + 1 && body.expires_at > body.created_at && body.expires_at - body.created_at <= 86400 && body.source !== body.destination, 'invalid_transfer_plan');
requireThat(patientAddress(body.address).address === body.address, 'invalid_transfer_plan'); secureEndpoint(body.destination_endpoint);
} else {
requireThat(['ready','release','activation','abort'].includes(body.kind), 'invalid_migration_proof');
const fields = ['v','kind','transfer_id','plan_digest','created_at'];
if (body.kind !== 'abort') fields.push('archive_digest');
if (body.kind === 'release') fields.push('ready_digest');
if (body.kind === 'activation') fields.push('release_digest');
exact(body, fields); for (const field of fields.filter(field => field.endsWith('_digest'))) id((body as any)[field]);
}
const signer = trust.validate(proof.credential, body.created_at, undefined, 'host', proof.status_evidence);
requireThat(verify(null, Buffer.from(`OPRP-MIGRATION-0.1.${proof.payload}`), publicKey(signer.signingKey, 'signing'), unb64(proof.signature, 3309)), 'invalid_migration_signature');
if (body.kind === 'plan') requireThat(signer.subject === body.source && signer.signingKid === body.source_signing_kid && signer.encryptionKid === body.source_encryption_kid, 'wrong_migration_signer');
return { body, signer };
}
export function verifiedPlan(proof: MigrationProof, trust: TrustStore, now: number): TransferPlan {
const body = verifyMigration(proof, trust, now).body; requireThat(body.kind === 'plan', 'invalid_transfer_plan'); return body;
}
export function verifiedReceipt(proof: MigrationProof, planProof: MigrationProof, trust: TrustStore, now: number, kind: TransferReceipt['kind']): TransferReceipt {
const plan = verifiedPlan(planProof, trust, now), verified = verifyMigration(proof, trust, now), body = verified.body;
requireThat(body.kind === kind && body.transfer_id === plan.transfer_id && (body as TransferReceipt).plan_digest === proofDigest(planProof) && body.created_at >= plan.created_at, 'migration_context_mismatch');
const side = kind === 'release' || kind === 'abort' ? 'source' : 'destination';
requireThat(verified.signer.subject === plan[side] && verified.signer.signingKid === plan[`${side}_signing_kid`] && verified.signer.encryptionKid === plan[`${side}_encryption_kid`], 'wrong_migration_signer');
return body as TransferReceipt;
}
export function verifyHandoff(handoff: Handoff, trust: TrustStore, now: number): { plan: TransferPlan; archiveDigest: string } {
exact(handoff, ['plan','ready','release','activation']); const plan = verifiedPlan(handoff.plan, trust, now);
const ready = verifiedReceipt(handoff.ready, handoff.plan, trust, now, 'ready');
const release = verifiedReceipt(handoff.release, handoff.plan, trust, now, 'release');
const activation = verifiedReceipt(handoff.activation, handoff.plan, trust, now, 'activation');
requireThat(release.ready_digest === proofDigest(handoff.ready) && activation.release_digest === proofDigest(handoff.release) && release.archive_digest === ready.archive_digest && activation.archive_digest === release.archive_digest && ready.created_at <= release.created_at && release.created_at <= activation.created_at && release.created_at < plan.expires_at, 'migration_context_mismatch');
return { plan, archiveDigest: release.archive_digest! };
}