135 lines
4.8 KiB
JavaScript
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 };
|
|
})();
|