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>
345 lines
12 KiB
JavaScript
345 lines
12 KiB
JavaScript
// High-level E2E crypto built on the vendored Signal Protocol library.
|
|
// This module is the ONLY place message plaintext exists in the app besides the UI.
|
|
|
|
import {
|
|
KeyHelper,
|
|
SignalProtocolAddress,
|
|
SessionBuilder,
|
|
SessionCipher,
|
|
} from "./vendor/libsignal.js";
|
|
import { SignalStore } from "./store.js";
|
|
|
|
export const store = new SignalStore();
|
|
|
|
// ---- encoding helpers ----
|
|
export function abToB64(ab) {
|
|
const bytes = new Uint8Array(ab);
|
|
let bin = "";
|
|
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
return btoa(bin);
|
|
}
|
|
export function b64ToAb(b64) {
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes.buffer;
|
|
}
|
|
// Signal message bodies are binary strings; base64 them for JSON transport.
|
|
function binStrToB64(s) {
|
|
return btoa(s);
|
|
}
|
|
function b64ToBinStr(b64) {
|
|
return atob(b64);
|
|
}
|
|
|
|
const NUM_ONE_TIME_PREKEYS = 100;
|
|
const SIGNED_PREKEY_ID = 1;
|
|
|
|
// Generate a fresh identity + prekeys and persist private halves locally.
|
|
// Returns the PUBLIC registration payload to send to the server.
|
|
export async function generateRegistration() {
|
|
const identityKeyPair = await KeyHelper.generateIdentityKeyPair();
|
|
const registrationId = KeyHelper.generateRegistrationId();
|
|
await store.setIdentity(identityKeyPair, registrationId);
|
|
|
|
const signedPreKey = await KeyHelper.generateSignedPreKey(identityKeyPair, SIGNED_PREKEY_ID);
|
|
await store.storeSignedPreKey(SIGNED_PREKEY_ID, signedPreKey.keyPair);
|
|
|
|
const oneTimePreKeys = [];
|
|
const startId = 1;
|
|
for (let i = 0; i < NUM_ONE_TIME_PREKEYS; i++) {
|
|
const id = startId + i;
|
|
const pk = await KeyHelper.generatePreKey(id);
|
|
await store.storePreKey(id, pk.keyPair);
|
|
oneTimePreKeys.push({ id, pub: abToB64(pk.keyPair.pubKey) });
|
|
}
|
|
|
|
return {
|
|
registrationId,
|
|
identityKey: abToB64(identityKeyPair.pubKey),
|
|
signedPreKey: {
|
|
id: SIGNED_PREKEY_ID,
|
|
pub: abToB64(signedPreKey.keyPair.pubKey),
|
|
sig: abToB64(signedPreKey.signature),
|
|
},
|
|
oneTimePreKeys,
|
|
};
|
|
}
|
|
|
|
// Generate a batch of fresh one-time prekeys to replenish the server's supply.
|
|
export async function generateMoreOneTimePreKeys(count = 100) {
|
|
// Continue numbering past whatever we've used; store a counter in meta-less way:
|
|
// derive from a random high base to avoid collisions (server ignores dupes).
|
|
const base = 1000 + Math.floor(Math.random() * 1_000_000);
|
|
const oneTimePreKeys = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const id = base + i;
|
|
const pk = await KeyHelper.generatePreKey(id);
|
|
await store.storePreKey(id, pk.keyPair);
|
|
oneTimePreKeys.push({ id, pub: abToB64(pk.keyPair.pubKey) });
|
|
}
|
|
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) {
|
|
const address = new SignalProtocolAddress(bundlesResponse.userId, deviceIndex(b.deviceId));
|
|
const existing = await store.loadSession(address.toString());
|
|
if (existing) continue;
|
|
const builder = new SessionBuilder(store, address);
|
|
await builder.processPreKey({
|
|
registrationId: b.registrationId,
|
|
identityKey: b64ToAb(b.identityKey),
|
|
signedPreKey: {
|
|
keyId: b.signedPreKey.id,
|
|
publicKey: b64ToAb(b.signedPreKey.pub),
|
|
signature: b64ToAb(b.signedPreKey.sig),
|
|
},
|
|
preKey: b.oneTimePreKey
|
|
? { keyId: b.oneTimePreKey.id, publicKey: b64ToAb(b.oneTimePreKey.pub) }
|
|
: undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Signal addresses need an integer deviceId. Map our uuid deviceIds to a stable
|
|
// small int by keeping a per-user registry in memory (and the session key string
|
|
// already encodes the full identity, so uniqueness within a user is all we need).
|
|
const deviceIndexMap = new Map();
|
|
let deviceCounter = 1;
|
|
function deviceIndex(deviceId) {
|
|
if (!deviceIndexMap.has(deviceId)) deviceIndexMap.set(deviceId, deviceCounter++);
|
|
return deviceIndexMap.get(deviceId);
|
|
}
|
|
|
|
// Encrypt a plaintext string for every device of a recipient. Returns the array
|
|
// of per-device messages to POST to /api/messages/send.
|
|
export async function encryptForUser(userId, deviceIds, plaintext) {
|
|
const buffer = new TextEncoder().encode(plaintext).buffer;
|
|
const messages = [];
|
|
for (const deviceId of deviceIds) {
|
|
const address = new SignalProtocolAddress(userId, deviceIndex(deviceId));
|
|
const cipher = new SessionCipher(store, address);
|
|
const ct = await cipher.encrypt(buffer);
|
|
messages.push({ toDeviceId: deviceId, type: ct.type, body: binStrToB64(ct.body) });
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
// Decrypt an inbound envelope. Handles both prekey (type 3) and whisper (type 1).
|
|
export async function decryptEnvelope(envelope, senderUserId) {
|
|
const address = new SignalProtocolAddress(senderUserId, deviceIndex(envelope.fromDeviceId));
|
|
const cipher = new SessionCipher(store, address);
|
|
const body = b64ToBinStr(envelope.body);
|
|
let plaintextBuf;
|
|
if (envelope.type === 3) {
|
|
plaintextBuf = await cipher.decryptPreKeyWhisperMessage(body, "binary");
|
|
} else {
|
|
plaintextBuf = await cipher.decryptWhisperMessage(body, "binary");
|
|
}
|
|
return new TextDecoder().decode(new Uint8Array(plaintextBuf));
|
|
}
|
|
|
|
// ---- media encryption (AES-GCM, key travels inside the E2E message) ----
|
|
export async function encryptMedia(fileArrayBuffer) {
|
|
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
|
|
"encrypt",
|
|
"decrypt",
|
|
]);
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, fileArrayBuffer);
|
|
const rawKey = await crypto.subtle.exportKey("raw", key);
|
|
return {
|
|
ciphertext, // ArrayBuffer to upload to R2
|
|
keyB64: abToB64(rawKey),
|
|
ivB64: abToB64(iv.buffer),
|
|
};
|
|
}
|
|
|
|
export async function decryptMedia(ciphertext, keyB64, ivB64) {
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
b64ToAb(keyB64),
|
|
{ name: "AES-GCM" },
|
|
false,
|
|
["decrypt"]
|
|
);
|
|
const iv = new Uint8Array(b64ToAb(ivB64));
|
|
return crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
|
|
}
|