Files
PassKeeper/app/static/js/crypto.js
T

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 };
})();