1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
|
/**
* MeshBay Browser Crypto — AES-256-GCM private group decryption.
* Uses WebCrypto SubtleCrypto API (available in all modern browsers).
*
* Handles groups with cipher="aes-256-gcm" (browser-accessible groups).
* ChaCha20-Poly1305 groups (cipher="chacha20-poly1305") require the
* native client (node) for decryption — not supported in browser.
*
* Usage:
* const gek = await importGEK(gekB64);
* const plaintext = await decryptChunk(gek, fileHashHex, chunkIndex, nonceB64, ctB64);
*/
const CIPHER_INFO_PREFIX = new TextEncoder().encode('file:');
const CIPHER_INFO_SUFFIX_AES = new TextEncoder().encode(':aes');
// ── Key derivation ────────────────────────────────────────────────────────────
/**
* Import a raw GEK (base64) as a WebCrypto key for HKDF.
* @param {string} gekB64 - base64-encoded GEK (32 bytes)
* @returns {Promise<CryptoKey>}
*/
async function importGEK(gekB64) {
const raw = b64decode(gekB64);
return crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']);
}
/**
* Derive a per-chunk AES-256-GCM key from the GEK.
* Mirrors meshbay_common/webcrypto.py::chunk_key_aes().
*
* @param {CryptoKey} gek - HKDF key from importGEK()
* @param {string} fileHashHex - blake3 hash of file (hex, 64 chars)
* @param {number} chunkIndex
* @returns {Promise<CryptoKey>}
*/
async function deriveChunkKey(gek, fileHashHex, chunkIndex) {
// Build HKDF info: "file:" + file_hash_bytes + ":chunk:" + uint32be + ":aes"
const fileHashBytes = hexToBytes(fileHashHex);
const chunkIdxBytes = new Uint8Array(4);
new DataView(chunkIdxBytes.buffer).setUint32(0, chunkIndex, false); // big-endian
const infoParts = [
new TextEncoder().encode('file:'),
fileHashBytes,
new TextEncoder().encode(':chunk:'),
chunkIdxBytes,
new TextEncoder().encode(':aes'),
];
const info = concatBuffers(infoParts);
return crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info },
gek,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt'],
);
}
// ── Decryption ────────────────────────────────────────────────────────────────
/**
* Decrypt one chunk of a private group file.
* @param {CryptoKey} gek - from importGEK()
* @param {string} fileHashHex
* @param {number} chunkIndex
* @param {string} nonceB64 - 12-byte nonce, base64
* @param {string} ctB64 - ciphertext + GCM tag, base64
* @returns {Promise<Uint8Array>} plaintext
*/
async function decryptChunk(gek, fileHashHex, chunkIndex, nonceB64, ctB64) {
const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex);
const nonce = b64decode(nonceB64);
const ct = b64decode(ctB64);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce },
chunkKey,
ct,
);
return new Uint8Array(plaintext);
}
/**
* Decrypt a full file by fetching and decrypting all chunks in order.
* @param {CryptoKey} gek
* @param {string} nodeUrl - base URL of node HTTP API
* @param {string} fileId - blake3 hex hash (= file_id in index)
* @param {string} fileHashHex - same as fileId (blake3 of file content)
* @param {string} jwtToken
* @returns {Promise<Blob>} decrypted file as Blob
*/
async function decryptFile(gek, nodeUrl, fileId, fileHashHex, jwtToken) {
const chunks = [];
let chunkIdx = 0;
while (true) {
const sep = nodeUrl.includes('?') ? '&' : '?';
const url = `${nodeUrl}/file/${fileId}/${chunkIdx}${sep}token=${jwtToken}`;
const resp = await fetch(url);
if (!resp.ok) break;
const data = await resp.json();
if (data.encrypted === false) {
// Public group: data_b64 is plaintext
chunks.push(b64decode(data.data_b64));
} else {
// Private group with AES-256-GCM
const plain = await decryptChunk(
gek, fileHashHex, chunkIdx,
data.nonce_b64, data.ct_b64,
);
chunks.push(plain);
}
if (data.plaintext_size < 1024 * 1024) break; // last chunk (< 1MB)
chunkIdx++;
}
return new Blob(chunks);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function b64decode(b64) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2)
bytes[i / 2] = parseInt(hex.substring(i, 2), 16);
return bytes;
}
function concatBuffers(arrays) {
const total = arrays.reduce((s, a) => s + a.byteLength, 0);
const result = new Uint8Array(total);
let offset = 0;
for (const arr of arrays) {
result.set(new Uint8Array(arr.buffer || arr), offset);
offset += arr.byteLength;
}
return result;
}
// Export for use in app.js
window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunk, decryptFile };
|