salamailer 1.0.0
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/LICENSE +43 -0
- package/README.md +159 -0
- package/cli/salamailer.mjs +795 -0
- package/package.json +45 -0
- package/public/js/crypto.js +319 -0
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "salamailer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"mcpName": "fi.salamailer/salamailer",
|
|
5
|
+
"description": "Zero-knowledge secure email CLI + MCP server — encrypt messages and attachments on your own machine, gate delivery with an SMS code or passphrase",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"modelcontextprotocol",
|
|
9
|
+
"encryption",
|
|
10
|
+
"end-to-end-encryption",
|
|
11
|
+
"email",
|
|
12
|
+
"secure-file-transfer",
|
|
13
|
+
"zero-knowledge",
|
|
14
|
+
"gdpr"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://salamailer.fi",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Maffen Oy",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"salamailer": "cli/salamailer.mjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"cli/salamailer.mjs",
|
|
25
|
+
"public/js/crypto.js",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.5.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"start": "node src/server.js",
|
|
33
|
+
"dev": "node --watch src/server.js",
|
|
34
|
+
"test": "node test/e2e.mjs",
|
|
35
|
+
"hashes": "node scripts/hashes.mjs",
|
|
36
|
+
"claims": "node scripts/claims-lint.mjs",
|
|
37
|
+
"build:hashes": "node scripts/hashes.mjs --out public/SERVED-HASHES.txt",
|
|
38
|
+
"security-report": "node test/adversarial.mjs",
|
|
39
|
+
"build:cli": "node scripts/build-cli.mjs",
|
|
40
|
+
"prestart": "node scripts/build-cli.mjs",
|
|
41
|
+
"pretest": "node scripts/build-cli.mjs",
|
|
42
|
+
"prebuild:hashes": "node scripts/build-cli.mjs",
|
|
43
|
+
"deploy": "bash scripts/deploy.sh"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// Salamailer client-side crypto — the heart of the product (PLAN.md §5).
|
|
2
|
+
//
|
|
3
|
+
// Everything here runs in the browser. Plaintext, content keys (CK), the KEK, the
|
|
4
|
+
// VK, and the URL fragment NEVER leave this file's memory to the network.
|
|
5
|
+
//
|
|
6
|
+
// PROTOCOL CONSTANTS ARE FROZEN: the HKDF salt/info strings ('salamailer-reply-v1',
|
|
7
|
+
// 'salamailer-paranoid-v1', 'salamailer-recovery-v1') are domain-separation labels
|
|
8
|
+
// baked into every ciphertext, printed recovery key, and reply keypair. Renaming
|
|
9
|
+
// them (even for branding) makes existing data undecryptable. They were switched
|
|
10
|
+
// once from the old product name's spelling, pre-launch on 2026-07-13, while no
|
|
11
|
+
// real user data existed; never again.
|
|
12
|
+
//
|
|
13
|
+
// Primitives (PLAN.md §5.2): browser-native Web Crypto only, no third-party crypto.
|
|
14
|
+
// - Content encryption: AES-256-GCM, random 96-bit IV per encryption.
|
|
15
|
+
// - Key wrapping: AES-256-GCM key-wrap.
|
|
16
|
+
// - Password -> KEK / auth verifier: PBKDF2-SHA256.
|
|
17
|
+
//
|
|
18
|
+
// MVP DEVIATION from PLAN.md §5.2 (documented in DECISIONS.md): the plan specifies
|
|
19
|
+
// Argon2id for password stretching. Web Crypto has no Argon2, and bundling an
|
|
20
|
+
// argon2 wasm would force `wasm-unsafe-eval` into the CSP — weakening the very
|
|
21
|
+
// control (§7.1) that protects the whole zero-knowledge claim. For the MVP we use
|
|
22
|
+
// PBKDF2-SHA256 at a high iteration count. The key hierarchy (§5.3) is identical,
|
|
23
|
+
// so swapping the KDF later is a localized change.
|
|
24
|
+
|
|
25
|
+
const PBKDF2_ITERS = 600_000;
|
|
26
|
+
const enc = new TextEncoder();
|
|
27
|
+
const dec = new TextDecoder();
|
|
28
|
+
|
|
29
|
+
// --- base64 helpers ---------------------------------------------------------
|
|
30
|
+
export function b64(buf) {
|
|
31
|
+
const bytes = new Uint8Array(buf);
|
|
32
|
+
let s = '';
|
|
33
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
34
|
+
return btoa(s);
|
|
35
|
+
}
|
|
36
|
+
export function unb64(str) {
|
|
37
|
+
const bin = atob(str);
|
|
38
|
+
const out = new Uint8Array(bin.length);
|
|
39
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
export function b64url(buf) {
|
|
43
|
+
return b64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
44
|
+
}
|
|
45
|
+
export function unb64url(str) {
|
|
46
|
+
return unb64(str.replace(/-/g, '+').replace(/_/g, '/'));
|
|
47
|
+
}
|
|
48
|
+
export const utf8 = (s) => enc.encode(s);
|
|
49
|
+
export const fromUtf8 = (buf) => dec.decode(buf);
|
|
50
|
+
|
|
51
|
+
// --- packed AES-GCM blobs ("iv.ciphertext" as base64) -----------------------
|
|
52
|
+
function pack(iv, ct) { return b64(iv) + '.' + b64(ct); }
|
|
53
|
+
function unpack(str) {
|
|
54
|
+
const [iv, ct] = str.split('.');
|
|
55
|
+
return { iv: unb64(iv), ct: unb64(ct) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- password -> KEK and auth verifier --------------------------------------
|
|
59
|
+
async function pbkdf2Material(password) {
|
|
60
|
+
return crypto.subtle.importKey('raw', utf8(password), 'PBKDF2', false, ['deriveBits', 'deriveKey']);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// KEK: wraps/unwraps the VK. Never stored, never leaves the browser (§5.3).
|
|
64
|
+
export async function deriveKEK(password, saltBytes) {
|
|
65
|
+
const material = await pbkdf2Material(password);
|
|
66
|
+
return crypto.subtle.deriveKey(
|
|
67
|
+
{ name: 'PBKDF2', salt: saltBytes, iterations: PBKDF2_ITERS, hash: 'SHA-256' },
|
|
68
|
+
material,
|
|
69
|
+
{ name: 'AES-GCM', length: 256 },
|
|
70
|
+
false,
|
|
71
|
+
['wrapKey', 'unwrapKey']
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Auth verifier: a value the server can check without ever seeing the password.
|
|
76
|
+
export async function deriveAuthVerifier(password, saltBytes) {
|
|
77
|
+
const material = await pbkdf2Material(password);
|
|
78
|
+
const bits = await crypto.subtle.deriveBits(
|
|
79
|
+
{ name: 'PBKDF2', salt: saltBytes, iterations: PBKDF2_ITERS, hash: 'SHA-256' },
|
|
80
|
+
material,
|
|
81
|
+
256
|
|
82
|
+
);
|
|
83
|
+
return b64(bits);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- vault key (VK) ---------------------------------------------------------
|
|
87
|
+
export function generateVK() {
|
|
88
|
+
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['wrapKey', 'unwrapKey']);
|
|
89
|
+
}
|
|
90
|
+
export async function wrapVK(vk, kek) {
|
|
91
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
92
|
+
const ct = await crypto.subtle.wrapKey('raw', vk, kek, { name: 'AES-GCM', iv });
|
|
93
|
+
return pack(iv, ct);
|
|
94
|
+
}
|
|
95
|
+
export async function unwrapVK(packed, kek) {
|
|
96
|
+
const { iv, ct } = unpack(packed);
|
|
97
|
+
return crypto.subtle.unwrapKey('raw', ct, kek, { name: 'AES-GCM', iv },
|
|
98
|
+
{ name: 'AES-GCM', length: 256 }, true, ['wrapKey', 'unwrapKey']);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Persist the VK on the sender's own device so compose and resend/recovery work
|
|
102
|
+
// across page loads — as a NON-EXTRACTABLE CryptoKey in IndexedDB. Even code
|
|
103
|
+
// running in this origin (the XSS worst case) can only *use* the key while the
|
|
104
|
+
// page is open; it can never read the key bytes out. TTL-bound; cleared on logout.
|
|
105
|
+
const VK_TTL_MS = 12 * 60 * 60 * 1000;
|
|
106
|
+
|
|
107
|
+
function idb() {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const r = indexedDB.open('salamailer', 1);
|
|
110
|
+
r.onupgradeneeded = () => r.result.createObjectStore('kv');
|
|
111
|
+
r.onsuccess = () => resolve(r.result);
|
|
112
|
+
r.onerror = () => reject(r.error);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function idbOp(mode, fn) {
|
|
116
|
+
return idb().then((db) => new Promise((resolve, reject) => {
|
|
117
|
+
const tx = db.transaction('kv', mode);
|
|
118
|
+
const req = fn(tx.objectStore('kv'));
|
|
119
|
+
tx.oncomplete = () => { db.close(); resolve(req.result); };
|
|
120
|
+
tx.onerror = () => { db.close(); reject(tx.error); };
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function stashVK(vk) {
|
|
125
|
+
// Re-import as non-extractable: the stored handle can wrap/unwrap but never export.
|
|
126
|
+
const raw = await crypto.subtle.exportKey('raw', vk);
|
|
127
|
+
const nonExtractable = await crypto.subtle.importKey(
|
|
128
|
+
'raw', raw, { name: 'AES-GCM', length: 256 }, false, ['wrapKey', 'unwrapKey']);
|
|
129
|
+
await idbOp('readwrite', (s) => s.put({ key: nonExtractable, expiresAt: Date.now() + VK_TTL_MS }, 'vk'));
|
|
130
|
+
}
|
|
131
|
+
export async function loadVK() {
|
|
132
|
+
try {
|
|
133
|
+
const rec = await idbOp('readonly', (s) => s.get('vk'));
|
|
134
|
+
if (!rec || rec.expiresAt < Date.now()) { if (rec) clearVK(); return null; }
|
|
135
|
+
return rec.key;
|
|
136
|
+
} catch { return null; }
|
|
137
|
+
}
|
|
138
|
+
export function clearVK() {
|
|
139
|
+
idbOp('readwrite', (s) => s.delete('vk')).catch(() => { /* best effort */ });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- content key (CK) -------------------------------------------------------
|
|
143
|
+
export function generateCK() {
|
|
144
|
+
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
145
|
+
}
|
|
146
|
+
// Sender copy: CK wrapped under VK, stored server-side (enables resend, §5.5).
|
|
147
|
+
export async function wrapCK(ck, vk) {
|
|
148
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
149
|
+
const ct = await crypto.subtle.wrapKey('raw', ck, vk, { name: 'AES-GCM', iv });
|
|
150
|
+
return pack(iv, ct);
|
|
151
|
+
}
|
|
152
|
+
export async function unwrapCK(packed, vk) {
|
|
153
|
+
const { iv, ct } = unpack(packed);
|
|
154
|
+
return crypto.subtle.unwrapKey('raw', ct, vk, { name: 'AES-GCM', iv },
|
|
155
|
+
{ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
156
|
+
}
|
|
157
|
+
// Recipient copy: the raw CK, carried ONLY in the URL fragment (§5.3).
|
|
158
|
+
export async function exportCKFragment(ck) {
|
|
159
|
+
return b64url(await crypto.subtle.exportKey('raw', ck));
|
|
160
|
+
}
|
|
161
|
+
export async function importCKFragment(fragment) {
|
|
162
|
+
return crypto.subtle.importKey('raw', unb64url(fragment), { name: 'AES-GCM', length: 256 },
|
|
163
|
+
true, ['encrypt', 'decrypt']);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- saved-passphrase wrapping (per-recipient standing gate secret) ----------
|
|
167
|
+
// A saved gate passphrase is stored server-side encrypted under the VK, so the
|
|
168
|
+
// server can't read it. Composes the same primitives as a message: a random data
|
|
169
|
+
// key encrypts the passphrase; that key is wrapped under the VK. Returns the
|
|
170
|
+
// three fields to persist; unwrap needs the VK (password-derived, sender-only).
|
|
171
|
+
export async function wrapPassphrase(passphrase, vk) {
|
|
172
|
+
const dk = await generateCK();
|
|
173
|
+
const { iv, ct } = await encryptBytes(dk, utf8(passphrase));
|
|
174
|
+
return { keyWrapped: await wrapCK(dk, vk), iv, ct: b64(ct) };
|
|
175
|
+
}
|
|
176
|
+
export async function unwrapPassphrase({ keyWrapped, iv, ct }, vk) {
|
|
177
|
+
const dk = await unwrapCK(keyWrapped, vk);
|
|
178
|
+
return fromUtf8(await decryptBytes(dk, iv, unb64(ct)));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- secure reply (asymmetric, ECIES over ECDH P-256) ------------------------
|
|
182
|
+
// Each user has one reply keypair. The private key is wrapped under the VK and
|
|
183
|
+
// stored server-side (useless without the password); the PUBLIC key is never
|
|
184
|
+
// stored server-side at all — it travels to the recipient INSIDE the encrypted
|
|
185
|
+
// message envelope, so a malicious server cannot swap it. The recipient encrypts
|
|
186
|
+
// their reply under an ephemeral ECDH key against that public key; only the
|
|
187
|
+
// sender's unwrapped private key can derive the reply key.
|
|
188
|
+
export function generateReplyKeypair() {
|
|
189
|
+
return crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
190
|
+
}
|
|
191
|
+
export async function wrapReplySK(sk, vk) {
|
|
192
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
193
|
+
const ct = await crypto.subtle.wrapKey('jwk', sk, vk, { name: 'AES-GCM', iv });
|
|
194
|
+
return pack(iv, ct);
|
|
195
|
+
}
|
|
196
|
+
export async function unwrapReplySK(packed, vk) {
|
|
197
|
+
const { iv, ct } = unpack(packed);
|
|
198
|
+
return crypto.subtle.unwrapKey('jwk', ct, vk, { name: 'AES-GCM', iv },
|
|
199
|
+
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
200
|
+
}
|
|
201
|
+
// The EC private JWK contains the public point (x, y) — derive the public key
|
|
202
|
+
// locally from the unwrapped private key; never trust a server-supplied copy.
|
|
203
|
+
export async function replyPubJwk(sk) {
|
|
204
|
+
const j = await crypto.subtle.exportKey('jwk', sk);
|
|
205
|
+
return { x: j.x, y: j.y };
|
|
206
|
+
}
|
|
207
|
+
function importReplyPub({ x, y }) {
|
|
208
|
+
return crypto.subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', x, y },
|
|
209
|
+
{ name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
210
|
+
}
|
|
211
|
+
async function ecdhToAes(privKey, pubKey, saltBytes) {
|
|
212
|
+
const bits = await crypto.subtle.deriveBits({ name: 'ECDH', public: pubKey }, privKey, 256);
|
|
213
|
+
const hk = await crypto.subtle.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
|
|
214
|
+
return crypto.subtle.deriveKey(
|
|
215
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: saltBytes, info: utf8('salamailer-reply-v1') },
|
|
216
|
+
hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
217
|
+
}
|
|
218
|
+
// Recipient side: fresh ephemeral keypair per reply against the sender's public key.
|
|
219
|
+
export async function deriveReplyKeyForSend(senderPubJwk) {
|
|
220
|
+
const eph = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
221
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
222
|
+
const key = await ecdhToAes(eph.privateKey, await importReplyPub(senderPubJwk), salt);
|
|
223
|
+
const ephJwk = await crypto.subtle.exportKey('jwk', eph.publicKey);
|
|
224
|
+
return { key, ephPub: { x: ephJwk.x, y: ephJwk.y }, hkdfSalt: b64(salt) };
|
|
225
|
+
}
|
|
226
|
+
// Sender side: unwrapped reply SK + the reply's stored ephemeral pub + salt.
|
|
227
|
+
export async function deriveReplyKeyForRead(replySk, ephPubJwk, hkdfSaltB64) {
|
|
228
|
+
return ecdhToAes(replySk, await importReplyPub(ephPubJwk), unb64(hkdfSaltB64));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- message body envelope ----------------------------------------------------
|
|
232
|
+
// The body blob's plaintext is a JSON envelope so the sender's reply public key
|
|
233
|
+
// rides INSIDE the end-to-end encryption. Legacy bodies (plain text) still parse.
|
|
234
|
+
export function packEnvelope(text, replyPub) {
|
|
235
|
+
return JSON.stringify(replyPub ? { sala: 2, text, replyPub } : { sala: 2, text });
|
|
236
|
+
}
|
|
237
|
+
export function parseEnvelope(str) {
|
|
238
|
+
try {
|
|
239
|
+
const o = JSON.parse(str);
|
|
240
|
+
if (o && o.sala === 2 && typeof o.text === 'string')
|
|
241
|
+
return { text: o.text, replyPub: o.replyPub && o.replyPub.x && o.replyPub.y ? o.replyPub : null };
|
|
242
|
+
} catch { /* legacy plain-text body */ }
|
|
243
|
+
return { text: str, replyPub: null };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- paranoid mode (PLAN.md §5.4 roadmap decision, opt-in) --------------------
|
|
247
|
+
// Mixes the second-channel secret into key derivation via HKDF, so a combined
|
|
248
|
+
// server-breach + email-interception attacker (who holds ciphertext + the URL
|
|
249
|
+
// fragment) still cannot decrypt without the gate secret. The fragment and the
|
|
250
|
+
// sender's wrapped CK stay the RAW CK — resend/recovery flows are unchanged;
|
|
251
|
+
// only the content-encryption key is derived. Passphrase gates only: SMS codes
|
|
252
|
+
// rotate on resend, which would orphan the ciphertext.
|
|
253
|
+
export async function deriveParanoidKey(ck, gateSecret) {
|
|
254
|
+
const raw = await crypto.subtle.exportKey('raw', ck);
|
|
255
|
+
const hk = await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']);
|
|
256
|
+
return crypto.subtle.deriveKey(
|
|
257
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: utf8('salamailer-paranoid-v1'), info: utf8(gateSecret) },
|
|
258
|
+
hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- printable recovery key (PLAN.md §5.5 roadmap decision) -------------------
|
|
262
|
+
// A 256-bit random code the user prints and stores offline. It separately wraps
|
|
263
|
+
// the VK, so a forgotten password no longer orphans the sender's vault. Two
|
|
264
|
+
// independent values are derived from it with HKDF domain separation:
|
|
265
|
+
// wrapKey — wraps/unwraps the VK (client-side only, like the KEK)
|
|
266
|
+
// verifier — proves possession to the server (stored scrypt-hashed, like the
|
|
267
|
+
// auth verifier; it cannot unwrap anything)
|
|
268
|
+
// Crockford base32, no lookalikes (O→0, I/L→1 accepted on input).
|
|
269
|
+
const B32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
270
|
+
function b32encode(bytes) {
|
|
271
|
+
let bits = 0, acc = 0, out = '';
|
|
272
|
+
for (const b of bytes) {
|
|
273
|
+
acc = (acc << 8) | b; bits += 8;
|
|
274
|
+
while (bits >= 5) { bits -= 5; out += B32[(acc >> bits) & 31]; }
|
|
275
|
+
}
|
|
276
|
+
if (bits) out += B32[(acc << (5 - bits)) & 31];
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
function b32decode(str) {
|
|
280
|
+
const norm = str.toUpperCase().replace(/O/g, '0').replace(/[IL]/g, '1').replace(/[^0-9A-Z]/g, '');
|
|
281
|
+
if (norm.length !== 52) throw new Error('recovery code must be 52 characters');
|
|
282
|
+
let bits = 0, acc = 0;
|
|
283
|
+
const out = [];
|
|
284
|
+
for (const c of norm) {
|
|
285
|
+
const v = B32.indexOf(c);
|
|
286
|
+
if (v < 0) throw new Error('invalid character in recovery code');
|
|
287
|
+
acc = (acc << 5) | v; bits += 5;
|
|
288
|
+
if (bits >= 8) { bits -= 8; out.push((acc >> bits) & 255); }
|
|
289
|
+
}
|
|
290
|
+
return new Uint8Array(out.slice(0, 32));
|
|
291
|
+
}
|
|
292
|
+
export function generateRecoveryCode() {
|
|
293
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
294
|
+
return b32encode(bytes).match(/.{1,4}/g).join('-');
|
|
295
|
+
}
|
|
296
|
+
export async function deriveRecoveryKeys(code) {
|
|
297
|
+
const bytes = b32decode(code);
|
|
298
|
+
const hk = await crypto.subtle.importKey('raw', bytes, 'HKDF', false, ['deriveKey', 'deriveBits']);
|
|
299
|
+
const params = (info) => ({ name: 'HKDF', hash: 'SHA-256', salt: utf8('salamailer-recovery-v1'), info: utf8(info) });
|
|
300
|
+
const wrapKey = await crypto.subtle.deriveKey(params('wrap'), hk,
|
|
301
|
+
{ name: 'AES-GCM', length: 256 }, false, ['wrapKey', 'unwrapKey']);
|
|
302
|
+
const verifier = b64(await crypto.subtle.deriveBits(params('verify'), hk, 256));
|
|
303
|
+
return { wrapKey, verifier };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// --- content encryption -----------------------------------------------------
|
|
307
|
+
export async function encryptBytes(ck, bytes) {
|
|
308
|
+
const iv = crypto.getRandomValues(new Uint8Array(12)); // unique per encryption (§5.6)
|
|
309
|
+
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, ck, bytes);
|
|
310
|
+
return { iv: b64(iv), ct: new Uint8Array(ct) };
|
|
311
|
+
}
|
|
312
|
+
export async function decryptBytes(ck, ivB64, ctBytes) {
|
|
313
|
+
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: unb64(ivB64) }, ck, ctBytes);
|
|
314
|
+
return new Uint8Array(pt);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function randomSalt() {
|
|
318
|
+
return b64(crypto.getRandomValues(new Uint8Array(16)));
|
|
319
|
+
}
|