voidlogue-crypto 1.0.11 → 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/vault.ts ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * vault.ts — 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 = 2_000_000;
19
+ const MAX_ATTEMPTS = 5;
20
+ const LOCKOUT_MS = 15 * 60 * 1000;
21
+
22
+ type LabelEntry = {
23
+ encrypted: string;
24
+ iv: string;
25
+ salt?: string;
26
+ hint?: string;
27
+ };
28
+ type ConvListEntry = { roomHash: string; hint: LabelEntry; savedAt: number };
29
+ type VaultBlob = {
30
+ salt: string;
31
+ iv: string;
32
+ data: string;
33
+ attempts: number;
34
+ lockedUntil: number | null;
35
+ codenameSalt?: string;
36
+ codenameIv?: string;
37
+ codenameData?: string;
38
+ };
39
+
40
+ function randomB64(bytes: number): string {
41
+ const array = crypto.getRandomValues(new Uint8Array(bytes));
42
+ let binary = '';
43
+ for (let i = 0; i < array.length; i++) {
44
+ binary += String.fromCharCode(array[i]!);
45
+ }
46
+ return btoa(binary);
47
+ }
48
+
49
+ async function deriveKey(pin: string, saltB64: string): Promise<CryptoKey> {
50
+ const salt = Uint8Array.from(atob(saltB64), (c) => c.charCodeAt(0));
51
+ const km = await crypto.subtle.importKey(
52
+ 'raw',
53
+ ENC.encode(pin),
54
+ 'PBKDF2',
55
+ false,
56
+ ['deriveKey']
57
+ );
58
+ return crypto.subtle.deriveKey(
59
+ { name: 'PBKDF2', salt, iterations: PBKDF2_ITER, hash: 'SHA-256' },
60
+ km,
61
+ { name: 'AES-GCM', length: 256 },
62
+ false,
63
+ ['encrypt', 'decrypt']
64
+ );
65
+ }
66
+
67
+ async function deriveLabelKey(
68
+ passphrase: string,
69
+ saltB64: string
70
+ ): Promise<CryptoKey> {
71
+ const salt = Uint8Array.from(atob(saltB64), (c) => c.charCodeAt(0));
72
+ const km = await crypto.subtle.importKey(
73
+ 'raw',
74
+ ENC.encode(passphrase),
75
+ 'PBKDF2',
76
+ false,
77
+ ['deriveKey']
78
+ );
79
+ return crypto.subtle.deriveKey(
80
+ { name: 'PBKDF2', salt, iterations: PBKDF2_ITER, hash: 'SHA-256' },
81
+ km,
82
+ { name: 'AES-GCM', length: 256 },
83
+ false,
84
+ ['encrypt', 'decrypt']
85
+ );
86
+ }
87
+
88
+ async function encryptLabel(
89
+ label: string,
90
+ keyOrPassphrase: CryptoKey | string
91
+ ): Promise<LabelEntry> {
92
+ if (!label) return { encrypted: '', iv: '', hint: '(no label)' };
93
+ let key: CryptoKey;
94
+ let salt: string | undefined;
95
+ if (typeof keyOrPassphrase === 'string') {
96
+ salt = randomB64(16);
97
+ key = await deriveLabelKey(keyOrPassphrase, salt);
98
+ } else {
99
+ key = keyOrPassphrase;
100
+ }
101
+ const ivBytes = crypto.getRandomValues(new Uint8Array(12));
102
+ const iv = btoa(String.fromCharCode(...ivBytes));
103
+ const ct = await crypto.subtle.encrypt(
104
+ { name: 'AES-GCM', iv: ivBytes },
105
+ key,
106
+ ENC.encode(label)
107
+ );
108
+ const data = btoa(String.fromCharCode(...new Uint8Array(ct)));
109
+ return salt ? { encrypted: data, iv, salt } : { encrypted: data, iv };
110
+ }
111
+
112
+ async function decryptLabel(
113
+ entry: LabelEntry,
114
+ keyOrPassphrase: CryptoKey | string
115
+ ): Promise<string> {
116
+ if (!entry || !entry.encrypted) return '';
117
+ let key: CryptoKey;
118
+ if (typeof keyOrPassphrase === 'string') {
119
+ if (!entry.salt) throw new Error('missing_salt');
120
+ key = await deriveLabelKey(keyOrPassphrase, entry.salt);
121
+ } else {
122
+ key = keyOrPassphrase;
123
+ }
124
+ const iv = Uint8Array.from(atob(entry.iv), (c) => c.charCodeAt(0));
125
+ const ct = Uint8Array.from(atob(entry.encrypted), (c) => c.charCodeAt(0));
126
+ const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
127
+ return DEC.decode(pt);
128
+ }
129
+
130
+ export const LabelCipher = {
131
+ encrypt: encryptLabel,
132
+ decrypt: decryptLabel,
133
+ deriveKey: deriveLabelKey,
134
+ };
135
+
136
+ export const Vault = {
137
+ async save(
138
+ roomHash: string,
139
+ email: string,
140
+ codename: string,
141
+ pin: string,
142
+ hint: string = ''
143
+ ): Promise<boolean> {
144
+ const salt = randomB64(16);
145
+ const ivBytes = crypto.getRandomValues(new Uint8Array(12));
146
+ const iv = btoa(String.fromCharCode(...ivBytes));
147
+ const key = await deriveKey(pin, salt);
148
+ const ct = await crypto.subtle.encrypt(
149
+ { name: 'AES-GCM', iv: ivBytes },
150
+ key,
151
+ ENC.encode(JSON.stringify({ email, codename }))
152
+ );
153
+ const blob: VaultBlob = {
154
+ salt,
155
+ iv,
156
+ data: btoa(String.fromCharCode(...new Uint8Array(ct))),
157
+ attempts: 0,
158
+ lockedUntil: null,
159
+ };
160
+
161
+ const codenameSalt = randomB64(16);
162
+ const codenameIvBytes = crypto.getRandomValues(new Uint8Array(12));
163
+ const codenameIv = btoa(String.fromCharCode(...codenameIvBytes));
164
+ const codenameKey = await deriveKey(codename, codenameSalt);
165
+ const codenameCt = await crypto.subtle.encrypt(
166
+ { name: 'AES-GCM', iv: codenameIvBytes },
167
+ codenameKey,
168
+ ENC.encode(JSON.stringify({ email }))
169
+ );
170
+ blob.codenameSalt = codenameSalt;
171
+ blob.codenameIv = codenameIv;
172
+ blob.codenameData = btoa(
173
+ String.fromCharCode(...new Uint8Array(codenameCt))
174
+ );
175
+
176
+ localStorage.setItem(`voidlogue_conv_${roomHash}`, JSON.stringify(blob));
177
+
178
+ const labelEntry = await encryptLabel(hint, key);
179
+ await this._updateIndex(roomHash, labelEntry);
180
+ return true;
181
+ },
182
+
183
+ async verifyCodename(
184
+ roomHash: string,
185
+ codename: string
186
+ ): Promise<{ email: string }> {
187
+ const raw = localStorage.getItem(`voidlogue_conv_${roomHash}`);
188
+ if (!raw) throw new Error('not_found');
189
+ const blob = JSON.parse(raw) as VaultBlob;
190
+ if (!blob.codenameSalt || !blob.codenameIv || !blob.codenameData) {
191
+ throw new Error('no_codename_blob');
192
+ }
193
+ const iv = Uint8Array.from(atob(blob.codenameIv), (c) => c.charCodeAt(0));
194
+ const ct = Uint8Array.from(atob(blob.codenameData), (c) => c.charCodeAt(0));
195
+ const key = await deriveKey(codename, blob.codenameSalt);
196
+ const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
197
+ const { email } = JSON.parse(DEC.decode(pt));
198
+ return { email };
199
+ },
200
+
201
+ async load(
202
+ roomHash: string,
203
+ pin: string
204
+ ): Promise<
205
+ | { email: string; codename: string; hint: string }
206
+ | { error: string; minutesRemaining?: number; attemptsLeft?: number }
207
+ > {
208
+ const raw = localStorage.getItem(`voidlogue_conv_${roomHash}`);
209
+ if (!raw) return { error: 'not_found' };
210
+ const blob = JSON.parse(raw) as VaultBlob;
211
+
212
+ if (blob.lockedUntil && Date.now() < blob.lockedUntil) {
213
+ const mins = Math.ceil((blob.lockedUntil - Date.now()) / 60000);
214
+ return { error: 'locked', minutesRemaining: mins };
215
+ }
216
+
217
+ try {
218
+ const key = await deriveKey(pin, blob.salt);
219
+ const iv = Uint8Array.from(atob(blob.iv), (c) => c.charCodeAt(0));
220
+ const ct = Uint8Array.from(atob(blob.data), (c) => c.charCodeAt(0));
221
+ const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
222
+ const { email, codename } = JSON.parse(DEC.decode(pt));
223
+ blob.attempts = 0;
224
+ blob.lockedUntil = null;
225
+ localStorage.setItem(`voidlogue_conv_${roomHash}`, JSON.stringify(blob));
226
+
227
+ let hint = '';
228
+ try {
229
+ const entry = this._getEncryptedHint(roomHash);
230
+ hint = entry ? await decryptLabel(entry, key) : '';
231
+ } catch (_) {
232
+ /* hint decrypt failed, continue without it */
233
+ }
234
+
235
+ return { email, codename, hint };
236
+ } catch {
237
+ blob.attempts = (blob.attempts || 0) + 1;
238
+ if (blob.attempts >= MAX_ATTEMPTS) {
239
+ blob.lockedUntil = Date.now() + LOCKOUT_MS;
240
+ blob.attempts = 0;
241
+ localStorage.setItem(
242
+ `voidlogue_conv_${roomHash}`,
243
+ JSON.stringify(blob)
244
+ );
245
+ return { error: 'locked', minutesRemaining: 15 };
246
+ }
247
+ const attemptsLeft = MAX_ATTEMPTS - blob.attempts;
248
+ localStorage.setItem(`voidlogue_conv_${roomHash}`, JSON.stringify(blob));
249
+ return { error: 'wrong_pin', attemptsLeft };
250
+ }
251
+ },
252
+
253
+ async resetPin(
254
+ roomHash: string,
255
+ email: string,
256
+ codename: string,
257
+ newPin: string
258
+ ): Promise<boolean> {
259
+ const entry = this._getEncryptedHint(roomHash);
260
+ return this.save(
261
+ roomHash,
262
+ email,
263
+ codename,
264
+ newPin,
265
+ entry ? '(restored)' : ''
266
+ );
267
+ },
268
+
269
+ async updateLabel(
270
+ roomHash: string,
271
+ newHint: string,
272
+ keyOrPassphrase: CryptoKey | string
273
+ ): Promise<void> {
274
+ const entry = await encryptLabel(newHint, keyOrPassphrase);
275
+ await this._updateIndex(roomHash, entry);
276
+ },
277
+
278
+ delete(roomHash: string): void {
279
+ localStorage.removeItem(`voidlogue_conv_${roomHash}`);
280
+ const list = this.list().filter((c) => c.roomHash !== roomHash);
281
+ localStorage.setItem('voidlogue_convlist', JSON.stringify(list));
282
+ },
283
+
284
+ list(): ConvListEntry[] {
285
+ try {
286
+ return JSON.parse(localStorage.getItem('voidlogue_convlist') || '[]');
287
+ } catch {
288
+ return [];
289
+ }
290
+ },
291
+
292
+ has(roomHash: string): boolean {
293
+ const raw = localStorage.getItem(`voidlogue_conv_${roomHash}`);
294
+ if (!raw) return false;
295
+ try {
296
+ const blob = JSON.parse(raw) as VaultBlob;
297
+ return !!blob.salt && !!blob.data;
298
+ } catch {
299
+ return false;
300
+ }
301
+ },
302
+
303
+ hasAny(roomHash: string): boolean {
304
+ if (this.has(roomHash)) return true;
305
+ return this.list().some((c) => c.roomHash === roomHash);
306
+ },
307
+
308
+ lockoutStatus(roomHash: string): {
309
+ locked: boolean;
310
+ minutesRemaining?: number;
311
+ attemptsUsed?: number;
312
+ } | null {
313
+ const raw = localStorage.getItem(`voidlogue_conv_${roomHash}`);
314
+ if (!raw) return null;
315
+ const blob = JSON.parse(raw) as VaultBlob;
316
+ if (blob.lockedUntil && Date.now() < blob.lockedUntil) {
317
+ return {
318
+ locked: true,
319
+ minutesRemaining: Math.ceil((blob.lockedUntil - Date.now()) / 60000),
320
+ };
321
+ }
322
+ return { locked: false, attemptsUsed: blob.attempts || 0 };
323
+ },
324
+
325
+ wipeAll(): void {
326
+ const list = this.list();
327
+ list.forEach((c) =>
328
+ localStorage.removeItem(`voidlogue_conv_${c.roomHash}`)
329
+ );
330
+ localStorage.removeItem('voidlogue_convlist');
331
+ },
332
+
333
+ _getEncryptedHint(roomHash: string): LabelEntry | null {
334
+ return this.list().find((c) => c.roomHash === roomHash)?.hint || null;
335
+ },
336
+
337
+ async _updateIndex(roomHash: string, labelEntry: LabelEntry): Promise<void> {
338
+ const list = this.list().filter((c) => c.roomHash !== roomHash);
339
+ list.unshift({ roomHash, hint: labelEntry, savedAt: Date.now() });
340
+ localStorage.setItem('voidlogue_convlist', JSON.stringify(list));
341
+ },
342
+
343
+ async migratePlaintextLabels(): Promise<void> {
344
+ if (localStorage.getItem('voidlogue_labels_migrated')) return;
345
+ const list = this.list();
346
+ let changed = false;
347
+ for (const entry of list) {
348
+ if (!entry.hint || typeof entry.hint === 'string') {
349
+ entry.hint = { encrypted: '', iv: '', hint: '(no label)' };
350
+ changed = true;
351
+ }
352
+ }
353
+ if (changed) {
354
+ localStorage.setItem('voidlogue_convlist', JSON.stringify(list));
355
+ }
356
+ localStorage.setItem('voidlogue_labels_migrated', '1');
357
+ },
358
+ };