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>
This commit is contained in:
maverick 2026-07-22 13:04:46 +10:00
parent bd112d07fe
commit 34c7ef08eb
12 changed files with 2233 additions and 2 deletions

View file

@ -0,0 +1,21 @@
-- Multi-device key sync: encrypted recovery backup + QR provisioning relay.
-- Everything stored here is CIPHERTEXT the server cannot read.
-- One encrypted backup blob per user, decryptable only with the user's recovery
-- code (client-side PBKDF2 + AES-GCM). Server sees {salt, iv, ct} — opaque.
CREATE TABLE key_backups (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
payload TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
-- Short-lived QR-linking channel. The new device publishes its ephemeral ECDH
-- public key; the authorizing device drops back a blob encrypted to that key.
-- The server only relays ciphertext. `id` is an unguessable capability token.
CREATE TABLE provisions (
id TEXT PRIMARY KEY,
ephemeral_pub TEXT NOT NULL,
payload TEXT,
created_at INTEGER NOT NULL,
claimed_at INTEGER
);

8
package-lock.json generated
View file

@ -15,6 +15,7 @@
"buffer": "^6.0.3",
"esbuild": "^0.28.1",
"fake-indexeddb": "^6.2.5",
"qrcode-generator": "^2.0.4",
"wrangler": "^4.96.0"
}
},
@ -1563,6 +1564,13 @@
"node": ">=12.0.0"
}
},
"node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",

View file

@ -18,6 +18,7 @@
"buffer": "^6.0.3",
"esbuild": "^0.28.1",
"fake-indexeddb": "^6.2.5",
"qrcode-generator": "^2.0.4",
"wrangler": "^4.96.0"
}
}

View file

