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/README.md +3 -3
- package/SECURITY.md +41 -14
- package/{index.js → index.ts} +3 -3
- package/package.json +23 -9
- package/src/eff_wordlist.ts +7778 -0
- package/src/vault.ts +358 -0
- package/src/voidshield.ts +520 -0
- package/src/eff_wordlist.js +0 -7778
- package/src/vault.js +0 -286
- package/src/voidshield.js +0 -353
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
|
+
};
|