Files

135 lines
4.8 KiB
JavaScript

/**
* extension/shared/crypto.js — Zero-knowledge cryptography for the extension.
*
* Mirrors the web app's crypto.js but:
* - Uses `crypto.subtle` (no `window.`) so it works in both popup and service worker.
* - deriveVaultKey uses extractable:true so the key can be serialised to
* chrome.storage.session (exportVaultKey / importVaultKey).
*/
const ExtCrypto = (() => {
const subtle = crypto.subtle;
// ── Helpers ─────────────────────────────────────────────────────────────────
function strToBytes(str) { return new TextEncoder().encode(str); }
function base64ToBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToBase64(bytes) {
let bin = '';
bytes.forEach(b => (bin += String.fromCharCode(b)));
return btoa(bin);
}
// ── PBKDF2 base key ─────────────────────────────────────────────────────────
async function importPbkdf2Key(password) {
return subtle.importKey('raw', strToBytes(password), 'PBKDF2', false, ['deriveBits', 'deriveKey']);
}
// ── Auth hash (sent to server for login/register) ───────────────────────────
async function deriveAuthHash(password, email) {
const baseKey = await importPbkdf2Key(password);
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 (AES-256-GCM, extractable for session storage) ────────────────
async function deriveVaultKey(password, enc_key_salt) {
const baseKey = await importPbkdf2Key(password);
return subtle.deriveKey(
{ name: 'PBKDF2', salt: base64ToBytes(enc_key_salt), iterations: 600_000, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
true, // extractable — needed to serialise into chrome.storage.session
['encrypt', 'decrypt']
);
}
/** Serialise a CryptoKey to a JSON string for chrome.storage.session. */
async function exportVaultKey(vaultKey) {
const jwk = await subtle.exportKey('jwk', vaultKey);
return JSON.stringify(jwk);
}
/** Deserialise a CryptoKey from chrome.storage.session. */
async function importVaultKey(jwkStr) {
const jwk = JSON.parse(jwkStr);
return subtle.importKey('jwk', jwk, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
}
// ── Encrypt / Decrypt ────────────────────────────────────────────────────────
async function encryptItem(vaultKey, plainObj) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await subtle.encrypt(
{ name: 'AES-GCM', iv },
vaultKey,
strToBytes(JSON.stringify(plainObj))
);
return { enc_data: bytesToBase64(new Uint8Array(ct)), iv: bytesToBase64(iv) };
}
async function decryptItem(vaultKey, enc_data, iv) {
const pt = await subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(iv) },
vaultKey,
base64ToBytes(enc_data)
);
return JSON.parse(new TextDecoder().decode(pt));
}
function generateSalt(byteLength = 16) {
return bytesToBase64(crypto.getRandomValues(new Uint8Array(byteLength)));
}
/**
* Encrypt a plain name string with the vault key.
* Returns { enc_name: base64, iv_name: base64 }
*/
async function encryptName(vaultKey, nameStr) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(nameStr);
const ciphertext = await crypto.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 on failure (legacy item without enc_name).
*/
async function decryptName(vaultKey, enc_name, iv_name) {
try {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBytes(iv_name) },
vaultKey,
base64ToBytes(enc_name)
);
return new TextDecoder().decode(plaintext);
} catch {
return null;
}
}
return { deriveAuthHash, deriveVaultKey, exportVaultKey, importVaultKey, encryptItem, decryptItem, encryptName, decryptName, generateSalt };
})();