mirror of
https://github.com/temetro/temetro.git
synced 2026-08-10 18:49:29 +00:00
99aa534e88
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>
137 lines
4.4 KiB
TypeScript
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,
|
|
};
|
|
}
|