@ -102,6 +102,19 @@
}
.modal-input:focus { outline: 2px solid var(--accent); }
.modal-err { color: #f15c6d; font-size: 13px; min-height: 16px; }
.modal-note { color: var(--muted); font-size: 13px; line-height: 1.4; }
textarea.modal-input { resize: vertical; font-family: inherit; }
.code-box {
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 18px; letter-spacing: 1px;
background: var(--panel-2); padding: 14px; border-radius: 10px; text-align: center;
word-break: break-all; user-select: all;
}
.code-box.small { font-size: 12px; letter-spacing: 0; }
.qr { display: block; width: 220px; height: 220px; margin: 0 auto; border-radius: 10px; background: #fff; }
.qr-video { width: 100%; max-width: 280px; border-radius: 10px; display: block; margin: 8px auto; background: #000; }
.auth-divider { text-align: center; color: var(--muted); font-size: 12px; margin-top: 6px; text-transform: uppercase; letter-spacing: 1px; }
.link-btn { background: transparent; color: var(--accent); border: none; padding: 6px; font-size: 14px; cursor: pointer; }
.link-btn:hover { text-decoration: underline; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; }
.modal-actions button { padding: 10px 16px; }

View file

@ -36,7 +36,19 @@ export const api = {
bundle: (userId) => req("GET", "/api/keys/bundle/" + userId),
keyCount: () => req("GET", "/api/keys/count"),
addPreKeys: (oneTimePreKeys) => req("POST", "/api/keys/prekeys", { oneTimePreKeys }),
resetKeys: (signedPreKey, oneTimePreKeys) =>
req("POST", "/api/keys/reset", { signedPreKey, oneTimePreKeys }),
send: (toUserId, messages) => req("POST", "/api/messages/send", { toUserId, messages }),
// recovery backup (server stores/serves only ciphertext)
putBackup: (payload) => req("POST", "/api/backup", { payload }),
getBackup: () => req("GET", "/api/backup"),
backupExists: () => req("GET", "/api/backup/exists"),
// QR provisioning relay
provisionNew: (ephemeralPub) => req("POST", "/api/provision/new", { ephemeralPub }),
provisionGet: (id) => req("GET", "/api/provision/" + id),
provisionComplete: (id, payload) => req("POST", "/api/provision/" + id + "/complete", { payload }),
uploadMedia: async (ciphertext) => {
const res = await req("POST", "/api/media", ciphertext, true);
return res.json ? res.json() : res;

View file

@ -4,6 +4,7 @@ import { api, setToken, MailboxSocket } from "./api.js";
import * as C from "./crypto.js";
import { useAccount, contacts, messages as msgStore } from "./store.js";
import * as accounts from "./accounts.js";
import qrcode from "./vendor/qrcode.js";
const $ = (sel) => document.querySelector(sel);
const el = (tag, props = {}, ...children) => {
@ -58,7 +59,11 @@ function renderAuth() {
const loginBtn = el("button", { type: "submit", className: "primary", textContent: "Log in" });
const registerBtn = el("button", { type: "button", className: "ghost", textContent: "Create account" });
form.append(title, tag, err, username, displayName, password, loginBtn, registerBtn);
const divider = el("div", { className: "auth-divider", textContent: "new device?" });
const restoreBtn = el("button", { type: "button", className: "link-btn", textContent: "Restore with recovery code", onclick: (e) => { e.preventDefault(); startRecoveryRestore(); } });
const qrLinkBtn = el("button", { type: "button", className: "link-btn", textContent: "Link from another device", onclick: (e) => { e.preventDefault(); startQrLink(); } });
form.append(title, tag, err, username, displayName, password, loginBtn, registerBtn, divider, restoreBtn, qrLinkBtn);
root.append(form);
form.onsubmit = async (e) => {
@ -249,6 +254,223 @@ function openModal({ title, placeholder, action, onSubmit }) {
};
}
// ---------- overlay + toast helpers ----------
function makeOverlay(...children) {
const card = el("div", { className: "modal-card" }, ...children);
const overlay = el("div", { className: "modal-overlay" }, card);
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
document.body.append(overlay);
return { overlay, card, close: () => overlay.remove() };
}
function encodeLink(obj) {
return btoa(JSON.stringify(obj)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function decodeLink(s) {
const b = s.trim().replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(atob(b));
}
function qrImg(text) {
const qr = qrcode(0, "M");
qr.addData(text);
qr.make();
const img = el("img", { className: "qr", alt: "linking QR code" });
img.src = qr.createDataURL(6, 12);
return img;
}
// ---------- recovery code: set up backup ----------
async function setupRecovery() {
let code;
try {
const blob = await C.exportIdentityBlob(state.me);
code = C.generateRecoveryCode();
const payload = await C.encryptWithCode(blob, code);
await api.putBackup(payload);
} catch (ex) {
toast("Couldn't set up recovery: " + ex.message);
return;
}
const codeBox = el("div", { className: "code-box", textContent: code });
const copy = el("button", {
type: "button", className: "primary", textContent: "Copy code",
onclick: () => { navigator.clipboard?.writeText(code); copy.textContent = "Copied ✓"; },
});
const done = el("button", { type: "button", className: "ghost", textContent: "I've saved it" });
const { close } = makeOverlay(
el("h2", { className: "modal-title", textContent: "Your recovery code" }),
el("p", { className: "modal-note", textContent: "Save this somewhere safe. It's the ONLY way to restore your account on a new device if you lose this one. We can't recover it for you." }),
codeBox,
el("div", { className: "modal-actions" }, copy, done)
);
done.onclick = close;
}
// ---------- recovery code: restore on a new device ----------
function startRecoveryRestore() {
const uname = el("input", { className: "modal-input", placeholder: "username", autocomplete: "username" });
const pass = el("input", { type: "password", className: "modal-input", placeholder: "password", autocomplete: "current-password" });
const code = el("input", { className: "modal-input", placeholder: "recovery code (XXXX-XXXX-…)" });
const err = el("div", { className: "modal-err" });
const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" });
const go = el("button", { type: "button", className: "primary", textContent: "Restore" });
const { close } = makeOverlay(
el("h2", { className: "modal-title", textContent: "Restore with recovery code" }),
el("p", { className: "modal-note", textContent: "Enter your login and the recovery code you saved when you set up this account." }),
uname, pass, code, err,
el("div", { className: "modal-actions" }, cancel, go)
);
cancel.onclick = close;
go.onclick = async () => {
err.textContent = "";
const u = uname.value.trim().toLowerCase();
if (!u || !pass.value || !code.value) { err.textContent = "All fields required"; return; }
go.disabled = true; go.textContent = "Restoring…";
try {
useAccount(u);
const res = await api.login({ username: u, password: pass.value });
setToken(res.token);
const { payload } = await api.getBackup();
let blob;
try {
blob = await C.decryptWithCode(payload, code.value);
} catch {
throw new Error("Wrong recovery code");
}
const fresh = await C.importIdentityBlob(blob);
await api.resetKeys(fresh.signedPreKey, fresh.oneTimePreKeys);
close();
await onAuthenticated(res);
toast("Account restored on this device", "info");
} catch (ex) {
err.textContent = ex.message === "no backup" ? "No recovery backup found for that account" : ex.message;
go.disabled = false; go.textContent = "Restore";
}
};
}
// ---------- QR linking: authorize a new device (from the signed-in device) ----------
function linkDeviceAuthorizer() {
const paste = el("textarea", { className: "modal-input", placeholder: "Paste the linking code from your new device", rows: 3 });
const err = el("div", { className: "modal-err" });
const status = el("div", { className: "modal-note" });
const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" });
const approve = el("button", { type: "button", className: "primary", textContent: "Approve link" });
const scanBtn = el("button", { type: "button", className: "ghost", textContent: "📷 Scan QR" });
const scanArea = el("div", {});
const { close } = makeOverlay(
el("h2", { className: "modal-title", textContent: "Link a device" }),
el("p", { className: "modal-note", textContent: "On your new device choose “Link from another device”, then scan its QR here or paste its code." }),
scanBtn, scanArea, paste, status, err,
el("div", { className: "modal-actions" }, cancel, approve)
);
cancel.onclick = close;
const doApprove = async (linkText) => {
err.textContent = "";
let link;
try { link = decodeLink(linkText); } catch { err.textContent = "Invalid linking code"; return; }
if (!link.p || !link.k) { err.textContent = "Invalid linking code"; return; }
approve.disabled = true; status.textContent = "Approving…";
try {
const blob = { ...(await C.exportIdentityBlob(state.me)), token: state.me.token };
const payload = await C.encryptToEphemeral(blob, link.k);
await api.provisionComplete(link.p, payload);
close();
toast("Device linked ✓", "info");
} catch (ex) {
err.textContent = ex.message;
approve.disabled = false; status.textContent = "";
}
};
approve.onclick = () => doApprove(paste.value);
scanBtn.onclick = async () => {
if (!("BarcodeDetector" in window)) { err.textContent = "Camera scanning not supported here — paste the code instead"; return; }
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
const video = el("video", { className: "qr-video", autoplay: true, playsInline: true, muted: true });
video.srcObject = stream;
scanArea.innerHTML = ""; scanArea.append(video);
const detector = new BarcodeDetector({ formats: ["qr_code"] });
const tick = async () => {
if (!video.isConnected) { stream.getTracks().forEach((t) => t.stop()); return; }
try {
const codes = await detector.detect(video);
if (codes.length) {
stream.getTracks().forEach((t) => t.stop());
scanArea.innerHTML = "";
await doApprove(codes[0].rawValue);
return;
}
} catch {}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
} catch {
err.textContent = "Couldn't open camera — paste the code instead";
}
};
}
// ---------- QR linking: get linked (on the new device) ----------
async function startQrLink() {
let eph, provisionId;
const status = el("div", { className: "modal-note", textContent: "Generating link…" });
const codeText = el("div", { className: "code-box small" });
const copy = el("button", { type: "button", className: "ghost", textContent: "Copy code" });
const cancel = el("button", { type: "button", className: "ghost", textContent: "Cancel" });
const qrHolder = el("div", {});
const { overlay, close } = makeOverlay(
el("h2", { className: "modal-title", textContent: "Link from another device" }),
el("p", { className: "modal-note", textContent: "On a device already signed in, choose “Link a device” and scan this QR (or paste the code)." }),
qrHolder, status, codeText, el("div", { className: "modal-actions" }, copy, cancel)
);
let cancelled = false;
cancel.onclick = () => { cancelled = true; close(); };
try {
eph = await C.generateEphemeralKeyPair();
const res = await api.provisionNew(eph.pubB64);
provisionId = res.provisionId;
} catch (ex) {
status.textContent = "Couldn't start linking: " + ex.message;
return;
}
const linkCode = encodeLink({ p: provisionId, k: eph.pubB64 });
qrHolder.append(qrImg(linkCode));
codeText.textContent = linkCode;
status.textContent = "Waiting for approval…";
copy.onclick = () => { navigator.clipboard?.writeText(linkCode); copy.textContent = "Copied ✓"; };
// poll for the encrypted blob
for (let i = 0; i < 150 && !cancelled; i++) {
await new Promise((r) => setTimeout(r, 1200));
let got;
try { got = await api.provisionGet(provisionId); } catch { continue; }
if (got?.payload) {
status.textContent = "Approved — restoring…";
try {
const blob = await C.decryptFromEphemeral(got.payload, eph.keyPair);
useAccount(blob.username);
setToken(blob.token);
const fresh = await C.importIdentityBlob(blob);
await api.resetKeys(fresh.signedPreKey, fresh.oneTimePreKeys);
close();
await onAuthenticated({
userId: blob.userId, deviceId: blob.deviceId, username: blob.username,
displayName: blob.displayName, token: blob.token,
});
toast("Device linked ✓", "info");
} catch (ex) {
status.textContent = "Link failed: " + ex.message;
}
return;
}
}
if (!cancelled) status.textContent = "Link timed out — try again";
}
// ---------- account menu (switch / add / log out) ----------
function openAccountMenu() {
const all = accounts.list();
@ -267,6 +489,18 @@ function openAccountMenu() {
})
);
}
const recoveryBtn = el("button", {
type: "button",
className: "acct-action",
textContent: "🔑 Set up recovery code",
onclick: () => { overlay.remove(); setupRecovery(); },
});
const linkBtn = el("button", {
type: "button",
className: "acct-action",
textContent: "🔗 Link a device",
onclick: () => { overlay.remove(); linkDeviceAuthorizer(); },
});
const addBtn = el("button", {
type: "button",
className: "acct-action",
@ -289,6 +523,8 @@ function openAccountMenu() {
const card = el("div", { className: "modal-card" },
el("h2", { className: "modal-title", textContent: "Accounts" }),
...rows,
recoveryBtn,
linkBtn,
addBtn,
logoutBtn
);

View file

@ -81,6 +81,181 @@ export async function generateMoreOneTimePreKeys(count = 100) {
return oneTimePreKeys;
}
// ---- multi-device key sync (recovery backup + QR linking) ----
// Serialize the on-device identity so it can be restored on another device.
// This is the SECRET material — it is only ever emitted encrypted.
export async function exportIdentityBlob(account) {
const idkp = await store.getIdentityKeyPair();
const registrationId = await store.getLocalRegistrationId();
if (!idkp) throw new Error("no identity on this device");
return {
v: 1,
userId: account.userId,
deviceId: account.deviceId,
username: account.username,
displayName: account.displayName,
registrationId,
identityKey: { pub: abToB64(idkp.pubKey), priv: abToB64(idkp.privKey) },
};
}
// Restore an identity blob into the (already namespaced) store, then generate a
// fresh set of prekeys to publish. Caller must have called useAccount() first.
// Old sessions/ratchets don't survive — new conversations re-handshake cleanly.
export async function importIdentityBlob(blob) {
const identityKeyPair = {
pubKey: b64ToAb(blob.identityKey.pub),
privKey: b64ToAb(blob.identityKey.priv),
};
await store.setIdentity(identityKeyPair, blob.registrationId);
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, SIGNED_PREKEY_ID);
await store.storeSignedPreKey(SIGNED_PREKEY_ID, signedPreKey.keyPair);
const oneTimePreKeys = [];
for (let i = 1; i <= NUM_ONE_TIME_PREKEYS; i++) {
const pk = await KeyHelper.generatePreKey(i);
await store.storePreKey(i, pk.keyPair);
oneTimePreKeys.push({ id: i, pub: abToB64(pk.keyPair.pubKey) });
}
return {
signedPreKey: {
id: SIGNED_PREKEY_ID,
pub: abToB64(signedPreKey.keyPair.pubKey),
sig: abToB64(signedPreKey.signature),
},
oneTimePreKeys,
};
}
// A human-writable recovery code: 24 Crockford-base32 chars in 6 groups of 4.
// ~120 bits of entropy — infeasible to brute force even with the blob in hand.
export function generateRecoveryCode() {
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // no I L O U
const bytes = crypto.getRandomValues(new Uint8Array(15));
let bits = 0, value = 0, out = "";
for (const b of bytes) {
value = (value << 8) | b;
bits += 8;
while (bits >= 5) {
out += alphabet[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
return out.match(/.{1,4}/g).join("-"); // e.g. XK4P-92MB-...
}
function normalizeCode(code) {
return code.toUpperCase().replace(/[^0-9A-Z]/g, "");
}
async function deriveCodeKey(code, salt) {
const base = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(normalizeCode(code)),
"PBKDF2",
false,
["deriveKey"]
);
// Client-side (browser) has no 100k PBKDF2 cap, unlike the Worker.
return crypto.subtle.deriveKey(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: 250000 },
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
// Encrypt an object under a recovery code -> {salt, iv, ct} (all base64).
export async function encryptWithCode(obj, code) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveCodeKey(code, salt);
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(JSON.stringify(obj))
);
return { salt: abToB64(salt.buffer), iv: abToB64(iv.buffer), ct: abToB64(ct) };
}
export async function decryptWithCode(payload, code) {
const key = await deriveCodeKey(code, new Uint8Array(b64ToAb(payload.salt)));
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(b64ToAb(payload.iv)) },
key,
b64ToAb(payload.ct)
);
return JSON.parse(new TextDecoder().decode(new Uint8Array(pt)));
}
// ---- QR linking: ECDH to the new device's ephemeral public key ----
// New device generates an ephemeral P-256 keypair; the raw public key goes in
// the QR. The private key never leaves this device.
export async function generateEphemeralKeyPair() {
const kp = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
"deriveKey",
]);
const rawPub = await crypto.subtle.exportKey("raw", kp.publicKey);
return { keyPair: kp, pubB64: abToB64(rawPub) };
}
// Authorizing device: encrypt a blob to the new device's ephemeral public key.
// Uses an ephemeral-static ECDH (fresh sender key each time) -> AES-GCM.
export async function encryptToEphemeral(obj, peerPubB64) {
const peerPub = await crypto.subtle.importKey(
"raw",
b64ToAb(peerPubB64),
{ name: "ECDH", namedCurve: "P-256" },
false,
[]
);
const my = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
"deriveKey",
]);
const key = await crypto.subtle.deriveKey(
{ name: "ECDH", public: peerPub },
my.privateKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(JSON.stringify(obj))
);
const myRawPub = await crypto.subtle.exportKey("raw", my.publicKey);
return { epk: abToB64(myRawPub), iv: abToB64(iv.buffer), ct: abToB64(ct) };
}
// New device: decrypt the relayed blob using its ephemeral private key.
export async function decryptFromEphemeral(payload, ephemeralKeyPair) {
const senderPub = await crypto.subtle.importKey(
"raw",
b64ToAb(payload.epk),
{ name: "ECDH", namedCurve: "P-256" },
false,
[]
);
const key = await crypto.subtle.deriveKey(
{ name: "ECDH", public: senderPub },
ephemeralKeyPair.privateKey,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"]
);
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(b64ToAb(payload.iv)) },
key,
b64ToAb(payload.ct)
);
return JSON.parse(new TextDecoder().decode(new Uint8Array(pt)));
}
// Establish outbound sessions with every device in a fetched prekey bundle.
export async function ensureSessions(bundlesResponse) {
for (const b of bundlesResponse.bundles) {

1639
public/js/vendor/qrcode.js vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
// Service worker: offline app shell + Web Push wake-up.
// It never touches message plaintext — decryption happens only in the page.
const CACHE = "cipher-v3";
const CACHE = "cipher-v4";
const SHELL = [
"/",
"/index.html",
@ -12,6 +12,7 @@ const SHELL = [
"/js/store.js",
"/js/accounts.js",
"/js/vendor/libsignal.js",
"/js/vendor/qrcode.js",
"/icons/icon.svg",
];

View file

@ -6,6 +6,7 @@ import auth from "./routes/auth.js";
import keys from "./routes/keys.js";
import messages from "./routes/messages.js";
import media from "./routes/media.js";
import backup from "./routes/backup.js";
export { Mailbox } from "./do/mailbox.js";
@ -22,6 +23,7 @@ app.route("/api/auth", auth);
app.route("/api", keys);
app.route("/api", messages);
app.route("/api", media);
app.route("/api", backup);
// Everything else falls through to static assets (the PWA), handled by the
// ASSETS binding via not_found_handling: single-page-application.

95
src/routes/backup.js Normal file
View file

@ -0,0 +1,95 @@
// 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;

View file

@ -52,6 +52,34 @@ app.post("/keys/signed", requireAuth(), async (c) => {
return c.json({ ok: true });
});
// POST /api/keys/reset — after restoring an identity on a new device, the old
// prekeys' private keys are gone. Atomically DELETE all stale one-time prekeys,
// replace the signed prekey, and install the fresh one-time prekeys, so the
// server only ever hands out prekeys this device can actually complete.
// body: { signedPreKey: {id,pub,sig}, oneTimePreKeys: [{id,pub}...] }
app.post("/keys/reset", requireAuth(), async (c) => {
const deviceId = c.get("deviceId");
const body = await c.req.json().catch(() => null);
const spk = body?.signedPreKey;
const otks = body?.oneTimePreKeys || [];
if (!spk) return c.json({ error: "missing signedPreKey" }, 400);
const stmts = [
c.env.DB.prepare("DELETE FROM one_time_prekeys WHERE device_id = ?").bind(deviceId),
c.env.DB.prepare(
"UPDATE devices SET signed_prekey_id=?, signed_prekey_pub=?, signed_prekey_sig=? WHERE id=?"
).bind(spk.id, spk.pub, spk.sig, deviceId),
];
for (const o of otks) {
stmts.push(
c.env.DB.prepare(
"INSERT INTO one_time_prekeys (device_id, prekey_id, prekey_pub) VALUES (?,?,?)"
).bind(deviceId, o.id, o.pub)
);
}
await c.env.DB.batch(stmts);
return c.json({ ok: true, prekeys: otks.length });
});
// GET /api/keys/count — how many one-time prekeys remain for my device
app.get("/keys/count", requireAuth(), async (c) => {
const deviceId = c.get("deviceId");