Archived
85 lines
4.7 KiB
JavaScript
85 lines
4.7 KiB
JavaScript
// 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; }
|
||
}
|