// All secret cryptography stays in the extension. Server stores opaque blobs. const utf8 = new TextEncoder(); const decode = new TextDecoder('utf-8', {fatal: true}); const b64 = value => btoa(String.fromCharCode(...new Uint8Array(value))); const unb64 = value => Uint8Array.from(atob(value), c => c.charCodeAt(0)); const random = size => crypto.getRandomValues(new Uint8Array(size)); const ITERATIONS = 600000; export function isHttpsOrigin(value) { try { const url = new URL(value); return url.protocol === 'https:' && url.origin === value && !url.username && !url.password; } catch { return false; } } const wrapAAD = owner => utf8.encode(JSON.stringify(['persimmons-wrap', 1, owner])); const itemAAD = (owner, item) => utf8.encode(JSON.stringify(['persimmons-item', item.v, owner, item.id, item.origin])); async function wrappingKey(passphrase, salt) { let bytes = utf8.encode(passphrase); try { const material = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, ['deriveKey']); return await crypto.subtle.deriveKey({name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS}, material, {name: 'AES-GCM', length: 256}, false, ['wrapKey', 'unwrapKey']); } finally { bytes.fill(0); bytes = null; } } export async function initialize(passphrase, owner) { if (passphrase.length < 12 || passphrase.length > 1024) throw new Error('Use a passphrase of 12–1024 characters.'); const salt = random(16), iv = random(12); let generated = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']); let wrapping = await wrappingKey(passphrase, salt); try { const wrapped = await crypto.subtle.wrapKey('raw', generated, wrapping, {name: 'AES-GCM', iv, additionalData: wrapAAD(owner), tagLength: 128}); return {v: 1, kdf: 'PBKDF2', hash: 'SHA-256', iterations: ITERATIONS, salt: b64(salt), iv: b64(iv), wrappedKey: b64(wrapped)}; } finally { generated = null; wrapping = null; passphrase = ''; } } export async function unlock(passphrase, owner, record) { let wrapping; try { if (!record || record.v !== 1 || record.kdf !== 'PBKDF2' || record.hash !== 'SHA-256' || record.iterations !== ITERATIONS || unb64(record.salt).length !== 16 || unb64(record.iv).length !== 12 || unb64(record.wrappedKey).length !== 48 || passphrase.length > 1024) throw new Error(); wrapping = await wrappingKey(passphrase, unb64(record.salt)); return await crypto.subtle.unwrapKey('raw', unb64(record.wrappedKey), wrapping, {name: 'AES-GCM', iv: unb64(record.iv), additionalData: wrapAAD(owner), tagLength: 128}, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']); } catch { throw new Error('Unlock failed. Check passphrase and local vault data.'); } finally { wrapping = null; passphrase = ''; } } export function safeMetadata(item) { if (!item || item.v !== 1 || !/^[0-9a-f]{32}$/.test(item.id) || !isHttpsOrigin(item.origin)) throw new Error('Invalid encrypted item.'); if (item.username !== undefined && (typeof item.username !== 'string' || !item.username || item.username.length > 512)) throw new Error('Invalid encrypted item.'); return {v: 1, id: item.id, origin: item.origin, ...(item.username ? {username: item.username} : {})}; } export async function encryptItem(key, owner, origin, account, id = crypto.randomUUID().replaceAll('-', '')) { const item = safeMetadata({v: 1, id, origin}); if (typeof account.username !== 'string' || typeof account.password !== 'string' || !account.username || !account.password) throw new Error('Enter username and password.'); let plain = utf8.encode(JSON.stringify({username: account.username, password: account.password})); try { if (plain.length > 12000) throw new Error('Account is too large.'); const iv = random(12); const ciphertext = await crypto.subtle.encrypt({name: 'AES-GCM', iv, additionalData: itemAAD(owner, item), tagLength: 128}, key, plain); return {...item, username: account.username, iv: b64(iv), ciphertext: b64(ciphertext)}; } finally { plain.fill(0); plain = null; } } export async function decryptItem(key, owner, item) { let plain; try { safeMetadata(item); if (unb64(item.iv).length !== 12 || item.ciphertext.length > 22000) throw new Error(); plain = new Uint8Array(await crypto.subtle.decrypt({name: 'AES-GCM', iv: unb64(item.iv), additionalData: itemAAD(owner, item), tagLength: 128}, key, unb64(item.ciphertext))); const account = JSON.parse(decode.decode(plain)); if (typeof account.username !== 'string' || typeof account.password !== 'string' || Object.keys(account).sort().join() !== 'password,username') throw new Error(); return account; } catch { throw new Error('Encrypted item failed authentication.'); } finally { plain?.fill(0); plain = null; } }