Files
temetro/backend/src/services/signing.ts
T
Khalid Abdi 99aa534e88 feat: multi-clinic Temetro Network with per-clinic identity (v0.8.0)
The relay is now multi-clinic. Each clinic authenticates to the /hub
namespace by signing a challenge with its own Ed25519 clinic signing key
(a per-clinic identity, not a shared RELAY_TOKEN), and the relay routes
every device response back to only the clinic that originated the request
(keyed by requestId) — so clinics never see each other's traffic.

Backend:
- clinic_signing_keys.network_enabled + GET/PUT /api/signing/network
  (owner/admin) to join/leave the network.
- relay-client keeps one authenticated hub connection per network-enabled
  org (connectOrg/disconnectOrg, hubs map keyed by orgId); emitToWallet/
  sendToWallet take orgId; offline flush is org-scoped.
- Wallet import/push return 409 until a clinic joins.
- RELAY_TOKEN is now optional/legacy (open relay needs no shared secret).

Frontend:
- "Join Temetro Network" toggle in Settings → Signing, localized in all
  five languages (en, fr, de, so, ar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:30:19 +03:00

137 lines
4.4 KiB
TypeScript

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);
}
// Whether this clinic has joined the Temetro Network relay. Defaults to `false`
// when the clinic has no signing key yet (it hasn't opted in).
export async function getNetworkEnabled(orgId: string): Promise<boolean> {
const [row] = await db
.select({ networkEnabled: clinicSigningKeys.networkEnabled })
.from(clinicSigningKeys)
.where(eq(clinicSigningKeys.organizationId, orgId));
return row?.networkEnabled ?? false;
}
// Org ids of every clinic currently on the network — used at startup to open a
// relay hub connection for each.
export async function networkEnabledOrgs(): Promise<string[]> {
const rows = await db
.select({ organizationId: clinicSigningKeys.organizationId })
.from(clinicSigningKeys)
.where(eq(clinicSigningKeys.networkEnabled, true));
return rows.map((r) => r.organizationId);
}
// Join or leave the Temetro Network. Ensures the clinic has a signing key first
// (the relay authenticates with it), then flips the flag. Returns the new state.
export async function setNetworkEnabled(
orgId: string,
enabled: boolean,
): Promise<boolean> {
await getOrCreateKey(orgId);
await db
.update(clinicSigningKeys)
.set({ networkEnabled: enabled })
.where(eq(clinicSigningKeys.organizationId, orgId));
return enabled;
}
// 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,
};
}