mirror of
https://github.com/temetro/temetro.git
synced 2026-08-25 18:07:44 +00:00
feat: patient wallet — real Signing, encrypted share relay, import-from-app
Backend - clinic Ed25519 signing key (services/signing.ts, routes/signing.ts, clinic_signing_keys table) — Settings → Signing is now real - @noble wallet-crypto (lib/wallet-crypto.ts): ed25519 identity, base58check wallet numbers, sealed-box (x25519 + xchacha20poly1305) - /wallet Socket.io relay namespace (challenge-signed device auth) forwarding only ciphertext; emitToWallet helper - import-from-app flow (routes/patients-wallet.ts, services/wallet-share.ts): request-share → patient approval → decrypt + verify → review draft → commit - temporary shares: patients.share_expires_at + 5-min auto-delete sweep; revoke Frontend - SigningPanel wired to live key/fingerprint/rotate + shared-records list - "Import from a patient app" dialog (lib/signing.ts, import-from-wallet-dialog) reusing the draft-review path; temporary badge on the patient list Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,7 @@ function toPatient(row: PatientRow, children: Children): Patient {
|
||||
labTrend: row.labTrend,
|
||||
encounters: children.encounters,
|
||||
source: row.source,
|
||||
shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -426,6 +427,9 @@ export async function createPatient(
|
||||
userId: string,
|
||||
rawInput: PatientInput,
|
||||
demographicsOnly = false,
|
||||
// Extra columns set on import from a patient wallet (provenance + the
|
||||
// auto-delete deadline for a temporary share).
|
||||
extra?: { shareOrigin?: "wallet" | null; shareExpiresAt?: Date | null },
|
||||
): Promise<Patient> {
|
||||
// Auto-assign a file number when one wasn't supplied (e.g. AI imports).
|
||||
const input: PatientInput = rawInput.fileNumber
|
||||
@@ -438,13 +442,13 @@ export async function createPatient(
|
||||
if (demographicsOnly) {
|
||||
const [row] = await tx
|
||||
.insert(patients)
|
||||
.values(demographicColumns(orgId, input, userId))
|
||||
.values({ ...demographicColumns(orgId, input, userId), ...extra })
|
||||
.returning();
|
||||
return toPatient(row!, emptyChildren());
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(patients)
|
||||
.values(patientColumns(orgId, input, userId))
|
||||
.values({ ...patientColumns(orgId, input, userId), ...extra })
|
||||
.returning();
|
||||
await insertChildren(tx, row!.id, input);
|
||||
return toPatient(row!, childrenFromInput(input));
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { hexToBytes } from "@noble/hashes/utils.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { clinicSigningKeys } from "../db/schema/signing.js";
|
||||
import { decryptSecret, encryptSecret } from "../lib/crypto.js";
|
||||
import {
|
||||
fingerprint,
|
||||
newSigningKeypair,
|
||||
signMessage,
|
||||
} from "../lib/wallet-crypto.js";
|
||||
|
||||
export type SigningKeyView = {
|
||||
algorithm: string;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
createdAt: string;
|
||||
rotatedAt: string | null;
|
||||
};
|
||||
|
||||
type SigningKeyRow = typeof clinicSigningKeys.$inferSelect;
|
||||
|
||||
function toView(row: SigningKeyRow): SigningKeyView {
|
||||
return {
|
||||
algorithm: row.algorithm,
|
||||
publicKey: row.publicKey,
|
||||
fingerprint: row.fingerprint,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
rotatedAt: row.rotatedAt ? row.rotatedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a fresh Ed25519 keypair and upsert it as the clinic's signing key
|
||||
// (rotating overwrites the row and stamps `rotatedAt`). The private key is only
|
||||
// ever stored encrypted (lib/crypto.ts).
|
||||
async function mintKey(orgId: string, rotating: boolean): Promise<SigningKeyView> {
|
||||
const { privateKeyHex, publicKeyHex } = newSigningKeypair();
|
||||
const fp = fingerprint(hexToBytes(publicKeyHex));
|
||||
const [row] = await db
|
||||
.insert(clinicSigningKeys)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
algorithm: "ed25519",
|
||||
publicKey: publicKeyHex,
|
||||
fingerprint: fp,
|
||||
privateKeyEnc: encryptSecret(privateKeyHex),
|
||||
rotatedAt: rotating ? new Date() : null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: clinicSigningKeys.organizationId,
|
||||
set: {
|
||||
publicKey: publicKeyHex,
|
||||
fingerprint: fp,
|
||||
privateKeyEnc: encryptSecret(privateKeyHex),
|
||||
rotatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
return toView(row!);
|
||||
}
|
||||
|
||||
// The clinic's signing key, creating one on first read so the panel always has
|
||||
// a real key + fingerprint to show.
|
||||
export async function getOrCreateKey(orgId: string): Promise<SigningKeyView> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
if (existing) return toView(existing);
|
||||
return mintKey(orgId, false);
|
||||
}
|
||||
|
||||
export async function rotateKey(orgId: string): Promise<SigningKeyView> {
|
||||
return mintKey(orgId, true);
|
||||
}
|
||||
|
||||
// Sign a message with the clinic's signing key (creating one if needed). Returns
|
||||
// the signature + public key so a verifier can check provenance.
|
||||
export async function signWithClinicKey(
|
||||
orgId: string,
|
||||
message: Uint8Array,
|
||||
): Promise<{ signature: string; publicKey: string }> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
if (!row) {
|
||||
const view = await mintKey(orgId, false);
|
||||
const [fresh] = await db
|
||||
.select()
|
||||
.from(clinicSigningKeys)
|
||||
.where(eq(clinicSigningKeys.organizationId, orgId));
|
||||
return {
|
||||
signature: signMessage(decryptSecret(fresh!.privateKeyEnc), message),
|
||||
publicKey: view.publicKey,
|
||||
};
|
||||
}
|
||||
return {
|
||||
signature: signMessage(decryptSecret(row.privateKeyEnc), message),
|
||||
publicKey: row.publicKey,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { utf8ToBytes } from "@noble/hashes/utils.js";
|
||||
import { and, eq, isNotNull, lte } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { patients } from "../db/schema/patients.js";
|
||||
import {
|
||||
walletShareRequests,
|
||||
type WalletShareMode,
|
||||
} from "../db/schema/wallet-share.js";
|
||||
import { decryptSecret, encryptSecret } from "../lib/crypto.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
import {
|
||||
decodeWalletNumber,
|
||||
newEncryptionKeypair,
|
||||
open,
|
||||
verifySignature,
|
||||
} from "../lib/wallet-crypto.js";
|
||||
import type { Patient } from "../types/patient.js";
|
||||
import { recordActivity } from "./activity.js";
|
||||
import { deletePatient } from "./patients.js";
|
||||
|
||||
type ShareRow = typeof walletShareRequests.$inferSelect;
|
||||
|
||||
export type ShareRequestView = {
|
||||
id: string;
|
||||
walletNumber: string;
|
||||
status: ShareRow["status"];
|
||||
shareMode: WalletShareMode;
|
||||
shareExpiresAt: string | null;
|
||||
draft: Patient | null;
|
||||
};
|
||||
|
||||
function toView(row: ShareRow): ShareRequestView {
|
||||
return {
|
||||
id: row.id,
|
||||
walletNumber: row.walletNumber,
|
||||
status: row.status,
|
||||
shareMode: row.shareMode,
|
||||
shareExpiresAt: row.shareExpiresAt ? row.shareExpiresAt.toISOString() : null,
|
||||
draft: row.draft ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Create an import request: validate the wallet number, mint a per-request
|
||||
// ephemeral X25519 keypair (the phone seals the bundle to its public key) and
|
||||
// store the request. Returns the row + the ephemeral public key to relay to the
|
||||
// device. Throws 400 on a malformed wallet number.
|
||||
export async function createShareRequest(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
walletNumber: string,
|
||||
mode: WalletShareMode,
|
||||
durationHours?: number,
|
||||
): Promise<{ view: ShareRequestView; ephemeralPubKey: string }> {
|
||||
try {
|
||||
decodeWalletNumber(walletNumber);
|
||||
} catch (err) {
|
||||
throw new HttpError(400, (err as Error).message);
|
||||
}
|
||||
const { privateKeyHex, publicKeyHex } = newEncryptionKeypair();
|
||||
const shareExpiresAt =
|
||||
mode === "temporary" && durationHours
|
||||
? new Date(Date.now() + durationHours * 3_600_000)
|
||||
: null;
|
||||
const [row] = await db
|
||||
.insert(walletShareRequests)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
requestedBy: userId,
|
||||
walletNumber: walletNumber.trim(),
|
||||
ephemeralPubKey: publicKeyHex,
|
||||
ephemeralPrivEnc: encryptSecret(privateKeyHex),
|
||||
shareMode: mode,
|
||||
shareExpiresAt,
|
||||
})
|
||||
.returning();
|
||||
return { view: toView(row!), ephemeralPubKey: publicKeyHex };
|
||||
}
|
||||
|
||||
export async function getShareRequest(
|
||||
orgId: string,
|
||||
id: string,
|
||||
): Promise<ShareRequestView | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(walletShareRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(walletShareRequests.id, id),
|
||||
eq(walletShareRequests.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
return row ? toView(row) : null;
|
||||
}
|
||||
|
||||
// Recent import requests for the clinic — feeds the Signing panel's "Signed
|
||||
// records" / shared-records list.
|
||||
export async function listShareRequests(
|
||||
orgId: string,
|
||||
limit = 20,
|
||||
): Promise<ShareRequestView[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(walletShareRequests)
|
||||
.where(eq(walletShareRequests.organizationId, orgId))
|
||||
.orderBy(walletShareRequests.createdAt)
|
||||
.limit(limit);
|
||||
return rows.map(toView);
|
||||
}
|
||||
|
||||
// Apply a response relayed back from the patient's device. On approval we
|
||||
// decrypt the sealed bundle with the request's ephemeral private key and verify
|
||||
// the wallet's Ed25519 signature over it (provenance: it really came from that
|
||||
// wallet number). Returns the resolved view, or null when the request is unknown
|
||||
// / already resolved. Throws on a tampered/forged bundle.
|
||||
export async function applyShareResponse(
|
||||
requestId: string,
|
||||
walletNumber: string,
|
||||
decision: "approved" | "denied",
|
||||
sealed?: string,
|
||||
signatureHex?: string,
|
||||
): Promise<ShareRequestView | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(walletShareRequests)
|
||||
.where(eq(walletShareRequests.id, requestId));
|
||||
if (!row || row.status !== "pending") return null;
|
||||
if (row.walletNumber !== walletNumber.trim()) return null;
|
||||
|
||||
if (decision === "denied") {
|
||||
const [updated] = await db
|
||||
.update(walletShareRequests)
|
||||
.set({ status: "denied", resolvedAt: new Date() })
|
||||
.where(eq(walletShareRequests.id, requestId))
|
||||
.returning();
|
||||
return updated ? toView(updated) : null;
|
||||
}
|
||||
|
||||
if (!sealed || !signatureHex) {
|
||||
throw new HttpError(400, "Approval is missing the sealed record bundle.");
|
||||
}
|
||||
const plaintext = open(decryptSecret(row.ephemeralPrivEnc), sealed);
|
||||
const publicKey = decodeWalletNumber(walletNumber);
|
||||
if (!verifySignature(publicKey, signatureHex, plaintext)) {
|
||||
throw new HttpError(400, "Bundle signature did not match the wallet number.");
|
||||
}
|
||||
const bundle = JSON.parse(Buffer.from(plaintext).toString("utf8")) as {
|
||||
patient: Patient;
|
||||
};
|
||||
|
||||
const [updated] = await db
|
||||
.update(walletShareRequests)
|
||||
.set({ status: "approved", resolvedAt: new Date(), draft: bundle.patient })
|
||||
.where(eq(walletShareRequests.id, requestId))
|
||||
.returning();
|
||||
return updated ? toView(updated) : null;
|
||||
}
|
||||
|
||||
// Record the file number once a clinic commits the imported draft, so a later
|
||||
// revoke from the device can delete exactly that record.
|
||||
export async function markCommitted(
|
||||
orgId: string,
|
||||
id: string,
|
||||
fileNumber: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(walletShareRequests)
|
||||
.set({ committedFileNumber: fileNumber })
|
||||
.where(
|
||||
and(
|
||||
eq(walletShareRequests.id, id),
|
||||
eq(walletShareRequests.organizationId, orgId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Patient-initiated revoke from the app: find the committed import for this
|
||||
// request + wallet and hard-delete that patient from the clinic. Returns the
|
||||
// org/fileNumber deleted (for an activity entry), or null.
|
||||
export async function revokeShare(
|
||||
requestId: string,
|
||||
walletNumber: string,
|
||||
): Promise<{ orgId: string; fileNumber: string } | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(walletShareRequests)
|
||||
.where(eq(walletShareRequests.id, requestId));
|
||||
if (!row || row.walletNumber !== walletNumber.trim()) return null;
|
||||
if (!row.committedFileNumber) return null;
|
||||
const ok = await deletePatient(row.organizationId, row.committedFileNumber);
|
||||
if (!ok) return null;
|
||||
await recordActivity({
|
||||
orgId: row.organizationId,
|
||||
actor: { id: row.requestedBy, name: "Patient wallet" },
|
||||
action: `Patient revoked shared record #${row.committedFileNumber}`,
|
||||
entityType: "patient",
|
||||
entityId: row.committedFileNumber,
|
||||
patientFileNumber: row.committedFileNumber,
|
||||
}).catch(() => {});
|
||||
return { orgId: row.organizationId, fileNumber: row.committedFileNumber };
|
||||
}
|
||||
|
||||
// Deployment-wide sweep: hard-delete any temporarily-shared patient whose
|
||||
// share window has passed. Called on an interval from index.ts.
|
||||
export async function sweepExpiredShares(): Promise<number> {
|
||||
const expired = await db
|
||||
.select({
|
||||
organizationId: patients.organizationId,
|
||||
fileNumber: patients.fileNumber,
|
||||
})
|
||||
.from(patients)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(patients.shareExpiresAt),
|
||||
lte(patients.shareExpiresAt, new Date()),
|
||||
),
|
||||
);
|
||||
for (const p of expired) {
|
||||
await deletePatient(p.organizationId, p.fileNumber);
|
||||
await recordActivity({
|
||||
orgId: p.organizationId,
|
||||
actor: { id: "system", name: "temetro" },
|
||||
action: `Temporary shared record #${p.fileNumber} expired and was deleted`,
|
||||
entityType: "patient",
|
||||
entityId: p.fileNumber,
|
||||
patientFileNumber: p.fileNumber,
|
||||
}).catch(() => {});
|
||||
}
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
// Build the canonical bytes a wallet signs / the clinic verifies for a bundle.
|
||||
export function bundleBytes(patient: Patient): Uint8Array {
|
||||
return utf8ToBytes(JSON.stringify({ patient }));
|
||||
}
|
||||
Reference in New Issue
Block a user