176 lines
6.1 KiB
JavaScript
176 lines
6.1 KiB
JavaScript
/**
|
|
* crypto.js — Zero-knowledge cryptography layer
|
|
*
|
|
* All encryption/decryption runs in the browser using the Web Crypto API.
|
|
* The vault key (AES-256-GCM) is derived from the master password client-side
|
|
* and is NEVER sent to the server. The server only stores encrypted blobs.
|
|
*
|
|
* Key derivation chain:
|
|
* authHash = PBKDF2(masterPassword, email, 100_000 iter, SHA-256) → sent to server for auth
|
|
* vaultKey = PBKDF2(masterPassword, enc_key_salt, 600_000 iter, SHA-256) → stays in memory only
|
|
*/
|
|
|
|
const Crypto = (() => {
|
|
const subtle = window.crypto.subtle;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
function strToBytes(str) {
|
|
return new TextEncoder().encode(str);
|
|
}
|
|
|
|
function base64ToBytes(b64) {
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
function bytesToBase64(bytes) {
|
|
let bin = '';
|
|
bytes.forEach(b => (bin += String.fromCharCode(b)));
|
|
return btoa(bin);
|
|
}
|
|
|
|
// ── PBKDF2 key import ────────────────────────────────────────────────────
|
|
|
|
async function importPbkdf2Key(masterPassword) {
|
|
return subtle.importKey(
|
|
'raw',
|
|
strToBytes(masterPassword),
|
|
'PBKDF2',
|
|
false, // not extractable
|
|
['deriveBits', 'deriveKey']
|
|
);
|
|
}
|
|
|
|
// ── Auth hash (sent to server) ───────────────────────────────────────────
|
|
|
|
/**
|
|
* Derive an auth token from the master password.
|
|
* Used ONLY for server-side authentication — never for encryption.
|
|
* Returns a base64 string safe to POST to /api/auth/login|register.
|
|
*/
|
|
async function deriveAuthHash(masterPassword, email) {
|
|
const baseKey = await importPbkdf2Key(masterPassword);
|
|
const bits = await subtle.deriveBits(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt: strToBytes(email.toLowerCase()),
|
|
iterations: 100_000,
|
|
hash: 'SHA-256',
|
|
},
|
|
baseKey,
|
|
256
|
|
);
|
|
return bytesToBase64(new Uint8Array(bits));
|
|
}
|
|
|
|
// ── Vault key (stays in memory) ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Derive the AES-256-GCM vault key from the master password.
|
|
* enc_key_salt is the base64-encoded 16-byte salt returned by the server on login.
|
|
* The returned CryptoKey is marked extractable:false — raw bytes cannot be read back.
|
|
*/
|
|
async function deriveVaultKey(masterPassword, enc_key_salt) {
|
|
const baseKey = await importPbkdf2Key(masterPassword);
|
|
return subtle.deriveKey(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt: base64ToBytes(enc_key_salt),
|
|
iterations: 600_000,
|
|
hash: 'SHA-256',
|
|
},
|
|
baseKey,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false, // extractable:false — key material cannot be exported
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
// ── Encrypt / Decrypt ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Encrypt a plain JS object with the vault key.
|
|
* A fresh random 12-byte IV is generated for every call (required for GCM).
|
|
* Returns { enc_data: base64, iv: base64 }
|
|
*/
|
|
async function encryptItem(vaultKey, plaintextObject) {
|
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
|
const plaintext = strToBytes(JSON.stringify(plaintextObject));
|
|
const ciphertext = await subtle.encrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
vaultKey,
|
|
plaintext
|
|
);
|
|
return {
|
|
enc_data: bytesToBase64(new Uint8Array(ciphertext)),
|
|
iv: bytesToBase64(iv),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decrypt an encrypted vault item back to a JS object.
|
|
* enc_data and iv must be the base64 strings stored on the server.
|
|
*/
|
|
async function decryptItem(vaultKey, enc_data, iv) {
|
|
const plaintext = await subtle.decrypt(
|
|
{ name: 'AES-GCM', iv: base64ToBytes(iv) },
|
|
vaultKey,
|
|
base64ToBytes(enc_data)
|
|
);
|
|
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
}
|
|
|
|
/**
|
|
* Generate a cryptographically random base64 string (for enc_key_salt).
|
|
* byteLength defaults to 16 (128-bit salt).
|
|
*/
|
|
function generateSalt(byteLength = 16) {
|
|
return bytesToBase64(window.crypto.getRandomValues(new Uint8Array(byteLength)));
|
|
}
|
|
|
|
// ── Encrypt / Decrypt name string ───────────────────────────────────────
|
|
|
|
/**
|
|
* Encrypt a plain string (the item name) with the vault key.
|
|
* Uses a fresh random IV each call — same scheme as encryptItem.
|
|
* Returns { enc_name: base64, iv_name: base64 }
|
|
*/
|
|
async function encryptName(vaultKey, nameStr) {
|
|
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
|
const plaintext = strToBytes(nameStr);
|
|
const ciphertext = await subtle.encrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
vaultKey,
|
|
plaintext
|
|
);
|
|
return {
|
|
enc_name: bytesToBase64(new Uint8Array(ciphertext)),
|
|
iv_name: bytesToBase64(iv),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decrypt an enc_name blob back to a plain string.
|
|
* Returns null if decryption fails (e.g. legacy item without enc_name).
|
|
*/
|
|
async function decryptName(vaultKey, enc_name, iv_name) {
|
|
try {
|
|
const plaintext = await subtle.decrypt(
|
|
{ name: 'AES-GCM', iv: base64ToBytes(iv_name) },
|
|
vaultKey,
|
|
base64ToBytes(enc_name)
|
|
);
|
|
return new TextDecoder().decode(plaintext);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Public API ───────────────────────────────────────────────────────────
|
|
|
|
return { deriveAuthHash, deriveVaultKey, encryptItem, decryptItem, encryptName, decryptName, generateSalt };
|
|
})();
|