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:
parent
bd112d07fe
commit
34c7ef08eb
12 changed files with 2233 additions and 2 deletions
21
migrations/0002_key_sync.sql
Normal file
21
migrations/0002_key_sync.sql
Normal 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
8
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
238
public/js/app.js
238
public/js/app.js
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
1639
public/js/vendor/qrcode.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
95
src/routes/backup.js
Normal 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;
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue