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>
95 lines
3.6 KiB
JavaScript
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;
|