Consolidate Persimmons source
This commit is contained in:
1 parent
60616291f4
commit
52a141868d
1 file changed
+84
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user