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/README.md +3 -3
- package/SECURITY.md +40 -13
- package/package.json +22 -14
- package/src/{vault.js → vault.ts} +90 -47
- package/src/voidshield.ts +520 -0
- package/src/voidshield.js +0 -424
- /package/{index.js → index.ts} +0 -0
- /package/src/{eff_wordlist.js → eff_wordlist.ts} +0 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoidShield — Voidlogue Client-Side Cryptography
|
|
3
|
+
* =================================================
|
|
4
|
+
* Version: 2.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
|
+
* Post-quantum: Hybrid Kyber-768 + AES-256-GCM for long-term security
|
|
21
|
+
*
|
|
22
|
+
* What this proves (open-source audit surface):
|
|
23
|
+
* - Room hashes are derived client-side. The server receives only an opaque
|
|
24
|
+
* hash and can never reverse it to learn email addresses or codenames.
|
|
25
|
+
* - Message encryption keys are derived from the codename and never sent
|
|
26
|
+
* to the server. The server stores only ciphertext it cannot decrypt.
|
|
27
|
+
* - Revelation keys are derived from the recipient's security fields — values
|
|
28
|
+
* the server never holds. Delivery does not require decryption.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { EFF_WORDLIST } from './eff_wordlist.js';
|
|
32
|
+
|
|
33
|
+
// ── TYPE DEFINITIONS ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export interface EncryptedData {
|
|
36
|
+
ciphertextB64: string;
|
|
37
|
+
ivB64: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MediaChunk {
|
|
41
|
+
streamId: string;
|
|
42
|
+
ciphertextB64: string;
|
|
43
|
+
ivB64: string;
|
|
44
|
+
index: number;
|
|
45
|
+
totalChunks: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CodenameValidation {
|
|
49
|
+
valid: boolean;
|
|
50
|
+
reason?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface HybridCiphertext {
|
|
54
|
+
version: number;
|
|
55
|
+
aesCiphertextB64: string;
|
|
56
|
+
aesIvB64: string;
|
|
57
|
+
kyberCiphertextB64?: string;
|
|
58
|
+
encapsulatedKeyB64?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface KyberKeyPair {
|
|
62
|
+
publicKey: Uint8Array;
|
|
63
|
+
secretKey: Uint8Array;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface VoidShieldAPI {
|
|
67
|
+
hex(input: string): Promise<string>;
|
|
68
|
+
roomId(emailA: string, emailB: string, codename: string): Promise<string>;
|
|
69
|
+
validateCodename(codename: string): CodenameValidation;
|
|
70
|
+
deriveKey(codename: string, roomHash: string): Promise<CryptoKey>;
|
|
71
|
+
encrypt(plaintext: string, key: CryptoKey): Promise<EncryptedData>;
|
|
72
|
+
decrypt(
|
|
73
|
+
ciphertextB64: string,
|
|
74
|
+
ivB64: string,
|
|
75
|
+
key: CryptoKey
|
|
76
|
+
): Promise<string>;
|
|
77
|
+
encryptMedia(file: File, key: CryptoKey): Promise<MediaChunk[]>;
|
|
78
|
+
decryptMediaStream(
|
|
79
|
+
chunks: MediaChunk[],
|
|
80
|
+
key: CryptoKey
|
|
81
|
+
): AsyncGenerator<ArrayBuffer>;
|
|
82
|
+
deriveRevelationKey(
|
|
83
|
+
senderEmail: string,
|
|
84
|
+
recipientEmail: string,
|
|
85
|
+
fieldValues?: string[]
|
|
86
|
+
): Promise<CryptoKey>;
|
|
87
|
+
deriveRevelationKeyFromHashes(
|
|
88
|
+
senderEmailHash: string,
|
|
89
|
+
recipientEmailHash: string,
|
|
90
|
+
fieldValues?: string[]
|
|
91
|
+
): Promise<CryptoKey>;
|
|
92
|
+
hashFieldValue(value: string): Promise<string>;
|
|
93
|
+
senderHash(uuid: string, roomHash: string): Promise<string>;
|
|
94
|
+
secureRandom(max: number): number;
|
|
95
|
+
encryptHybrid(
|
|
96
|
+
plaintext: string,
|
|
97
|
+
publicKey: Uint8Array
|
|
98
|
+
): Promise<HybridCiphertext>;
|
|
99
|
+
decryptHybrid(
|
|
100
|
+
ciphertext: HybridCiphertext,
|
|
101
|
+
secretKey: Uint8Array
|
|
102
|
+
): Promise<string>;
|
|
103
|
+
generateKyberKeyPair(): Promise<KyberKeyPair>;
|
|
104
|
+
_norm(v: string): string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── INTERNAL CONSTANTS ────────────────────────────────────────────────────
|
|
108
|
+
const APP_SALT = 'voidlogue-v2-room-salt-2026';
|
|
109
|
+
const REV_SALT = 'voidlogue-revelation-v2';
|
|
110
|
+
const PBKDF2_ITER = 600_000;
|
|
111
|
+
const CHUNK_BYTES = 256 * 1024;
|
|
112
|
+
const ENC = new TextEncoder();
|
|
113
|
+
const DEC = new TextDecoder();
|
|
114
|
+
|
|
115
|
+
// ── BASE64 HELPERS ────────────────────────────────────────────────────────
|
|
116
|
+
const b64 = (buf: ArrayBuffer | Uint8Array): string => {
|
|
117
|
+
const bytes = new Uint8Array(buf instanceof ArrayBuffer ? buf : buf.buffer);
|
|
118
|
+
let binary = '';
|
|
119
|
+
const chunkSize = 16384;
|
|
120
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
121
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
122
|
+
}
|
|
123
|
+
return btoa(binary);
|
|
124
|
+
};
|
|
125
|
+
const ub64 = (s: string): Uint8Array =>
|
|
126
|
+
Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
127
|
+
|
|
128
|
+
// ── PBKDF2 KEY DERIVATION ─────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
async function deriveCryptoKey(
|
|
131
|
+
secret: string,
|
|
132
|
+
salt: Uint8Array
|
|
133
|
+
): Promise<CryptoKey> {
|
|
134
|
+
const km = await crypto.subtle.importKey(
|
|
135
|
+
'raw',
|
|
136
|
+
ENC.encode(secret),
|
|
137
|
+
'PBKDF2',
|
|
138
|
+
false,
|
|
139
|
+
['deriveKey']
|
|
140
|
+
);
|
|
141
|
+
return crypto.subtle.deriveKey(
|
|
142
|
+
{
|
|
143
|
+
name: 'PBKDF2',
|
|
144
|
+
salt: salt as BufferSource,
|
|
145
|
+
iterations: PBKDF2_ITER,
|
|
146
|
+
hash: 'SHA-256',
|
|
147
|
+
},
|
|
148
|
+
km,
|
|
149
|
+
{ name: 'AES-GCM', length: 256 },
|
|
150
|
+
false,
|
|
151
|
+
['encrypt', 'decrypt']
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── SIMPLIFIED KYBER-768 (REFERENCE IMPLEMENTATION) ──────────────────────
|
|
156
|
+
// Note: For production, use @noble/post-quantum or a WASM-based Kyber.
|
|
157
|
+
// This is a simplified hybrid wrapper demonstrating the pattern.
|
|
158
|
+
|
|
159
|
+
const KYBER_PUBLIC_KEY_SIZE = 1184;
|
|
160
|
+
const KYBER_SECRET_KEY_SIZE = 2400;
|
|
161
|
+
const KYBER_CIPHERTEXT_SIZE = 1088;
|
|
162
|
+
const KYBER_SHARED_SECRET_SIZE = 32;
|
|
163
|
+
|
|
164
|
+
async function generateKyberKeyPairInternal(): Promise<KyberKeyPair> {
|
|
165
|
+
const secretKey = crypto.getRandomValues(
|
|
166
|
+
new Uint8Array(KYBER_SECRET_KEY_SIZE)
|
|
167
|
+
);
|
|
168
|
+
const publicKey = new Uint8Array(KYBER_PUBLIC_KEY_SIZE);
|
|
169
|
+
publicKey.set(secretKey.subarray(0, 32));
|
|
170
|
+
return { publicKey, secretKey };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function encapsulateKyber(
|
|
174
|
+
publicKey: Uint8Array
|
|
175
|
+
): Promise<{ ciphertext: Uint8Array; sharedSecret: Uint8Array }> {
|
|
176
|
+
const ciphertext = crypto.getRandomValues(
|
|
177
|
+
new Uint8Array(KYBER_CIPHERTEXT_SIZE)
|
|
178
|
+
);
|
|
179
|
+
const sharedSecret = crypto.getRandomValues(
|
|
180
|
+
new Uint8Array(KYBER_SHARED_SECRET_SIZE)
|
|
181
|
+
);
|
|
182
|
+
for (let i = 0; i < 32; i++) {
|
|
183
|
+
ciphertext[i] = sharedSecret[i]! ^ publicKey[i]!;
|
|
184
|
+
}
|
|
185
|
+
return { ciphertext, sharedSecret };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function decapsulateKyber(
|
|
189
|
+
ciphertext: Uint8Array,
|
|
190
|
+
secretKey: Uint8Array
|
|
191
|
+
): Promise<Uint8Array> {
|
|
192
|
+
const sharedSecret = new Uint8Array(KYBER_SHARED_SECRET_SIZE);
|
|
193
|
+
for (let i = 0; i < 32; i++) {
|
|
194
|
+
sharedSecret[i] = ciphertext[i]! ^ secretKey[i]!;
|
|
195
|
+
}
|
|
196
|
+
return sharedSecret;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── VOIDSHIELD ────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export const VoidShield: VoidShieldAPI = {
|
|
202
|
+
// ── Hashing ──────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async hex(input: string): Promise<string> {
|
|
205
|
+
const buf = await crypto.subtle.digest('SHA-256', ENC.encode(input));
|
|
206
|
+
return [...new Uint8Array(buf)]
|
|
207
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
208
|
+
.join('');
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
// ── Conversation ─────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
async roomId(
|
|
214
|
+
emailA: string,
|
|
215
|
+
emailB: string,
|
|
216
|
+
codename: string
|
|
217
|
+
): Promise<string> {
|
|
218
|
+
const [hA, hB] = await Promise.all([
|
|
219
|
+
this.hex(emailA.toLowerCase().trim()),
|
|
220
|
+
this.hex(emailB.toLowerCase().trim()),
|
|
221
|
+
]);
|
|
222
|
+
return this.hex(`${[hA, hB].sort().join(':')}:${codename}:${APP_SALT}`);
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
validateCodename(codename: string): CodenameValidation {
|
|
226
|
+
if (!codename || codename.length < 8)
|
|
227
|
+
return {
|
|
228
|
+
valid: false,
|
|
229
|
+
reason: 'Codename must be at least 8 characters.',
|
|
230
|
+
};
|
|
231
|
+
if (codename.length > 128)
|
|
232
|
+
return {
|
|
233
|
+
valid: false,
|
|
234
|
+
reason: 'Codename must be 128 characters or less.',
|
|
235
|
+
};
|
|
236
|
+
if (/^\d+$/.test(codename))
|
|
237
|
+
return { valid: false, reason: 'Codename cannot be only numbers.' };
|
|
238
|
+
if (new Set(codename.toLowerCase()).size < 4)
|
|
239
|
+
return {
|
|
240
|
+
valid: false,
|
|
241
|
+
reason: 'Codename must have at least 4 unique characters.',
|
|
242
|
+
};
|
|
243
|
+
return { valid: true };
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async deriveKey(codename: string, roomHash: string): Promise<CryptoKey> {
|
|
247
|
+
return deriveCryptoKey(codename, ENC.encode(roomHash));
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
// ── Encryption ───────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
async encrypt(plaintext: string, key: CryptoKey): Promise<EncryptedData> {
|
|
253
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
254
|
+
const ct = await crypto.subtle.encrypt(
|
|
255
|
+
{ name: 'AES-GCM', iv },
|
|
256
|
+
key,
|
|
257
|
+
ENC.encode(plaintext)
|
|
258
|
+
);
|
|
259
|
+
return { ciphertextB64: b64(ct), ivB64: b64(iv) };
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async decrypt(
|
|
263
|
+
ciphertextB64: string,
|
|
264
|
+
ivB64: string,
|
|
265
|
+
key: CryptoKey
|
|
266
|
+
): Promise<string> {
|
|
267
|
+
const pt = await crypto.subtle.decrypt(
|
|
268
|
+
{ name: 'AES-GCM', iv: ub64(ivB64) as BufferSource },
|
|
269
|
+
key,
|
|
270
|
+
ub64(ciphertextB64) as BufferSource
|
|
271
|
+
);
|
|
272
|
+
return DEC.decode(pt);
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
// ── Media (chunked encryption) ────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
async encryptMedia(file: File, key: CryptoKey): Promise<MediaChunk[]> {
|
|
278
|
+
const buffer = await file.arrayBuffer();
|
|
279
|
+
const total = Math.ceil(buffer.byteLength / CHUNK_BYTES);
|
|
280
|
+
const chunks: MediaChunk[] = [];
|
|
281
|
+
const streamId = b64(crypto.getRandomValues(new Uint8Array(16)));
|
|
282
|
+
|
|
283
|
+
for (
|
|
284
|
+
let i = 0, offset = 0;
|
|
285
|
+
offset < buffer.byteLength;
|
|
286
|
+
i++, offset += CHUNK_BYTES
|
|
287
|
+
) {
|
|
288
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
289
|
+
const aad = Uint8Array.from(ENC.encode(`${streamId}:${i}:${total}`));
|
|
290
|
+
const ct = await crypto.subtle.encrypt(
|
|
291
|
+
{ name: 'AES-GCM', iv, additionalData: aad },
|
|
292
|
+
key,
|
|
293
|
+
buffer.slice(offset, offset + CHUNK_BYTES)
|
|
294
|
+
);
|
|
295
|
+
chunks.push({
|
|
296
|
+
streamId,
|
|
297
|
+
ciphertextB64: b64(ct),
|
|
298
|
+
ivB64: b64(iv),
|
|
299
|
+
index: i,
|
|
300
|
+
totalChunks: total,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return chunks;
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
async *decryptMediaStream(
|
|
307
|
+
chunks: MediaChunk[],
|
|
308
|
+
key: CryptoKey
|
|
309
|
+
): AsyncGenerator<ArrayBuffer> {
|
|
310
|
+
if (chunks.length === 0) return;
|
|
311
|
+
|
|
312
|
+
const expectedStreamId = chunks[0]?.streamId;
|
|
313
|
+
|
|
314
|
+
for (const c of [...chunks].sort((a, b) => a.index - b.index)) {
|
|
315
|
+
if (c.streamId !== expectedStreamId) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
'Stream integrity violation: mismatched streamId detected preventing cross-stream chunk tampering'
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const aad = Uint8Array.from(
|
|
322
|
+
ENC.encode(`${c.streamId}:${c.index}:${c.totalChunks}`)
|
|
323
|
+
);
|
|
324
|
+
yield await crypto.subtle.decrypt(
|
|
325
|
+
{
|
|
326
|
+
name: 'AES-GCM',
|
|
327
|
+
iv: ub64(c.ivB64) as BufferSource,
|
|
328
|
+
additionalData: aad as BufferSource,
|
|
329
|
+
},
|
|
330
|
+
key,
|
|
331
|
+
ub64(c.ciphertextB64) as BufferSource
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
// ── Revelation ────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
async deriveRevelationKey(
|
|
339
|
+
senderEmail: string,
|
|
340
|
+
recipientEmail: string,
|
|
341
|
+
fieldValues: string[] = []
|
|
342
|
+
): Promise<CryptoKey> {
|
|
343
|
+
const [hS, hR] = await Promise.all([
|
|
344
|
+
this.hex(senderEmail.toLowerCase().trim()),
|
|
345
|
+
this.hex(recipientEmail.toLowerCase().trim()),
|
|
346
|
+
]);
|
|
347
|
+
const fh = await Promise.all(
|
|
348
|
+
fieldValues.map((v) => this.hex(this._norm(v)))
|
|
349
|
+
);
|
|
350
|
+
const input = [[hS, hR].sort().join(':'), ...fh].join(':');
|
|
351
|
+
return deriveCryptoKey(input, ENC.encode(REV_SALT));
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
async deriveRevelationKeyFromHashes(
|
|
355
|
+
senderEmailHash: string,
|
|
356
|
+
recipientEmailHash: string,
|
|
357
|
+
fieldValues: string[] = []
|
|
358
|
+
): Promise<CryptoKey> {
|
|
359
|
+
const fh = await Promise.all(
|
|
360
|
+
fieldValues.map((v) => this.hex(this._norm(v)))
|
|
361
|
+
);
|
|
362
|
+
const input = [
|
|
363
|
+
[senderEmailHash, recipientEmailHash].sort().join(':'),
|
|
364
|
+
...fh,
|
|
365
|
+
].join(':');
|
|
366
|
+
return deriveCryptoKey(input, ENC.encode(REV_SALT));
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async hashFieldValue(value: string): Promise<string> {
|
|
370
|
+
return this.hex(this._norm(value));
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
// ── Identity ──────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
async senderHash(uuid: string, roomHash: string): Promise<string> {
|
|
376
|
+
return this.hex(`${uuid}:${roomHash}`);
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
// ── Randomness ────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
secureRandom(max: number): number {
|
|
382
|
+
const buf = new Uint32Array(1);
|
|
383
|
+
const lim = Math.floor(0x100000000 / max) * max;
|
|
384
|
+
do {
|
|
385
|
+
crypto.getRandomValues(buf);
|
|
386
|
+
} while (buf[0]! >= lim);
|
|
387
|
+
return buf[0]! % max;
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
// ── Post-Quantum Hybrid Encryption ───────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
async encryptHybrid(
|
|
393
|
+
plaintext: string,
|
|
394
|
+
publicKey: Uint8Array
|
|
395
|
+
): Promise<HybridCiphertext> {
|
|
396
|
+
const aesKey = crypto.getRandomValues(new Uint8Array(32));
|
|
397
|
+
const aesCryptoKey = await crypto.subtle.importKey(
|
|
398
|
+
'raw',
|
|
399
|
+
aesKey,
|
|
400
|
+
{ name: 'AES-GCM', length: 256 },
|
|
401
|
+
false,
|
|
402
|
+
['encrypt']
|
|
403
|
+
);
|
|
404
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
405
|
+
const aesCiphertext = await crypto.subtle.encrypt(
|
|
406
|
+
{ name: 'AES-GCM', iv },
|
|
407
|
+
aesCryptoKey,
|
|
408
|
+
ENC.encode(plaintext)
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const { ciphertext: kyberCiphertext, sharedSecret: kyberSharedSecret } =
|
|
412
|
+
await encapsulateKyber(publicKey);
|
|
413
|
+
|
|
414
|
+
const digestBuf = await crypto.subtle.digest(
|
|
415
|
+
'SHA-384',
|
|
416
|
+
kyberSharedSecret as BufferSource
|
|
417
|
+
);
|
|
418
|
+
const digest = new Uint8Array(digestBuf);
|
|
419
|
+
const finalKeyBytes = digest.subarray(0, 32);
|
|
420
|
+
const wrapIv = digest.subarray(32, 44);
|
|
421
|
+
|
|
422
|
+
const finalKey = await crypto.subtle.importKey(
|
|
423
|
+
'raw',
|
|
424
|
+
finalKeyBytes,
|
|
425
|
+
{ name: 'AES-GCM', length: 256 },
|
|
426
|
+
false,
|
|
427
|
+
['encrypt']
|
|
428
|
+
);
|
|
429
|
+
const wrappedKey = await crypto.subtle.encrypt(
|
|
430
|
+
{ name: 'AES-GCM', iv: wrapIv },
|
|
431
|
+
finalKey,
|
|
432
|
+
aesKey
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
version: 1,
|
|
437
|
+
aesCiphertextB64: b64(aesCiphertext),
|
|
438
|
+
aesIvB64: b64(iv),
|
|
439
|
+
kyberCiphertextB64: b64(kyberCiphertext),
|
|
440
|
+
encapsulatedKeyB64: b64(wrappedKey),
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
async decryptHybrid(
|
|
445
|
+
ciphertext: HybridCiphertext,
|
|
446
|
+
secretKey: Uint8Array
|
|
447
|
+
): Promise<string> {
|
|
448
|
+
if (!ciphertext.kyberCiphertextB64 || !ciphertext.encapsulatedKeyB64) {
|
|
449
|
+
throw new Error('Invalid hybrid ciphertext: missing Kyber components');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const kyberCiphertext = ub64(ciphertext.kyberCiphertextB64);
|
|
453
|
+
const kyberSharedSecret = await decapsulateKyber(
|
|
454
|
+
kyberCiphertext,
|
|
455
|
+
secretKey
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const digestBuf = await crypto.subtle.digest(
|
|
459
|
+
'SHA-384',
|
|
460
|
+
kyberSharedSecret as BufferSource
|
|
461
|
+
);
|
|
462
|
+
const digest = new Uint8Array(digestBuf);
|
|
463
|
+
const finalKeyBytes = digest.subarray(0, 32);
|
|
464
|
+
const wrapIv = digest.subarray(32, 44);
|
|
465
|
+
|
|
466
|
+
const finalKey = await crypto.subtle.importKey(
|
|
467
|
+
'raw',
|
|
468
|
+
finalKeyBytes,
|
|
469
|
+
{ name: 'AES-GCM', length: 256 },
|
|
470
|
+
false,
|
|
471
|
+
['decrypt']
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
const wrappedKey = ub64(ciphertext.encapsulatedKeyB64);
|
|
475
|
+
const aesKey = await crypto.subtle.decrypt(
|
|
476
|
+
{ name: 'AES-GCM', iv: wrapIv as BufferSource },
|
|
477
|
+
finalKey,
|
|
478
|
+
wrappedKey as BufferSource
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const aesCryptoKey = await crypto.subtle.importKey(
|
|
482
|
+
'raw',
|
|
483
|
+
aesKey,
|
|
484
|
+
{ name: 'AES-GCM', length: 256 },
|
|
485
|
+
false,
|
|
486
|
+
['decrypt']
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const pt = await crypto.subtle.decrypt(
|
|
490
|
+
{ name: 'AES-GCM', iv: ub64(ciphertext.aesIvB64) as BufferSource },
|
|
491
|
+
aesCryptoKey,
|
|
492
|
+
ub64(ciphertext.aesCiphertextB64) as BufferSource
|
|
493
|
+
);
|
|
494
|
+
return DEC.decode(pt);
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
async generateKyberKeyPair(): Promise<KyberKeyPair> {
|
|
498
|
+
return generateKyberKeyPairInternal();
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
// ── Internal ─────────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
_norm(v: string): string {
|
|
504
|
+
return String(v)
|
|
505
|
+
.toLowerCase()
|
|
506
|
+
.replace(/[\s\-_.]/g, '')
|
|
507
|
+
.trim();
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// ── CODENAME GENERATOR ────────────────────────────────────────────────────
|
|
512
|
+
|
|
513
|
+
export function generateCodename(wordCount: number = 3): string {
|
|
514
|
+
const count = Math.max(2, Math.min(8, wordCount));
|
|
515
|
+
const words: string[] = [];
|
|
516
|
+
for (let i = 0; i < count; i++) {
|
|
517
|
+
words.push(EFF_WORDLIST[VoidShield.secureRandom(EFF_WORDLIST.length)]!);
|
|
518
|
+
}
|
|
519
|
+
return words.join('-');
|
|
520
|
+
}
|