voidlogue-crypto 1.0.12 → 2.0.2

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/voidshield.js DELETED
@@ -1,424 +0,0 @@
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, 600,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 = 600_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(
45
- String.fromCharCode(
46
- ...new Uint8Array(buf instanceof ArrayBuffer ? buf : buf.buffer)
47
- )
48
- );
49
- const ub64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
50
-
51
- // ── VOIDSHIELD ────────────────────────────────────────────────────────────
52
-
53
- /**
54
- * VoidShield — cryptographic primitives for Voidlogue.
55
- *
56
- * All methods are pure (no side-effects, no DOM access, no localStorage).
57
- * Safe to use in any JavaScript environment that provides the Web Crypto API.
58
- */
59
- export const VoidShield = {
60
- // ── Hashing ──────────────────────────────────────────────────────────────
61
-
62
- /**
63
- * SHA-256 of `input`, returned as lowercase hex string.
64
- * @param {string} input
65
- * @returns {Promise<string>} 64-character hex string
66
- */
67
- async hex(input) {
68
- const buf = await crypto.subtle.digest('SHA-256', ENC.encode(input));
69
- return [...new Uint8Array(buf)]
70
- .map((b) => b.toString(16).padStart(2, '0'))
71
- .join('');
72
- },
73
-
74
- // ── Conversation ─────────────────────────────────────────────────────────
75
-
76
- /**
77
- * Derives a room hash from two email addresses and a shared codename.
78
- *
79
- * The sort ensures the hash is identical regardless of which party derives
80
- * it first. The server receives only this opaque hash — never the emails
81
- * or the codename.
82
- *
83
- * Algorithm:
84
- * hA = SHA-256(emailA.toLowerCase().trim())
85
- * hB = SHA-256(emailB.toLowerCase().trim())
86
- * roomHash = SHA-256(sort([hA, hB]).join(":") + ":" + codename + ":" + APP_SALT)
87
- *
88
- * @param {string} emailA
89
- * @param {string} emailB
90
- * @param {string} codename
91
- * @returns {Promise<string>} 64-character hex room hash
92
- */
93
- async roomId(emailA, emailB, codename) {
94
- const [hA, hB] = await Promise.all([
95
- this.hex(emailA.toLowerCase().trim()),
96
- this.hex(emailB.toLowerCase().trim()),
97
- ]);
98
- return this.hex(`${[hA, hB].sort().join(':')}:${codename}:${APP_SALT}`);
99
- },
100
-
101
- /**
102
- * Validates a user-entered codename meets minimum entropy requirements.
103
- * @param {string} codename
104
- * @returns {{ valid: boolean, reason?: string }}
105
- */
106
- validateCodename(codename) {
107
- if (!codename || codename.length < 8)
108
- return {
109
- valid: false,
110
- reason: 'Codename must be at least 8 characters.',
111
- };
112
- if (codename.length > 128)
113
- return {
114
- valid: false,
115
- reason: 'Codename must be 128 characters or less.',
116
- };
117
- if (/^\d+$/.test(codename))
118
- return { valid: false, reason: 'Codename cannot be only numbers.' };
119
- if (new Set(codename.toLowerCase()).size < 4)
120
- return {
121
- valid: false,
122
- reason: 'Codename must have at least 4 unique characters.',
123
- };
124
- return { valid: true };
125
- },
126
-
127
- /**
128
- * Derives an AES-256-GCM key from a codename and room hash via PBKDF2.
129
- *
130
- * The derived key is non-extractable — the browser will not allow it to
131
- * be exported or inspected. It can only be used for encrypt/decrypt.
132
- *
133
- * @param {string} codename shared secret
134
- * @param {string} roomHash used as PBKDF2 salt
135
- * @returns {Promise<CryptoKey>}
136
- */
137
- async deriveKey(codename, roomHash) {
138
- const km = await crypto.subtle.importKey(
139
- 'raw',
140
- ENC.encode(codename),
141
- 'PBKDF2',
142
- false,
143
- ['deriveKey']
144
- );
145
- return crypto.subtle.deriveKey(
146
- {
147
- name: 'PBKDF2',
148
- salt: ENC.encode(roomHash),
149
- iterations: PBKDF2_ITER,
150
- hash: 'SHA-256',
151
- },
152
- km,
153
- { name: 'AES-GCM', length: 256 },
154
- false,
155
- ['encrypt', 'decrypt']
156
- );
157
- },
158
-
159
- /**
160
- * AES-256-GCM encrypt plaintext string.
161
- * A fresh random 96-bit IV is generated for every call.
162
- *
163
- * @param {string} plaintext
164
- * @param {CryptoKey} key
165
- * @returns {Promise<{ ciphertextB64: string, ivB64: string }>}
166
- */
167
- async encrypt(plaintext, key) {
168
- const iv = crypto.getRandomValues(new Uint8Array(12));
169
- const ct = await crypto.subtle.encrypt(
170
- { name: 'AES-GCM', iv },
171
- key,
172
- ENC.encode(plaintext)
173
- );
174
- return { ciphertextB64: b64(ct), ivB64: b64(iv) };
175
- },
176
-
177
- /**
178
- * AES-256-GCM decrypt.
179
- * Throws DOMException if ciphertext has been tampered with (authentication
180
- * tag mismatch — inherent to AES-GCM).
181
- *
182
- * @param {string} ciphertextB64
183
- * @param {string} ivB64
184
- * @param {CryptoKey} key
185
- * @returns {Promise<string>} plaintext
186
- */
187
- async decrypt(ciphertextB64, ivB64, key) {
188
- const pt = await crypto.subtle.decrypt(
189
- { name: 'AES-GCM', iv: ub64(ivB64) },
190
- key,
191
- ub64(ciphertextB64)
192
- );
193
- return DEC.decode(pt);
194
- },
195
-
196
- // ── Media (chunked encryption) ────────────────────────────────────────────
197
-
198
- /**
199
- * Encrypts a File in 256 KB chunks for Revelation media attachments.
200
- * Each chunk has an independent random IV.
201
- *
202
- * @param {File} file
203
- * @param {CryptoKey} key
204
- * @returns {Promise<Array<{ ciphertextB64, ivB64, index, totalChunks }>>}
205
- */
206
- async encryptMedia(file, key) {
207
- const buffer = await file.arrayBuffer();
208
- const total = Math.ceil(buffer.byteLength / CHUNK_BYTES);
209
- const chunks = [];
210
- for (
211
- let i = 0, offset = 0;
212
- offset < buffer.byteLength;
213
- i++, offset += CHUNK_BYTES
214
- ) {
215
- const iv = crypto.getRandomValues(new Uint8Array(12));
216
- const ct = await crypto.subtle.encrypt(
217
- { name: 'AES-GCM', iv },
218
- key,
219
- buffer.slice(offset, offset + CHUNK_BYTES)
220
- );
221
- chunks.push({
222
- ciphertextB64: b64(ct),
223
- ivB64: b64(iv),
224
- index: i,
225
- totalChunks: total,
226
- });
227
- }
228
- return chunks;
229
- },
230
-
231
- /**
232
- * Async generator — decrypts and yields each chunk buffer in index order.
233
- *
234
- * @param {Array} chunks
235
- * @param {CryptoKey} key
236
- * @yields {ArrayBuffer}
237
- */
238
- async *decryptMediaStream(chunks, key) {
239
- for (const c of [...chunks].sort((a, b) => a.index - b.index)) {
240
- yield await crypto.subtle.decrypt(
241
- { name: 'AES-GCM', iv: ub64(c.ivB64) },
242
- key,
243
- ub64(c.ciphertextB64)
244
- );
245
- }
246
- },
247
-
248
- // ── Revelation ────────────────────────────────────────────────────────────
249
-
250
- /**
251
- * Derives a Revelation encryption key from sender email, recipient email,
252
- * and recipient security field values.
253
- *
254
- * The server never holds any of these inputs. Delivery does not require
255
- * decryption — the server relays encrypted bytes it cannot read.
256
- *
257
- * For anonymous revelations, pass "__anon__" as senderEmail.
258
- *
259
- * @param {string} senderEmail raw email or "__anon__"
260
- * @param {string} recipientEmail raw recipient email
261
- * @param {string[]} fieldValues raw security field values (e.g. ["Alice", "1990-01-01"])
262
- * @returns {Promise<CryptoKey>}
263
- */
264
- async deriveRevelationKey(senderEmail, recipientEmail, fieldValues = []) {
265
- const [hS, hR] = await Promise.all([
266
- this.hex(senderEmail.toLowerCase().trim()),
267
- this.hex(recipientEmail.toLowerCase().trim()),
268
- ]);
269
- const fh = await Promise.all(
270
- fieldValues.map((v) => this.hex(this._norm(v)))
271
- );
272
- const input = [[hS, hR].sort().join(':'), ...fh].join(':');
273
- const km = await crypto.subtle.importKey(
274
- 'raw',
275
- ENC.encode(input),
276
- 'PBKDF2',
277
- false,
278
- ['deriveKey']
279
- );
280
- return crypto.subtle.deriveKey(
281
- {
282
- name: 'PBKDF2',
283
- salt: ENC.encode(REV_SALT),
284
- iterations: PBKDF2_ITER,
285
- hash: 'SHA-256',
286
- },
287
- km,
288
- { name: 'AES-GCM', length: 256 },
289
- false,
290
- ['encrypt', 'decrypt']
291
- );
292
- },
293
-
294
- /**
295
- * Revelation key derivation using pre-computed email hashes.
296
- *
297
- * Used by the recipient's ReadingView where the server provides the sender's
298
- * email hash (computed at send time) rather than the raw email. The recipient
299
- * never types the sender's email.
300
- *
301
- * @param {string} senderEmailHash 64-char hex SHA-256 of sender email
302
- * @param {string} recipientEmailHash 64-char hex SHA-256 of recipient email
303
- * @param {string[]} fieldValues raw security field values
304
- * @returns {Promise<CryptoKey>}
305
- */
306
- async deriveRevelationKeyFromHashes(
307
- senderEmailHash,
308
- recipientEmailHash,
309
- fieldValues = []
310
- ) {
311
- const fh = await Promise.all(
312
- fieldValues.map((v) => this.hex(this._norm(v)))
313
- );
314
- const input = [
315
- [senderEmailHash, recipientEmailHash].sort().join(':'),
316
- ...fh,
317
- ].join(':');
318
- const km = await crypto.subtle.importKey(
319
- 'raw',
320
- ENC.encode(input),
321
- 'PBKDF2',
322
- false,
323
- ['deriveKey']
324
- );
325
- return crypto.subtle.deriveKey(
326
- {
327
- name: 'PBKDF2',
328
- salt: ENC.encode(REV_SALT),
329
- iterations: PBKDF2_ITER,
330
- hash: 'SHA-256',
331
- },
332
- km,
333
- { name: 'AES-GCM', length: 256 },
334
- false,
335
- ['encrypt', 'decrypt']
336
- );
337
- },
338
-
339
- /**
340
- * Hash a security field value for server-side storage and comparison.
341
- * Raw values are never sent to the server.
342
- *
343
- * @param {string} value raw field value
344
- * @returns {Promise<string>} 64-char hex hash
345
- */
346
- async hashFieldValue(value) {
347
- return this.hex(this._norm(value));
348
- },
349
-
350
- // ── Identity ──────────────────────────────────────────────────────────────
351
-
352
- /**
353
- * Derives a room-scoped sender identifier.
354
- * SHA-256(uuid + ":" + roomHash) — opaque, cannot be used to join rooms.
355
- *
356
- * @param {string} uuid user's internal UUID
357
- * @param {string} roomHash derived room hash
358
- * @returns {Promise<string>} 64-char hex sender hash
359
- */
360
- async senderHash(uuid, roomHash) {
361
- return this.hex(`${uuid}:${roomHash}`);
362
- },
363
-
364
- // ── Randomness ────────────────────────────────────────────────────────────
365
-
366
- /**
367
- * Cryptographically uniform random integer in [0, max).
368
- *
369
- * Uses rejection sampling to eliminate modular bias — unlike a simple
370
- * `getRandomValues() % max` which biases toward lower values when max
371
- * is not a power of two.
372
- *
373
- * @param {number} max exclusive upper bound
374
- * @returns {number}
375
- */
376
- secureRandom(max) {
377
- const buf = new Uint32Array(1);
378
- const lim = Math.floor(0x100000000 / max) * max;
379
- do {
380
- crypto.getRandomValues(buf);
381
- } while (buf[0] >= lim);
382
- return buf[0] % max;
383
- },
384
-
385
- // ── Internal ─────────────────────────────────────────────────────────────
386
-
387
- /**
388
- * Normalise a field value for consistent hashing.
389
- * Lowercases, strips whitespace, hyphens, underscores, and dots.
390
- * @internal
391
- */
392
- _norm(v) {
393
- return String(v)
394
- .toLowerCase()
395
- .replace(/[\s\-_.]/g, '')
396
- .trim();
397
- },
398
- };
399
-
400
- // ── CODENAME GENERATOR ────────────────────────────────────────────────────
401
-
402
- /**
403
- * Generates a random passphrase from the EFF long wordlist.
404
- *
405
- * Default: 3 words (≈38 bits of entropy from 7776-word pool).
406
- * Increase wordCount for higher-security codenames:
407
- * 4 words ≈ 51 bits
408
- * 5 words ≈ 64 bits
409
- * 6 words ≈ 77 bits
410
- *
411
- * The EFF wordlist is intentionally public. Security comes from the
412
- * randomness of selection, not the secrecy of the word list.
413
- *
414
- * @param {number} wordCount number of words (2–8)
415
- * @returns {string} hyphen-separated passphrase
416
- */
417
- export function generateCodename(wordCount = 3) {
418
- const count = Math.max(2, Math.min(8, wordCount));
419
- const words = [];
420
- for (let i = 0; i < count; i++) {
421
- words.push(EFF_WORDLIST[VoidShield.secureRandom(EFF_WORDLIST.length)]);
422
- }
423
- return words.join('-');
424
- }
File without changes
File without changes