From 34c7ef08eb6fc71020fc12f672d51475173916a5 Mon Sep 17 00:00:00 2001 From: maverick Date: Wed, 22 Jul 2026 13:04:46 +1000 Subject: [PATCH] Add multi-device key sync: QR device linking + encrypted recovery code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- migrations/0002_key_sync.sql | 21 + package-lock.json | 8 + package.json | 1 + public/index.html | 13 + public/js/api.js | 12 + public/js/app.js | 238 ++++- public/js/crypto.js | 175 ++++ public/js/vendor/qrcode.js | 1639 ++++++++++++++++++++++++++++++++++ public/sw.js | 3 +- src/index.js | 2 + src/routes/backup.js | 95 ++ src/routes/keys.js | 28 + 12 files changed, 2233 insertions(+), 2 deletions(-) create mode 100644 migrations/0002_key_sync.sql create mode 100644 public/js/vendor/qrcode.js create mode 100644 src/routes/backup.js diff --git a/migrations/0002_key_sync.sql b/migrations/0002_key_sync.sql new file mode 100644 index 0000000..c5dcf3e --- /dev/null +++ b/migrations/0002_key_sync.sql @@ -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 +); diff --git a/package-lock.json b/package-lock.json index 7f0c9d0..841b494 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d41c1d9..5919842 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/index.html b/public/index.html index 48a8c76..b4b9189 100644 --- a/public/index.html +++ b/public/index.html @@ -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; } diff --git a/public/js/api.js b/public/js/api.js index b82aa9c..d7cccf0 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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; diff --git a/public/js/app.js b/public/js/app.js index 7bd427a..d0ddfc4 100644 --- a/public/js/app.js +++ b/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 ); diff --git a/public/js/crypto.js b/public/js/crypto.js index 9a207e4..eae45aa 100644 --- a/public/js/crypto.js +++ b/public/js/crypto.js @@ -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) { diff --git a/public/js/vendor/qrcode.js b/public/js/vendor/qrcode.js new file mode 100644 index 0000000..847ee20 --- /dev/null +++ b/public/js/vendor/qrcode.js @@ -0,0 +1,1639 @@ +// node_modules/qrcode-generator/dist/qrcode.mjs +var qrcode = function(typeNumber, errorCorrectionLevel) { + const PAD0 = 236; + const PAD1 = 17; + let _typeNumber = typeNumber; + const _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + let _modules = null; + let _moduleCount = 0; + let _dataCache = null; + const _dataList = []; + const _this = {}; + const makeImpl = function(test, maskPattern) { + _moduleCount = _typeNumber * 4 + 17; + _modules = (function(moduleCount) { + const modules = new Array(moduleCount); + for (let row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (let col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + })(_moduleCount); + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + mapData(_dataCache, maskPattern); + }; + const setupPositionProbePattern = function(row, col) { + for (let r = -1; r <= 7; r += 1) { + if (row + r <= -1 || _moduleCount <= row + r) continue; + for (let c = -1; c <= 7; c += 1) { + if (col + c <= -1 || _moduleCount <= col + c) continue; + if (0 <= r && r <= 6 && (c == 0 || c == 6) || 0 <= c && c <= 6 && (r == 0 || r == 6) || 2 <= r && r <= 4 && 2 <= c && c <= 4) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + const getBestMaskPattern = function() { + let minLostPoint = 0; + let pattern = 0; + for (let i = 0; i < 8; i += 1) { + makeImpl(true, i); + const lostPoint = QRUtil.getLostPoint(_this); + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + return pattern; + }; + const setupTimingPattern = function() { + for (let r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = r % 2 == 0; + } + for (let c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = c % 2 == 0; + } + }; + const setupPositionAdjustPattern = function() { + const pos = QRUtil.getPatternPosition(_typeNumber); + for (let i = 0; i < pos.length; i += 1) { + for (let j = 0; j < pos.length; j += 1) { + const row = pos[i]; + const col = pos[j]; + if (_modules[row][col] != null) { + continue; + } + for (let r = -2; r <= 2; r += 1) { + for (let c = -2; c <= 2; c += 1) { + if (r == -2 || r == 2 || c == -2 || c == 2 || r == 0 && c == 0) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + const setupTypeNumber = function(test) { + const bits = QRUtil.getBCHTypeNumber(_typeNumber); + for (let i = 0; i < 18; i += 1) { + const mod = !test && (bits >> i & 1) == 1; + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + for (let i = 0; i < 18; i += 1) { + const mod = !test && (bits >> i & 1) == 1; + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + const setupTypeInfo = function(test, maskPattern) { + const data = _errorCorrectionLevel << 3 | maskPattern; + const bits = QRUtil.getBCHTypeInfo(data); + for (let i = 0; i < 15; i += 1) { + const mod = !test && (bits >> i & 1) == 1; + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + for (let i = 0; i < 15; i += 1) { + const mod = !test && (bits >> i & 1) == 1; + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + _modules[_moduleCount - 8][8] = !test; + }; + const mapData = function(data, maskPattern) { + let inc = -1; + let row = _moduleCount - 1; + let bitIndex = 7; + let byteIndex = 0; + const maskFunc = QRUtil.getMaskFunction(maskPattern); + for (let col = _moduleCount - 1; col > 0; col -= 2) { + if (col == 6) col -= 1; + while (true) { + for (let c = 0; c < 2; c += 1) { + if (_modules[row][col - c] == null) { + let dark = false; + if (byteIndex < data.length) { + dark = (data[byteIndex] >>> bitIndex & 1) == 1; + } + const mask = maskFunc(row, col - c); + if (mask) { + dark = !dark; + } + _modules[row][col - c] = dark; + bitIndex -= 1; + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + row += inc; + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + const createBytes = function(buffer, rsBlocks) { + let offset = 0; + let maxDcCount = 0; + let maxEcCount = 0; + const dcdata = new Array(rsBlocks.length); + const ecdata = new Array(rsBlocks.length); + for (let r = 0; r < rsBlocks.length; r += 1) { + const dcCount = rsBlocks[r].dataCount; + const ecCount = rsBlocks[r].totalCount - dcCount; + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + dcdata[r] = new Array(dcCount); + for (let i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 255 & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + const rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + const rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + const modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (let i = 0; i < ecdata[r].length; i += 1) { + const modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = modIndex >= 0 ? modPoly.getAt(modIndex) : 0; + } + } + let totalCodeCount = 0; + for (let i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + const data = new Array(totalCodeCount); + let index = 0; + for (let i = 0; i < maxDcCount; i += 1) { + for (let r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + for (let i = 0; i < maxEcCount; i += 1) { + for (let r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + return data; + }; + const createData = function(typeNumber2, errorCorrectionLevel2, dataList) { + const rsBlocks = QRRSBlock.getRSBlocks(typeNumber2, errorCorrectionLevel2); + const buffer = qrBitBuffer(); + for (let i = 0; i < dataList.length; i += 1) { + const data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber2)); + data.write(buffer); + } + let totalDataCount = 0; + for (let i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw "code length overflow. (" + buffer.getLengthInBits() + ">" + totalDataCount * 8 + ")"; + } + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + while (true) { + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + return createBytes(buffer, rsBlocks); + }; + _this.addData = function(data, mode) { + mode = mode || "Byte"; + let newData = null; + switch (mode) { + case "Numeric": + newData = qrNumber(data); + break; + case "Alphanumeric": + newData = qrAlphaNum(data); + break; + case "Byte": + newData = qr8BitByte(data); + break; + case "Kanji": + newData = qrKanji(data); + break; + default: + throw "mode:" + mode; + } + _dataList.push(newData); + _dataCache = null; + }; + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + "," + col; + } + return _modules[row][col]; + }; + _this.getModuleCount = function() { + return _moduleCount; + }; + _this.make = function() { + if (_typeNumber < 1) { + let typeNumber2 = 1; + for (; typeNumber2 < 40; typeNumber2++) { + const rsBlocks = QRRSBlock.getRSBlocks(typeNumber2, _errorCorrectionLevel); + const buffer = qrBitBuffer(); + for (let i = 0; i < _dataList.length; i++) { + const data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber2)); + data.write(buffer); + } + let totalDataCount = 0; + for (let i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + _typeNumber = typeNumber2; + } + makeImpl(false, getBestMaskPattern()); + }; + _this.createTableTag = function(cellSize, margin) { + cellSize = cellSize || 2; + margin = typeof margin == "undefined" ? cellSize * 4 : margin; + let qrHtml = ""; + qrHtml += '' + escapeXml(title.text) + "" : ""; + qrSvg += alt.text ? '' + escapeXml(alt.text) + "" : ""; + qrSvg += ''; + qrSvg += '": + escaped += ">"; + break; + case "&": + escaped += "&"; + break; + case '"': + escaped += """; + break; + default: + escaped += c; + break; + } + } + return escaped; + }; + const _createHalfASCII = function(margin) { + const cellSize = 1; + margin = typeof margin == "undefined" ? cellSize * 2 : margin; + const size = _this.getModuleCount() * cellSize + margin * 2; + const min = margin; + const max = size - margin; + let y, x, r1, r2, p; + const blocks = { + "\u2588\u2588": "\u2588", + "\u2588 ": "\u2580", + " \u2588": "\u2584", + " ": " " + }; + const blocksLastLineNoMargin = { + "\u2588\u2588": "\u2580", + "\u2588 ": "\u2580", + " \u2588": " ", + " ": " " + }; + let ascii = ""; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = "\u2588"; + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = " "; + } + if (min <= x && x < max && min <= y + 1 && y + 1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += " "; + } else { + p += "\u2588"; + } + ascii += margin < 1 && y + 1 >= max ? blocksLastLineNoMargin[p] : blocks[p]; + } + ascii += "\n"; + } + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size + 1).join("\u2580"); + } + return ascii.substring(0, ascii.length - 1); + }; + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + if (cellSize < 2) { + return _createHalfASCII(margin); + } + cellSize -= 1; + margin = typeof margin == "undefined" ? cellSize * 2 : margin; + const size = _this.getModuleCount() * cellSize + margin * 2; + const min = margin; + const max = size - margin; + let y, x, r, p; + const white = Array(cellSize + 1).join("\u2588\u2588"); + const black = Array(cellSize + 1).join(" "); + let ascii = ""; + let line = ""; + for (y = 0; y < size; y += 1) { + r = Math.floor((y - min) / cellSize); + line = ""; + for (x = 0; x < size; x += 1) { + p = 1; + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + line += p ? white : black; + } + for (r = 0; r < cellSize; r += 1) { + ascii += line + "\n"; + } + } + return ascii.substring(0, ascii.length - 1); + }; + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + const length = _this.getModuleCount(); + for (let row = 0; row < length; row++) { + for (let col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? "black" : "white"; + context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); + } + } + }; + return _this; +}; +qrcode.stringToBytes = function(s) { + const bytes = []; + for (let i = 0; i < s.length; i += 1) { + const c = s.charCodeAt(i); + bytes.push(c & 255); + } + return bytes; +}; +qrcode.createStringToBytes = function(unicodeData, numChars) { + const unicodeMap = (function() { + const bin = base64DecodeInputStream(unicodeData); + const read = function() { + const b = bin.read(); + if (b == -1) throw "eof"; + return b; + }; + let count = 0; + const unicodeMap2 = {}; + while (true) { + const b0 = bin.read(); + if (b0 == -1) break; + const b1 = read(); + const b2 = read(); + const b3 = read(); + const k = String.fromCharCode(b0 << 8 | b1); + const v = b2 << 8 | b3; + unicodeMap2[k] = v; + count += 1; + } + if (count != numChars) { + throw count + " != " + numChars; + } + return unicodeMap2; + })(); + const unknownChar = "?".charCodeAt(0); + return function(s) { + const bytes = []; + for (let i = 0; i < s.length; i += 1) { + const c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + const b = unicodeMap[s.charAt(i)]; + if (typeof b == "number") { + if ((b & 255) == b) { + bytes.push(b); + } else { + bytes.push(b >>> 8); + bytes.push(b & 255); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; +}; +var QRMode = { + MODE_NUMBER: 1 << 0, + MODE_ALPHA_NUM: 1 << 1, + MODE_8BIT_BYTE: 1 << 2, + MODE_KANJI: 1 << 3 +}; +var QRErrorCorrectionLevel = { + L: 1, + M: 0, + Q: 3, + H: 2 +}; +var QRMaskPattern = { + PATTERN000: 0, + PATTERN001: 1, + PATTERN010: 2, + PATTERN011: 3, + PATTERN100: 4, + PATTERN101: 5, + PATTERN110: 6, + PATTERN111: 7 +}; +var QRUtil = (function() { + const PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + const G15 = 1 << 10 | 1 << 8 | 1 << 5 | 1 << 4 | 1 << 2 | 1 << 1 | 1 << 0; + const G18 = 1 << 12 | 1 << 11 | 1 << 10 | 1 << 9 | 1 << 8 | 1 << 5 | 1 << 2 | 1 << 0; + const G15_MASK = 1 << 14 | 1 << 12 | 1 << 10 | 1 << 4 | 1 << 1; + const _this = {}; + const getBCHDigit = function(data) { + let digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + _this.getBCHTypeInfo = function(data) { + let d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= G15 << getBCHDigit(d) - getBCHDigit(G15); + } + return (data << 10 | d) ^ G15_MASK; + }; + _this.getBCHTypeNumber = function(data) { + let d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= G18 << getBCHDigit(d) - getBCHDigit(G18); + } + return data << 12 | d; + }; + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + _this.getMaskFunction = function(maskPattern) { + switch (maskPattern) { + case QRMaskPattern.PATTERN000: + return function(i, j) { + return (i + j) % 2 == 0; + }; + case QRMaskPattern.PATTERN001: + return function(i, j) { + return i % 2 == 0; + }; + case QRMaskPattern.PATTERN010: + return function(i, j) { + return j % 3 == 0; + }; + case QRMaskPattern.PATTERN011: + return function(i, j) { + return (i + j) % 3 == 0; + }; + case QRMaskPattern.PATTERN100: + return function(i, j) { + return (Math.floor(i / 2) + Math.floor(j / 3)) % 2 == 0; + }; + case QRMaskPattern.PATTERN101: + return function(i, j) { + return i * j % 2 + i * j % 3 == 0; + }; + case QRMaskPattern.PATTERN110: + return function(i, j) { + return (i * j % 2 + i * j % 3) % 2 == 0; + }; + case QRMaskPattern.PATTERN111: + return function(i, j) { + return (i * j % 3 + (i + j) % 2) % 2 == 0; + }; + default: + throw "bad maskPattern:" + maskPattern; + } + }; + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + let a = qrPolynomial([1], 0); + for (let i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0)); + } + return a; + }; + _this.getLengthInBits = function(mode, type) { + if (1 <= type && type < 10) { + switch (mode) { + case QRMode.MODE_NUMBER: + return 10; + case QRMode.MODE_ALPHA_NUM: + return 9; + case QRMode.MODE_8BIT_BYTE: + return 8; + case QRMode.MODE_KANJI: + return 8; + default: + throw "mode:" + mode; + } + } else if (type < 27) { + switch (mode) { + case QRMode.MODE_NUMBER: + return 12; + case QRMode.MODE_ALPHA_NUM: + return 11; + case QRMode.MODE_8BIT_BYTE: + return 16; + case QRMode.MODE_KANJI: + return 10; + default: + throw "mode:" + mode; + } + } else if (type < 41) { + switch (mode) { + case QRMode.MODE_NUMBER: + return 14; + case QRMode.MODE_ALPHA_NUM: + return 13; + case QRMode.MODE_8BIT_BYTE: + return 16; + case QRMode.MODE_KANJI: + return 12; + default: + throw "mode:" + mode; + } + } else { + throw "type:" + type; + } + }; + _this.getLostPoint = function(qrcode2) { + const moduleCount = qrcode2.getModuleCount(); + let lostPoint = 0; + for (let row = 0; row < moduleCount; row += 1) { + for (let col = 0; col < moduleCount; col += 1) { + let sameCount = 0; + const dark = qrcode2.isDark(row, col); + for (let r = -1; r <= 1; r += 1) { + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + for (let c = -1; c <= 1; c += 1) { + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + if (r == 0 && c == 0) { + continue; + } + if (dark == qrcode2.isDark(row + r, col + c)) { + sameCount += 1; + } + } + } + if (sameCount > 5) { + lostPoint += 3 + sameCount - 5; + } + } + } + ; + for (let row = 0; row < moduleCount - 1; row += 1) { + for (let col = 0; col < moduleCount - 1; col += 1) { + let count = 0; + if (qrcode2.isDark(row, col)) count += 1; + if (qrcode2.isDark(row + 1, col)) count += 1; + if (qrcode2.isDark(row, col + 1)) count += 1; + if (qrcode2.isDark(row + 1, col + 1)) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + for (let row = 0; row < moduleCount; row += 1) { + for (let col = 0; col < moduleCount - 6; col += 1) { + if (qrcode2.isDark(row, col) && !qrcode2.isDark(row, col + 1) && qrcode2.isDark(row, col + 2) && qrcode2.isDark(row, col + 3) && qrcode2.isDark(row, col + 4) && !qrcode2.isDark(row, col + 5) && qrcode2.isDark(row, col + 6)) { + lostPoint += 40; + } + } + } + for (let col = 0; col < moduleCount; col += 1) { + for (let row = 0; row < moduleCount - 6; row += 1) { + if (qrcode2.isDark(row, col) && !qrcode2.isDark(row + 1, col) && qrcode2.isDark(row + 2, col) && qrcode2.isDark(row + 3, col) && qrcode2.isDark(row + 4, col) && !qrcode2.isDark(row + 5, col) && qrcode2.isDark(row + 6, col)) { + lostPoint += 40; + } + } + } + let darkCount = 0; + for (let col = 0; col < moduleCount; col += 1) { + for (let row = 0; row < moduleCount; row += 1) { + if (qrcode2.isDark(row, col)) { + darkCount += 1; + } + } + } + const ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + return lostPoint; + }; + return _this; +})(); +var QRMath = (function() { + const EXP_TABLE = new Array(256); + const LOG_TABLE = new Array(256); + for (let i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (let i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] ^ EXP_TABLE[i - 5] ^ EXP_TABLE[i - 6] ^ EXP_TABLE[i - 8]; + } + for (let i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i]] = i; + } + const _this = {}; + _this.glog = function(n) { + if (n < 1) { + throw "glog(" + n + ")"; + } + return LOG_TABLE[n]; + }; + _this.gexp = function(n) { + while (n < 0) { + n += 255; + } + while (n >= 256) { + n -= 255; + } + return EXP_TABLE[n]; + }; + return _this; +})(); +var qrPolynomial = function(num, shift) { + if (typeof num.length == "undefined") { + throw num.length + "/" + shift; + } + const _num = (function() { + let offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + const _num2 = new Array(num.length - offset + shift); + for (let i = 0; i < num.length - offset; i += 1) { + _num2[i] = num[i + offset]; + } + return _num2; + })(); + const _this = {}; + _this.getAt = function(index) { + return _num[index]; + }; + _this.getLength = function() { + return _num.length; + }; + _this.multiply = function(e) { + const num2 = new Array(_this.getLength() + e.getLength() - 1); + for (let i = 0; i < _this.getLength(); i += 1) { + for (let j = 0; j < e.getLength(); j += 1) { + num2[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i)) + QRMath.glog(e.getAt(j))); + } + } + return qrPolynomial(num2, 0); + }; + _this.mod = function(e) { + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + const ratio = QRMath.glog(_this.getAt(0)) - QRMath.glog(e.getAt(0)); + const num2 = new Array(_this.getLength()); + for (let i = 0; i < _this.getLength(); i += 1) { + num2[i] = _this.getAt(i); + } + for (let i = 0; i < e.getLength(); i += 1) { + num2[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i)) + ratio); + } + return qrPolynomial(num2, 0).mod(e); + }; + return _this; +}; +var QRRSBlock = (function() { + const RS_BLOCK_TABLE = [ + // L + // M + // Q + // H + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + const qrRSBlock = function(totalCount, dataCount) { + const _this2 = {}; + _this2.totalCount = totalCount; + _this2.dataCount = dataCount; + return _this2; + }; + const _this = {}; + const getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + switch (errorCorrectionLevel) { + case QRErrorCorrectionLevel.L: + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M: + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q: + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H: + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default: + return void 0; + } + }; + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + const rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + if (typeof rsBlock == "undefined") { + throw "bad rs block @ typeNumber:" + typeNumber + "/errorCorrectionLevel:" + errorCorrectionLevel; + } + const length = rsBlock.length / 3; + const list = []; + for (let i = 0; i < length; i += 1) { + const count = rsBlock[i * 3 + 0]; + const totalCount = rsBlock[i * 3 + 1]; + const dataCount = rsBlock[i * 3 + 2]; + for (let j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount)); + } + } + return list; + }; + return _this; +})(); +var qrBitBuffer = function() { + const _buffer = []; + let _length = 0; + const _this = {}; + _this.getBuffer = function() { + return _buffer; + }; + _this.getAt = function(index) { + const bufIndex = Math.floor(index / 8); + return (_buffer[bufIndex] >>> 7 - index % 8 & 1) == 1; + }; + _this.put = function(num, length) { + for (let i = 0; i < length; i += 1) { + _this.putBit((num >>> length - i - 1 & 1) == 1); + } + }; + _this.getLengthInBits = function() { + return _length; + }; + _this.putBit = function(bit) { + const bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + if (bit) { + _buffer[bufIndex] |= 128 >>> _length % 8; + } + _length += 1; + }; + return _this; +}; +var qrNumber = function(data) { + const _mode = QRMode.MODE_NUMBER; + const _data = data; + const _this = {}; + _this.getMode = function() { + return _mode; + }; + _this.getLength = function(buffer) { + return _data.length; + }; + _this.write = function(buffer) { + const data2 = _data; + let i = 0; + while (i + 2 < data2.length) { + buffer.put(strToNum(data2.substring(i, i + 3)), 10); + i += 3; + } + if (i < data2.length) { + if (data2.length - i == 1) { + buffer.put(strToNum(data2.substring(i, i + 1)), 4); + } else if (data2.length - i == 2) { + buffer.put(strToNum(data2.substring(i, i + 2)), 7); + } + } + }; + const strToNum = function(s) { + let num = 0; + for (let i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i)); + } + return num; + }; + const chatToNum = function(c) { + if ("0" <= c && c <= "9") { + return c.charCodeAt(0) - "0".charCodeAt(0); + } + throw "illegal char :" + c; + }; + return _this; +}; +var qrAlphaNum = function(data) { + const _mode = QRMode.MODE_ALPHA_NUM; + const _data = data; + const _this = {}; + _this.getMode = function() { + return _mode; + }; + _this.getLength = function(buffer) { + return _data.length; + }; + _this.write = function(buffer) { + const s = _data; + let i = 0; + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i)) * 45 + getCode(s.charAt(i + 1)), + 11 + ); + i += 2; + } + if (i < s.length) { + buffer.put(getCode(s.charAt(i)), 6); + } + }; + const getCode = function(c) { + if ("0" <= c && c <= "9") { + return c.charCodeAt(0) - "0".charCodeAt(0); + } else if ("A" <= c && c <= "Z") { + return c.charCodeAt(0) - "A".charCodeAt(0) + 10; + } else { + switch (c) { + case " ": + return 36; + case "$": + return 37; + case "%": + return 38; + case "*": + return 39; + case "+": + return 40; + case "-": + return 41; + case ".": + return 42; + case "/": + return 43; + case ":": + return 44; + default: + throw "illegal char :" + c; + } + } + }; + return _this; +}; +var qr8BitByte = function(data) { + const _mode = QRMode.MODE_8BIT_BYTE; + const _data = data; + const _bytes = qrcode.stringToBytes(data); + const _this = {}; + _this.getMode = function() { + return _mode; + }; + _this.getLength = function(buffer) { + return _bytes.length; + }; + _this.write = function(buffer) { + for (let i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + return _this; +}; +var qrKanji = function(data) { + const _mode = QRMode.MODE_KANJI; + const _data = data; + const stringToBytes2 = qrcode.stringToBytes; + !(function(c, code) { + const test = stringToBytes2(c); + if (test.length != 2 || (test[0] << 8 | test[1]) != code) { + throw "sjis not supported."; + } + })("\u53CB", 38726); + const _bytes = stringToBytes2(data); + const _this = {}; + _this.getMode = function() { + return _mode; + }; + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + _this.write = function(buffer) { + const data2 = _bytes; + let i = 0; + while (i + 1 < data2.length) { + let c = (255 & data2[i]) << 8 | 255 & data2[i + 1]; + if (33088 <= c && c <= 40956) { + c -= 33088; + } else if (57408 <= c && c <= 60351) { + c -= 49472; + } else { + throw "illegal char at " + (i + 1) + "/" + c; + } + c = (c >>> 8 & 255) * 192 + (c & 255); + buffer.put(c, 13); + i += 2; + } + if (i < data2.length) { + throw "illegal char at " + (i + 1); + } + }; + return _this; +}; +var byteArrayOutputStream = function() { + const _bytes = []; + const _this = {}; + _this.writeByte = function(b) { + _bytes.push(b & 255); + }; + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (let i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + _this.writeString = function(s) { + for (let i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i)); + } + }; + _this.toByteArray = function() { + return _bytes; + }; + _this.toString = function() { + let s = ""; + s += "["; + for (let i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ","; + } + s += _bytes[i]; + } + s += "]"; + return s; + }; + return _this; +}; +var base64EncodeOutputStream = function() { + let _buffer = 0; + let _buflen = 0; + let _length = 0; + let _base64 = ""; + const _this = {}; + const writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 63)); + }; + const encode = function(n) { + if (n < 0) { + throw "n:" + n; + } else if (n < 26) { + return 65 + n; + } else if (n < 52) { + return 97 + (n - 26); + } else if (n < 62) { + return 48 + (n - 52); + } else if (n == 62) { + return 43; + } else if (n == 63) { + return 47; + } else { + throw "n:" + n; + } + }; + _this.writeByte = function(n) { + _buffer = _buffer << 8 | n & 255; + _buflen += 8; + _length += 1; + while (_buflen >= 6) { + writeEncoded(_buffer >>> _buflen - 6); + _buflen -= 6; + } + }; + _this.flush = function() { + if (_buflen > 0) { + writeEncoded(_buffer << 6 - _buflen); + _buffer = 0; + _buflen = 0; + } + if (_length % 3 != 0) { + const padlen = 3 - _length % 3; + for (let i = 0; i < padlen; i += 1) { + _base64 += "="; + } + } + }; + _this.toString = function() { + return _base64; + }; + return _this; +}; +var base64DecodeInputStream = function(str) { + const _str = str; + let _pos = 0; + let _buffer = 0; + let _buflen = 0; + const _this = {}; + _this.read = function() { + while (_buflen < 8) { + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw "unexpected end of file./" + _buflen; + } + const c = _str.charAt(_pos); + _pos += 1; + if (c == "=") { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/)) { + continue; + } + _buffer = _buffer << 6 | decode(c.charCodeAt(0)); + _buflen += 6; + } + const n = _buffer >>> _buflen - 8 & 255; + _buflen -= 8; + return n; + }; + const decode = function(c) { + if (65 <= c && c <= 90) { + return c - 65; + } else if (97 <= c && c <= 122) { + return c - 97 + 26; + } else if (48 <= c && c <= 57) { + return c - 48 + 52; + } else if (c == 43) { + return 62; + } else if (c == 47) { + return 63; + } else { + throw "c:" + c; + } + }; + return _this; +}; +var gifImage = function(width, height) { + const _width = width; + const _height = height; + const _data = new Array(width * height); + const _this = {}; + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + _this.write = function(out) { + out.writeString("GIF87a"); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(128); + out.writeByte(0); + out.writeByte(0); + out.writeByte(0); + out.writeByte(0); + out.writeByte(0); + out.writeByte(255); + out.writeByte(255); + out.writeByte(255); + out.writeString(","); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + const lzwMinCodeSize = 2; + const raster = getLZWRaster(lzwMinCodeSize); + out.writeByte(lzwMinCodeSize); + let offset = 0; + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0); + out.writeString(";"); + }; + const bitOutputStream = function(out) { + const _out = out; + let _bitLength = 0; + let _bitBuffer = 0; + const _this2 = {}; + _this2.write = function(data, length) { + if (data >>> length != 0) { + throw "length over"; + } + while (_bitLength + length >= 8) { + _out.writeByte(255 & (data << _bitLength | _bitBuffer)); + length -= 8 - _bitLength; + data >>>= 8 - _bitLength; + _bitBuffer = 0; + _bitLength = 0; + } + _bitBuffer = data << _bitLength | _bitBuffer; + _bitLength = _bitLength + length; + }; + _this2.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + return _this2; + }; + const getLZWRaster = function(lzwMinCodeSize) { + const clearCode = 1 << lzwMinCodeSize; + const endCode = (1 << lzwMinCodeSize) + 1; + let bitLength = lzwMinCodeSize + 1; + const table = lzwTable(); + for (let i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i)); + } + table.add(String.fromCharCode(clearCode)); + table.add(String.fromCharCode(endCode)); + const byteOut = byteArrayOutputStream(); + const bitOut = bitOutputStream(byteOut); + bitOut.write(clearCode, bitLength); + let dataIndex = 0; + let s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + while (dataIndex < _data.length) { + const c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + if (table.contains(s + c)) { + s = s + c; + } else { + bitOut.write(table.indexOf(s), bitLength); + if (table.size() < 4095) { + if (table.size() == 1 << bitLength) { + bitLength += 1; + } + table.add(s + c); + } + s = c; + } + } + bitOut.write(table.indexOf(s), bitLength); + bitOut.write(endCode, bitLength); + bitOut.flush(); + return byteOut.toByteArray(); + }; + const lzwTable = function() { + const _map = {}; + let _size = 0; + const _this2 = {}; + _this2.add = function(key) { + if (_this2.contains(key)) { + throw "dup key:" + key; + } + _map[key] = _size; + _size += 1; + }; + _this2.size = function() { + return _size; + }; + _this2.indexOf = function(key) { + return _map[key]; + }; + _this2.contains = function(key) { + return typeof _map[key] != "undefined"; + }; + return _this2; + }; + return _this; +}; +var createDataURL = function(width, height, getPixel) { + const gif = gifImage(width, height); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y)); + } + } + const b = byteArrayOutputStream(); + gif.write(b); + const base64 = base64EncodeOutputStream(); + const bytes = b.toByteArray(); + for (let i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + return "data:image/gif;base64," + base64; +}; +var qrcode_default = qrcode; +var stringToBytes = qrcode.stringToBytes; + +// _qr-entry.js +var qr_entry_default = qrcode_default; +export { + qr_entry_default as default +}; diff --git a/public/sw.js b/public/sw.js index 2fd1b90..3834c9b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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", ]; diff --git a/src/index.js b/src/index.js index a54ef8f..b141221 100644 --- a/src/index.js +++ b/src/index.js @@ -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. diff --git a/src/routes/backup.js b/src/routes/backup.js new file mode 100644 index 0000000..4f92d45 --- /dev/null +++ b/src/routes/backup.js @@ -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; diff --git a/src/routes/keys.js b/src/routes/keys.js index e7a2b8f..87f225d 100644 --- a/src/routes/keys.js +++ b/src/routes/keys.js @@ -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");