voidlogue-crypto 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vault.js ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * vault.js — Voidlogue Conversation Vault
3
+ *
4
+ * PIN-based local encryption for saved conversations.
5
+ * The PIN never leaves the device. Email + codename are
6
+ * encrypted with AES-256-GCM keyed by PBKDF2(PIN, salt).
7
+ *
8
+ * Labels are ALWAYS encrypted — never stored in plaintext.
9
+ * Labels are encrypted with the PIN-derived key.
10
+ *
11
+ * Storage keys in localStorage:
12
+ * voidlogue_conv_{roomHash} — encrypted blob
13
+ * voidlogue_convlist — plaintext index (hashes + encrypted labels)
14
+ */
15
+
16
+ const ENC = new TextEncoder();
17
+ const DEC = new TextDecoder();
18
+ const PBKDF2_ITER = 100_000;
19
+ const MAX_ATTEMPTS = 5;
20
+ const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
21
+ const LABEL_PBKDF2_ITER = 60_000;
22
+
23
+ function randomB64(bytes) {
24
+ const array = crypto.getRandomValues(new Uint8Array(bytes));
25
+ let binary = '';
26
+ for (let i = 0; i < array.length; i++) {
27
+ binary += String.fromCharCode(array[i]);
28
+ }
29
+ return btoa(binary);
30
+ }
31
+
32
+ async function deriveKey(pin, saltB64) {
33
+ const salt = Uint8Array.from(atob(saltB64), c => c.charCodeAt(0));
34
+ const km = await crypto.subtle.importKey(
35
+ "raw", ENC.encode(String(pin)), "PBKDF2", false, ["deriveKey"]
36
+ );
37
+ return crypto.subtle.deriveKey(
38
+ { name: "PBKDF2", salt, iterations: PBKDF2_ITER, hash: "SHA-256" },
39
+ km,
40
+ { name: "AES-GCM", length: 256 },
41
+ false,
42
+ ["encrypt", "decrypt"]
43
+ );
44
+ }
45
+
46
+ async function deriveLabelKey(passphrase, saltB64) {
47
+ const salt = Uint8Array.from(atob(saltB64), c => c.charCodeAt(0));
48
+ const km = await crypto.subtle.importKey(
49
+ "raw", ENC.encode(passphrase), "PBKDF2", false, ["deriveKey"]
50
+ );
51
+ return crypto.subtle.deriveKey(
52
+ { name: "PBKDF2", salt, iterations: LABEL_PBKDF2_ITER, hash: "SHA-256" },
53
+ km,
54
+ { name: "AES-GCM", length: 256 },
55
+ false,
56
+ ["encrypt", "decrypt"]
57
+ );
58
+ }
59
+
60
+ async function encryptLabel(label, keyOrPassphrase) {
61
+ if (!label) return { encrypted: "", hint: "(no label)" };
62
+ let key, salt;
63
+ if (typeof keyOrPassphrase === "string") {
64
+ salt = randomB64(16);
65
+ key = await deriveLabelKey(keyOrPassphrase, salt);
66
+ } else {
67
+ key = keyOrPassphrase;
68
+ }
69
+ const ivBytes = crypto.getRandomValues(new Uint8Array(12));
70
+ const iv = btoa(String.fromCharCode(...ivBytes));
71
+ const ct = await crypto.subtle.encrypt(
72
+ { name: "AES-GCM", iv: ivBytes },
73
+ key,
74
+ ENC.encode(label)
75
+ );
76
+ const data = btoa(String.fromCharCode(...new Uint8Array(ct)));
77
+ return salt ? { encrypted: data, iv, salt } : { encrypted: data, iv };
78
+ }
79
+
80
+ async function decryptLabel(entry, keyOrPassphrase) {
81
+ if (!entry || !entry.encrypted) return "";
82
+ let key;
83
+ if (typeof keyOrPassphrase === "string") {
84
+ if (!entry.salt) throw new Error("missing_salt");
85
+ key = await deriveLabelKey(keyOrPassphrase, entry.salt);
86
+ } else {
87
+ key = keyOrPassphrase;
88
+ }
89
+ const iv = Uint8Array.from(atob(entry.iv), c => c.charCodeAt(0));
90
+ const ct = Uint8Array.from(atob(entry.encrypted), c => c.charCodeAt(0));
91
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
92
+ return DEC.decode(pt);
93
+ }
94
+
95
+ export const LabelCipher = {
96
+ encrypt: encryptLabel,
97
+ decrypt: decryptLabel,
98
+ deriveKey: deriveLabelKey,
99
+ };
100
+
101
+ export const Vault = {
102
+
103
+ /** Save email + codename encrypted with PIN. hint encrypted with PIN-derived key. */
104
+ async save(roomHash, email, codename, pin, hint = "") {
105
+ const salt = randomB64(16);
106
+ const ivBytes = crypto.getRandomValues(new Uint8Array(12));
107
+ const iv = btoa(String.fromCharCode(...ivBytes));
108
+ const key = await deriveKey(pin, salt);
109
+ const ct = await crypto.subtle.encrypt(
110
+ { name: "AES-GCM", iv: ivBytes },
111
+ key,
112
+ ENC.encode(JSON.stringify({ email, codename }))
113
+ );
114
+ const blob = {
115
+ salt, iv,
116
+ data: btoa(String.fromCharCode(...new Uint8Array(ct))),
117
+ attempts: 0,
118
+ lockedUntil: null,
119
+ };
120
+
121
+ const codenameSalt = randomB64(16);
122
+ const codenameIvBytes = crypto.getRandomValues(new Uint8Array(12));
123
+ const codenameIv = btoa(String.fromCharCode(...codenameIvBytes));
124
+ const codenameKey = await deriveKey(codename, codenameSalt);
125
+ const codenameCt = await crypto.subtle.encrypt(
126
+ { name: "AES-GCM", iv: codenameIvBytes },
127
+ codenameKey,
128
+ ENC.encode(JSON.stringify({ email }))
129
+ );
130
+ blob.codenameSalt = codenameSalt;
131
+ blob.codenameIv = codenameIv;
132
+ blob.codenameData = btoa(String.fromCharCode(...new Uint8Array(codenameCt)));
133
+
134
+
135
+ localStorage.setItem(`voidlogue_conv_${roomHash}`, JSON.stringify(blob));
136
+
137
+ const labelEntry = await encryptLabel(hint, key);
138
+ await this._updateIndex(roomHash, labelEntry);
139
+ return true;
140
+ },
141
+
142
+ /** Verify codename against the codename-encrypted verification blob. Returns {email} or throws. */
143
+ async verifyCodename(roomHash, codename) {
144
+ const raw = localStorage.getItem(`voidlogue_conv_${roomHash}`);
145
+ if (!raw) throw new Error("not_found");
146
+ const blob = JSON.parse(raw);
147
+ if (!blob.codenameSalt || !blob.codenameIv || !blob.codenameData) {
148
+ throw new Error("no_codename_blob");
149
+ }
150
+ const salt = Uint8Array.from(atob(blob.codenameSalt), c => c.charCodeAt(0));
151
+ const iv = Uint8Array.from(atob(blob.codenameIv), c => c.charCodeAt(0));
152
+ const ct = Uint8Array.from(atob(blob.codenameData), c => c.charCodeAt(0));
153
+ const key = await deriveKey(codename, blob.codenameSalt);
154
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
155
+ const { email } = JSON.parse(DEC.decode(pt));
156
+ return { email };
157
+ },
158
+
159
+ /** Decrypt with PIN. Returns {email, codename, hint} or {error, ...}. */
160
+ async load(roomHash, pin) {
161
+ const raw = localStorage.getItem(`voidlogue_conv_${roomHash}`);
162
+ if (!raw) return { error: "not_found" };
163
+ const blob = JSON.parse(raw);
164
+
165
+ if (blob.lockedUntil && Date.now() < blob.lockedUntil) {
166
+ const mins = Math.ceil((blob.lockedUntil - Date.now()) / 60000);
167
+ return { error: "locked", minutesRemaining: mins };
168
+ }
169
+
170
+ try {
171
+ const key = await deriveKey(pin, blob.salt);
172
+ const iv = Uint8Array.from(atob(blob.iv), c => c.charCodeAt(0));
173
+ const ct = Uint8Array.from(atob(blob.data), c => c.charCodeAt(0));
174
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
175
+ const { email, codename } = JSON.parse(DEC.decode(pt));
176
+ blob.attempts = 0; blob.lockedUntil = null;
177
+ localStorage.setItem(`voidlogue_conv_${roomHash}`, JSON.stringify(blob));
178
+
179
+ let hint = "";
180
+ try {
181
+ const entry = this._getEncryptedHint(roomHash);
182
+ hint = entry ? await decryptLabel(entry, key) : "";
183
+ } catch (_) { /* hint decrypt failed, continue without it */ }
184
+
185
+ return { email, codename, hint };
186
+ } catch {
187
+ blob.attempts = (blob.attempts || 0) + 1;
188
+ if (blob.attempts >= MAX_ATTEMPTS) {
189
+ blob.lockedUntil = Date.now() + LOCKOUT_MS;
190
+ blob.attempts = 0;
191
+ localStorage.setItem(`voidlogue_conv_${roomHash}`, JSON.stringify(blob));
192
+ return { error: "locked", minutesRemaining: 15 };
193
+ }
194
+ const attemptsLeft = MAX_ATTEMPTS - blob.attempts;
195
+ localStorage.setItem(`voidlogue_conv_${roomHash}`, JSON.stringify(blob));
196
+ return { error: "wrong_pin", attemptsLeft };
197
+ }
198
+ },
199
+
200
+ /** Re-encrypt with new PIN after user re-enters email + codename. */
201
+ async resetPin(roomHash, email, codename, newPin) {
202
+ const entry = this._getEncryptedHint(roomHash);
203
+ return this.save(roomHash, email, codename, newPin, entry ? "(restored)" : "");
204
+ },
205
+
206
+ /** Re-encrypt label with a new key. */
207
+ async updateLabel(roomHash, newHint, keyOrPassphrase) {
208
+ const entry = await encryptLabel(newHint, keyOrPassphrase);
209
+ await this._updateIndex(roomHash, entry);
210
+ },
211
+
212
+ /** Remove a conversation from vault and index. */
213
+ delete(roomHash) {
214
+ localStorage.removeItem(`voidlogue_conv_${roomHash}`);
215
+ const list = this.list().filter(c => c.roomHash !== roomHash);
216
+ localStorage.setItem("voidlogue_convlist", JSON.stringify(list));
217
+ },
218
+
219
+ /** Returns the plaintext index of saved conversations. */
220
+ list() {
221
+ try {
222
+ return JSON.parse(localStorage.getItem("voidlogue_convlist") || "[]");
223
+ } catch { return []; }
224
+ },
225
+
226
+ /** Returns true if conversation has a PIN-protected vault entry. */
227
+ has(roomHash) {
228
+ const raw = localStorage.getItem(`voidlogue_conv_${roomHash}`);
229
+ if (!raw) return false;
230
+ try {
231
+ const blob = JSON.parse(raw);
232
+ return !!blob.salt && !!blob.data;
233
+ } catch { return false; }
234
+ },
235
+
236
+ /** Returns true if conversation has any vault entry. */
237
+ hasAny(roomHash) {
238
+ if (this.has(roomHash)) return true;
239
+ return this.list().some(c => c.roomHash === roomHash);
240
+ },
241
+
242
+ /** Returns lockout info without attempting a decrypt. */
243
+ lockoutStatus(roomHash) {
244
+ const raw = localStorage.getItem(`voidlogue_conv_${roomHash}`);
245
+ if (!raw) return null;
246
+ const blob = JSON.parse(raw);
247
+ if (blob.lockedUntil && Date.now() < blob.lockedUntil) {
248
+ return { locked: true, minutesRemaining: Math.ceil((blob.lockedUntil - Date.now()) / 60000) };
249
+ }
250
+ return { locked: false, attemptsUsed: blob.attempts || 0 };
251
+ },
252
+
253
+ /** Wipe everything — called by panic clear. */
254
+ wipeAll() {
255
+ const list = this.list();
256
+ list.forEach(c => localStorage.removeItem(`voidlogue_conv_${c.roomHash}`));
257
+ localStorage.removeItem("voidlogue_convlist");
258
+ },
259
+
260
+ _getEncryptedHint(roomHash) {
261
+ return this.list().find(c => c.roomHash === roomHash)?.hint || null;
262
+ },
263
+
264
+ async _updateIndex(roomHash, labelEntry) {
265
+ const list = this.list().filter(c => c.roomHash !== roomHash);
266
+ list.unshift({ roomHash, hint: labelEntry, savedAt: Date.now() });
267
+ localStorage.setItem("voidlogue_convlist", JSON.stringify(list));
268
+ },
269
+
270
+ /** Migrate any plaintext labels to encrypted format. Call once on app start. */
271
+ async migratePlaintextLabels() {
272
+ if (localStorage.getItem("voidlogue_labels_migrated")) return;
273
+ const list = this.list();
274
+ let changed = false;
275
+ for (const entry of list) {
276
+ if (!entry.hint || typeof entry.hint === "string") {
277
+ entry.hint = { encrypted: "", hint: "(no label)" };
278
+ changed = true;
279
+ }
280
+ }
281
+ if (changed) {
282
+ localStorage.setItem("voidlogue_convlist", JSON.stringify(list));
283
+ }
284
+ localStorage.setItem("voidlogue_labels_migrated", "1");
285
+ },
286
+ };
@@ -0,0 +1,353 @@
1
+ /**
2
+ * VoidShield — Voidlogue Client-Side Cryptography
3
+ * =================================================
4
+ * Version: 1.0.0
5
+ * License: MIT
6
+ * Repository: https://github.com/voidlogue/voidlogue-crypto
7
+ *
8
+ * All cryptographic operations run entirely in the browser using the
9
+ * Web Crypto API. Nothing sensitive ever reaches the server as plaintext.
10
+ *
11
+ * Exports:
12
+ * VoidShield — crypto primitives for Conversation and Revelation
13
+ * generateCodename — EFF-wordlist passphrase generator
14
+ *
15
+ * Cryptographic primitives:
16
+ * Hashing: SHA-256 via SubtleCrypto.digest
17
+ * Key derivation: PBKDF2 (SHA-256, 100,000 iterations)
18
+ * Encryption: AES-256-GCM with random 96-bit IV per message
19
+ * Randomness: crypto.getRandomValues (rejection-sampling for uniformity)
20
+ *
21
+ * What this proves (open-source audit surface):
22
+ * - Room hashes are derived client-side. The server receives only an opaque
23
+ * hash and can never reverse it to learn email addresses or codenames.
24
+ * - Message encryption keys are derived from the codename and never sent
25
+ * to the server. The server stores only ciphertext it cannot decrypt.
26
+ * - Revelation keys are derived from the recipient's security fields — values
27
+ * the server never holds. Delivery does not require decryption.
28
+ */
29
+
30
+ import { EFF_WORDLIST } from "./eff_wordlist.js";
31
+
32
+ // ── INTERNAL CONSTANTS ────────────────────────────────────────────────────
33
+ // These salts are intentionally public — security depends on key secrecy,
34
+ // not salt secrecy. Published here for independent verification.
35
+ const APP_SALT = "voidlogue-v1-room-salt-2026";
36
+ const REV_SALT = "voidlogue-revelation-v1";
37
+ const PBKDF2_ITER = 100_000;
38
+ const CHUNK_BYTES = 256 * 1024; // 256 KB per media chunk
39
+ const ENC = new TextEncoder();
40
+ const DEC = new TextDecoder();
41
+
42
+ // ── BASE64 HELPERS ────────────────────────────────────────────────────────
43
+ const b64 = buf =>
44
+ btoa(String.fromCharCode(...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf.buffer)));
45
+ const ub64 = s =>
46
+ Uint8Array.from(atob(s), c => c.charCodeAt(0));
47
+
48
+ // ── VOIDSHIELD ────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * VoidShield — cryptographic primitives for Voidlogue.
52
+ *
53
+ * All methods are pure (no side-effects, no DOM access, no localStorage).
54
+ * Safe to use in any JavaScript environment that provides the Web Crypto API.
55
+ */
56
+ export const VoidShield = {
57
+
58
+ // ── Hashing ──────────────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * SHA-256 of `input`, returned as lowercase hex string.
62
+ * @param {string} input
63
+ * @returns {Promise<string>} 64-character hex string
64
+ */
65
+ async hex(input) {
66
+ const buf = await crypto.subtle.digest("SHA-256", ENC.encode(input));
67
+ return [...new Uint8Array(buf)]
68
+ .map(b => b.toString(16).padStart(2, "0"))
69
+ .join("");
70
+ },
71
+
72
+ // ── Conversation ─────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Derives a room hash from two email addresses and a shared codename.
76
+ *
77
+ * The sort ensures the hash is identical regardless of which party derives
78
+ * it first. The server receives only this opaque hash — never the emails
79
+ * or the codename.
80
+ *
81
+ * Algorithm:
82
+ * hA = SHA-256(emailA.toLowerCase().trim())
83
+ * hB = SHA-256(emailB.toLowerCase().trim())
84
+ * roomHash = SHA-256(sort([hA, hB]).join(":") + ":" + codename + ":" + APP_SALT)
85
+ *
86
+ * @param {string} emailA
87
+ * @param {string} emailB
88
+ * @param {string} codename
89
+ * @returns {Promise<string>} 64-character hex room hash
90
+ */
91
+ async roomId(emailA, emailB, codename) {
92
+ const [hA, hB] = await Promise.all([
93
+ this.hex(emailA.toLowerCase().trim()),
94
+ this.hex(emailB.toLowerCase().trim()),
95
+ ]);
96
+ return this.hex(`${[hA, hB].sort().join(":")}:${codename}:${APP_SALT}`);
97
+ },
98
+
99
+ /**
100
+ * Validates a user-entered codename meets minimum entropy requirements.
101
+ * @param {string} codename
102
+ * @returns {{ valid: boolean, reason?: string }}
103
+ */
104
+ validateCodename(codename) {
105
+ if (!codename || codename.length < 8)
106
+ return { valid: false, reason: "Codename must be at least 8 characters." };
107
+ if (codename.length > 128)
108
+ return { valid: false, reason: "Codename must be 128 characters or less." };
109
+ if (/^\d+$/.test(codename))
110
+ return { valid: false, reason: "Codename cannot be only numbers." };
111
+ if (new Set(codename.toLowerCase()).size < 4)
112
+ return { valid: false, reason: "Codename must have at least 4 unique characters." };
113
+ return { valid: true };
114
+ },
115
+
116
+ /**
117
+ * Derives an AES-256-GCM key from a codename and room hash via PBKDF2.
118
+ *
119
+ * The derived key is non-extractable — the browser will not allow it to
120
+ * be exported or inspected. It can only be used for encrypt/decrypt.
121
+ *
122
+ * @param {string} codename shared secret
123
+ * @param {string} roomHash used as PBKDF2 salt
124
+ * @returns {Promise<CryptoKey>}
125
+ */
126
+ async deriveKey(codename, roomHash) {
127
+ const km = await crypto.subtle.importKey(
128
+ "raw", ENC.encode(codename), "PBKDF2", false, ["deriveKey"]
129
+ );
130
+ return crypto.subtle.deriveKey(
131
+ { name: "PBKDF2", salt: ENC.encode(roomHash), iterations: PBKDF2_ITER, hash: "SHA-256" },
132
+ km,
133
+ { name: "AES-GCM", length: 256 },
134
+ false,
135
+ ["encrypt", "decrypt"]
136
+ );
137
+ },
138
+
139
+ /**
140
+ * AES-256-GCM encrypt plaintext string.
141
+ * A fresh random 96-bit IV is generated for every call.
142
+ *
143
+ * @param {string} plaintext
144
+ * @param {CryptoKey} key
145
+ * @returns {Promise<{ ciphertextB64: string, ivB64: string }>}
146
+ */
147
+ async encrypt(plaintext, key) {
148
+ const iv = crypto.getRandomValues(new Uint8Array(12));
149
+ const ct = await crypto.subtle.encrypt(
150
+ { name: "AES-GCM", iv }, key, ENC.encode(plaintext)
151
+ );
152
+ return { ciphertextB64: b64(ct), ivB64: b64(iv) };
153
+ },
154
+
155
+ /**
156
+ * AES-256-GCM decrypt.
157
+ * Throws DOMException if ciphertext has been tampered with (authentication
158
+ * tag mismatch — inherent to AES-GCM).
159
+ *
160
+ * @param {string} ciphertextB64
161
+ * @param {string} ivB64
162
+ * @param {CryptoKey} key
163
+ * @returns {Promise<string>} plaintext
164
+ */
165
+ async decrypt(ciphertextB64, ivB64, key) {
166
+ const pt = await crypto.subtle.decrypt(
167
+ { name: "AES-GCM", iv: ub64(ivB64) }, key, ub64(ciphertextB64)
168
+ );
169
+ return DEC.decode(pt);
170
+ },
171
+
172
+ // ── Media (chunked encryption) ────────────────────────────────────────────
173
+
174
+ /**
175
+ * Encrypts a File in 256 KB chunks for Revelation media attachments.
176
+ * Each chunk has an independent random IV.
177
+ *
178
+ * @param {File} file
179
+ * @param {CryptoKey} key
180
+ * @returns {Promise<Array<{ ciphertextB64, ivB64, index, totalChunks }>>}
181
+ */
182
+ async encryptMedia(file, key) {
183
+ const buffer = await file.arrayBuffer();
184
+ const total = Math.ceil(buffer.byteLength / CHUNK_BYTES);
185
+ const chunks = [];
186
+ for (let i = 0, offset = 0; offset < buffer.byteLength; i++, offset += CHUNK_BYTES) {
187
+ const iv = crypto.getRandomValues(new Uint8Array(12));
188
+ const ct = await crypto.subtle.encrypt(
189
+ { name: "AES-GCM", iv }, key, buffer.slice(offset, offset + CHUNK_BYTES)
190
+ );
191
+ chunks.push({ ciphertextB64: b64(ct), ivB64: b64(iv), index: i, totalChunks: total });
192
+ }
193
+ return chunks;
194
+ },
195
+
196
+ /**
197
+ * Async generator — decrypts and yields each chunk buffer in index order.
198
+ *
199
+ * @param {Array} chunks
200
+ * @param {CryptoKey} key
201
+ * @yields {ArrayBuffer}
202
+ */
203
+ async *decryptMediaStream(chunks, key) {
204
+ for (const c of [...chunks].sort((a, b) => a.index - b.index)) {
205
+ yield await crypto.subtle.decrypt(
206
+ { name: "AES-GCM", iv: ub64(c.ivB64) }, key, ub64(c.ciphertextB64)
207
+ );
208
+ }
209
+ },
210
+
211
+ // ── Revelation ────────────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Derives a Revelation encryption key from sender email, recipient email,
215
+ * and recipient security field values.
216
+ *
217
+ * The server never holds any of these inputs. Delivery does not require
218
+ * decryption — the server relays encrypted bytes it cannot read.
219
+ *
220
+ * For anonymous revelations, pass "__anon__" as senderEmail.
221
+ *
222
+ * @param {string} senderEmail raw email or "__anon__"
223
+ * @param {string} recipientEmail raw recipient email
224
+ * @param {string[]} fieldValues raw security field values (e.g. ["Alice", "1990-01-01"])
225
+ * @returns {Promise<CryptoKey>}
226
+ */
227
+ async deriveRevelationKey(senderEmail, recipientEmail, fieldValues = []) {
228
+ const [hS, hR] = await Promise.all([
229
+ this.hex(senderEmail.toLowerCase().trim()),
230
+ this.hex(recipientEmail.toLowerCase().trim()),
231
+ ]);
232
+ const fh = await Promise.all(fieldValues.map(v => this.hex(this._norm(v))));
233
+ const input = [[hS, hR].sort().join(":"), ...fh].join(":");
234
+ const km = await crypto.subtle.importKey(
235
+ "raw", ENC.encode(input), "PBKDF2", false, ["deriveKey"]
236
+ );
237
+ return crypto.subtle.deriveKey(
238
+ { name: "PBKDF2", salt: ENC.encode(REV_SALT), iterations: PBKDF2_ITER, hash: "SHA-256" },
239
+ km,
240
+ { name: "AES-GCM", length: 256 },
241
+ false,
242
+ ["encrypt", "decrypt"]
243
+ );
244
+ },
245
+
246
+ /**
247
+ * Revelation key derivation using pre-computed email hashes.
248
+ *
249
+ * Used by the recipient's ReadingView where the server provides the sender's
250
+ * email hash (computed at send time) rather than the raw email. The recipient
251
+ * never types the sender's email.
252
+ *
253
+ * @param {string} senderEmailHash 64-char hex SHA-256 of sender email
254
+ * @param {string} recipientEmailHash 64-char hex SHA-256 of recipient email
255
+ * @param {string[]} fieldValues raw security field values
256
+ * @returns {Promise<CryptoKey>}
257
+ */
258
+ async deriveRevelationKeyFromHashes(senderEmailHash, recipientEmailHash, fieldValues = []) {
259
+ const fh = await Promise.all(fieldValues.map(v => this.hex(this._norm(v))));
260
+ const input = [[senderEmailHash, recipientEmailHash].sort().join(":"), ...fh].join(":");
261
+ const km = await crypto.subtle.importKey(
262
+ "raw", ENC.encode(input), "PBKDF2", false, ["deriveKey"]
263
+ );
264
+ return crypto.subtle.deriveKey(
265
+ { name: "PBKDF2", salt: ENC.encode(REV_SALT), iterations: PBKDF2_ITER, hash: "SHA-256" },
266
+ km,
267
+ { name: "AES-GCM", length: 256 },
268
+ false,
269
+ ["encrypt", "decrypt"]
270
+ );
271
+ },
272
+
273
+ /**
274
+ * Hash a security field value for server-side storage and comparison.
275
+ * Raw values are never sent to the server.
276
+ *
277
+ * @param {string} value raw field value
278
+ * @returns {Promise<string>} 64-char hex hash
279
+ */
280
+ async hashFieldValue(value) {
281
+ return this.hex(this._norm(value));
282
+ },
283
+
284
+ // ── Identity ──────────────────────────────────────────────────────────────
285
+
286
+ /**
287
+ * Derives a room-scoped sender identifier.
288
+ * SHA-256(uuid + ":" + roomHash) — opaque, cannot be used to join rooms.
289
+ *
290
+ * @param {string} uuid user's internal UUID
291
+ * @param {string} roomHash derived room hash
292
+ * @returns {Promise<string>} 64-char hex sender hash
293
+ */
294
+ async senderHash(uuid, roomHash) {
295
+ return this.hex(`${uuid}:${roomHash}`);
296
+ },
297
+
298
+ // ── Randomness ────────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Cryptographically uniform random integer in [0, max).
302
+ *
303
+ * Uses rejection sampling to eliminate modular bias — unlike a simple
304
+ * `getRandomValues() % max` which biases toward lower values when max
305
+ * is not a power of two.
306
+ *
307
+ * @param {number} max exclusive upper bound
308
+ * @returns {number}
309
+ */
310
+ secureRandom(max) {
311
+ const buf = new Uint32Array(1);
312
+ const lim = Math.floor(0x100000000 / max) * max;
313
+ do { crypto.getRandomValues(buf); } while (buf[0] >= lim);
314
+ return buf[0] % max;
315
+ },
316
+
317
+ // ── Internal ─────────────────────────────────────────────────────────────
318
+
319
+ /**
320
+ * Normalise a field value for consistent hashing.
321
+ * Lowercases, strips whitespace, hyphens, underscores, and dots.
322
+ * @internal
323
+ */
324
+ _norm(v) {
325
+ return String(v).toLowerCase().replace(/[\s\-_.]/g, "").trim();
326
+ },
327
+ };
328
+
329
+ // ── CODENAME GENERATOR ────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * Generates a random passphrase from the EFF long wordlist.
333
+ *
334
+ * Default: 3 words (≈38 bits of entropy from 7776-word pool).
335
+ * Increase wordCount for higher-security codenames:
336
+ * 4 words ≈ 51 bits
337
+ * 5 words ≈ 64 bits
338
+ * 6 words ≈ 77 bits
339
+ *
340
+ * The EFF wordlist is intentionally public. Security comes from the
341
+ * randomness of selection, not the secrecy of the word list.
342
+ *
343
+ * @param {number} wordCount number of words (2–8)
344
+ * @returns {string} hyphen-separated passphrase
345
+ */
346
+ export function generateCodename(wordCount = 3) {
347
+ const count = Math.max(2, Math.min(8, wordCount));
348
+ const words = [];
349
+ for (let i = 0; i < count; i++) {
350
+ words.push(EFF_WORDLIST[VoidShield.secureRandom(EFF_WORDLIST.length)]);
351
+ }
352
+ return words.join("-");
353
+ }