Consolidate Persimmons source

This commit is contained in:
matcha committed 2026-10-04 02:45:34 +00:00
1 parent 60616291f4
commit 52a141868d
1 file changed
+84
+84
View File
@@ -0,0 +1,84 @@
// 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; }
}