Files
app/extension/human-crypto.mjs
T
2026-10-04 02:45:34 +00:00

85 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; }
}