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/LICENSE +57 -0
- package/README.md +215 -0
- package/SECURITY.md +154 -0
- package/index.js +13 -0
- package/package.json +52 -0
- package/src/eff_wordlist.js +7778 -0
- package/src/vault.js +286 -0
- package/src/voidshield.js +353 -0
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
|
+
}
|