cipher/src/routes/backup.js
maverick 34c7ef08eb Add multi-device key sync: QR device linking + encrypted recovery code
Answers 'how do you get keys to login elsewhere' — both mechanisms, Signal/WhatsApp style:

Server:
- migration 0002: key_backups (recovery blobs) + provisions (QR relay), ciphertext only
- routes/backup.js: POST/GET /api/backup, /backup/exists; provision new/get/complete
- routes/keys.js: POST /api/keys/reset — atomically wipe stale prekeys + install fresh
  (fixes Bad MAC: restore regenerates prekey IDs that INSERT-OR-IGNORE kept stale)

Crypto (crypto.js):
- exportIdentityBlob/importIdentityBlob (identity travels only encrypted)
- generateRecoveryCode (120-bit Crockford base32) + encrypt/decryptWithCode (PBKDF2 250k)
- ephemeral ECDH (P-256) encrypt/decrypt for QR transfer

Client (app.js): account-menu 'Set up recovery code' + 'Link a device' (camera scan
via BarcodeDetector or paste); auth-screen 'Restore with recovery code' + 'Link from
another device' (shows QR + code, polls, restores). Vendored qrcode-generator.

All verified live end-to-end (3-process isolated-store integration tests):
restore-from-code and QR-link both let a fresh device receive + decrypt messages.
SW cache v3->v4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:04:46 +10:00

95 lines
3.6 KiB
JavaScript

// 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;