// Encrypted recovery backup + QR provisioning relay. The server stores and // relays ONLY ciphertext — it can never read the keys passing through here. import { Hono } from "hono"; import { requireAuth } from "../lib/auth.js"; import { now, randomToken } from "../lib/util.js"; const app = new Hono(); const PROVISION_TTL = 1000 * 60 * 10; // 10 minutes // ---- recovery backup (decryptable only with the user's recovery code) ---- // POST /api/backup body: { payload: {salt, iv, ct} } app.post("/backup", requireAuth(), async (c) => { const userId = c.get("userId"); const body = await c.req.json().catch(() => null); if (!body?.payload) return c.json({ error: "missing payload" }, 400); await c.env.DB.prepare( `INSERT INTO key_backups (user_id, payload, updated_at) VALUES (?,?,?) ON CONFLICT(user_id) DO UPDATE SET payload=excluded.payload, updated_at=excluded.updated_at` ) .bind(userId, JSON.stringify(body.payload), now()) .run(); return c.json({ ok: true }); }); // GET /api/backup -> { payload } | 404 app.get("/backup", requireAuth(), async (c) => { const userId = c.get("userId"); const row = await c.env.DB.prepare("SELECT payload FROM key_backups WHERE user_id = ?") .bind(userId) .first(); if (!row) return c.json({ error: "no backup" }, 404); return c.json({ payload: JSON.parse(row.payload) }); }); // GET /api/backup/exists -> { exists } (used by login to offer restore) app.get("/backup/exists", requireAuth(), async (c) => { const userId = c.get("userId"); const row = await c.env.DB.prepare("SELECT 1 AS x FROM key_backups WHERE user_id = ?") .bind(userId) .first(); return c.json({ exists: !!row }); }); // ---- QR provisioning relay ---- // POST /api/provision/new body: { ephemeralPub } (UNAUTH — new device has no account yet) app.post("/provision/new", async (c) => { const body = await c.req.json().catch(() => null); if (!body?.ephemeralPub) return c.json({ error: "missing ephemeralPub" }, 400); const id = randomToken(18); await c.env.DB.prepare( "INSERT INTO provisions (id, ephemeral_pub, created_at) VALUES (?,?,?)" ) .bind(id, body.ephemeralPub, now()) .run(); return c.json({ provisionId: id }); }); // GET /api/provision/:id (UNAUTH — id is the capability) -> { ephemeralPub, payload|null } app.get("/provision/:id", async (c) => { const id = c.req.param("id"); const row = await c.env.DB.prepare( "SELECT ephemeral_pub, payload, created_at FROM provisions WHERE id = ?" ) .bind(id) .first(); if (!row) return c.json({ error: "not found" }, 404); if (row.created_at + PROVISION_TTL < now()) return c.json({ error: "expired" }, 410); return c.json({ ephemeralPub: row.ephemeral_pub, payload: row.payload ? JSON.parse(row.payload) : null, }); }); // POST /api/provision/:id/complete (AUTH) body: { payload } // The authorizing device drops the blob it encrypted to the new device's key. app.post("/provision/:id/complete", requireAuth(), async (c) => { const id = c.req.param("id"); const body = await c.req.json().catch(() => null); if (!body?.payload) return c.json({ error: "missing payload" }, 400); const row = await c.env.DB.prepare("SELECT created_at FROM provisions WHERE id = ?") .bind(id) .first(); if (!row) return c.json({ error: "not found" }, 404); if (row.created_at + PROVISION_TTL < now()) return c.json({ error: "expired" }, 410); await c.env.DB.prepare("UPDATE provisions SET payload=?, claimed_at=? WHERE id=?") .bind(JSON.stringify(body.payload), now(), id) .run(); return c.json({ ok: true }); }); export default app;