154 lines
5.9 KiB
JavaScript
154 lines
5.9 KiB
JavaScript
// Generated, do not edit. sha256:9577dee0ed20ca611f2d96db3a931311a0d672a76d95d80dda4d00726259c4be
|
|||
|
|
import { randomBytes, scrypt as scryptCb, createCipheriv, createDecipheriv, timingSafeEqual } from 'node:crypto';
|
||
|
|
import { promisify } from 'node:util';
|
||
|
|
import { readFile, writeFile, rm, mkdtemp, rename, access } from 'node:fs/promises';
|
||
|
|
import { tmpdir } from 'node:os';
|
||
|
|
import { join } from 'node:path';
|
||
|
|
import { secretsFile } from './keys.mjs';
|
||
|
|
import { run } from './db/conn.mjs';
|
||
|
|
import { initDatabase, dbFile } from './db.js';
|
||
|
|
import { keurBestand, wisselDatabaseBestand } from './db-restore.mjs';
|
||
|
|
import { fail } from '../shared/app-error.js';
|
||
|
|
|
||
|
|
const scrypt = promisify(scryptCb);
|
||
|
|
|
||
|
|
const MAGIC = Buffer.from('ASSETAPP-BACKUP', 'latin1');
|
||
|
|
const VERSIE = 1;
|
||
|
|
const SALT_LEN = 16;
|
||
|
|
const IV_LEN = 12;
|
||
|
|
const TAG_LEN = 16;
|
||
|
|
const KOP_LEN = MAGIC.length + 1 + SALT_LEN + IV_LEN + TAG_LEN;
|
||
|
|
const SCRYPT = Object.freeze({ N: 2 ** 15, r: 8, p: 1, maxmem: 96 * 1024 * 1024 });
|
||
|
|
|
||
|
|
const sleutelUit = (wachtwoord, salt) => scrypt(wachtwoord, salt, 32, SCRYPT);
|
||
|
|
|
||
|
|
function eisWachtwoord(wachtwoord) {
|
||
|
|
const w = String(wachtwoord ?? '');
|
||
|
|
if (w.length < 8) fail('dbfile.passTooShort', null, 'Choose a password of at least 8 characters for the backup.');
|
||
|
|
return w;
|
||
|
|
}
|
||
|
|
|
||
|
|
const bestaat = async pad => { try { await access(pad); return true; } catch { return false; } };
|
||
|
|
|
||
|
|
async function databaseKopie() {
|
||
|
|
const dir = await mkdtemp(join(tmpdir(), 'assetapp-backup-'));
|
||
|
|
const uit = join(dir, 'db.sqlite');
|
||
|
|
try {
|
||
|
|
run(`VACUUM INTO '${uit.replace(/'/g, "''")}'`, []);
|
||
|
|
return { bytes: await readFile(uit), opruimen: () => rm(dir, { recursive: true, force: true }) };
|
||
|
|
} catch (e) {
|
||
|
|
await rm(dir, { recursive: true, force: true });
|
||
|
|
throw e;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function createBackup(wachtwoord) {
|
||
|
|
const w = eisWachtwoord(wachtwoord);
|
||
|
|
const kopie = await databaseKopie();
|
||
|
|
try {
|
||
|
|
const geheimenPad = secretsFile();
|
||
|
|
const geheimen = await bestaat(geheimenPad) ? await readFile(geheimenPad) : Buffer.from('{}', 'utf-8');
|
||
|
|
const manifest = Buffer.from(JSON.stringify({
|
||
|
|
v: 1, gemaakt: new Date().toISOString(), db: kopie.bytes.length, secrets: geheimen.length,
|
||
|
|
}) + '\n', 'utf-8');
|
||
|
|
const plat = Buffer.concat([manifest, kopie.bytes, geheimen]);
|
||
|
|
|
||
|
|
const salt = randomBytes(SALT_LEN);
|
||
|
|
const iv = randomBytes(IV_LEN);
|
||
|
|
const c = createCipheriv('aes-256-gcm', await sleutelUit(w, salt), iv);
|
||
|
|
const cijfer = Buffer.concat([c.update(plat), c.final()]);
|
||
|
|
const bytes = Buffer.concat([MAGIC, Buffer.of(VERSIE), salt, iv, c.getAuthTag(), cijfer]);
|
||
|
|
return { bytes, db: kopie.bytes.length, secrets: geheimen.length };
|
||
|
|
} finally {
|
||
|
|
await kopie.opruimen();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function openBackup(bytes, wachtwoord) {
|
||
|
|
if (!bytes || bytes.length < KOP_LEN) fail('dbfile.notABackup', null, 'This file is not an AssetApp backup (too short).');
|
||
|
|
if (!bytes.subarray(0, MAGIC.length).equals(MAGIC)) {
|
||
|
|
fail('dbfile.notABackupUseRestore', null, 'This file is not an AssetApp backup; restore a loose .sqlite with "Terugzetten".');
|
||
|
|
}
|
||
|
|
const versie = bytes[MAGIC.length];
|
||
|
|
if (versie !== VERSIE) fail('dbfile.unknownBackupVersion', { versie, kent: VERSIE }, `Unknown backup version (${versie}); this app knows version ${VERSIE}.`);
|
||
|
|
|
||
|
|
let p = MAGIC.length + 1;
|
||
|
|
const salt = bytes.subarray(p, p += SALT_LEN);
|
||
|
|
const iv = bytes.subarray(p, p += IV_LEN);
|
||
|
|
const tag = bytes.subarray(p, p += TAG_LEN);
|
||
|
|
const cijfer = bytes.subarray(p);
|
||
|
|
|
||
|
|
const d = createDecipheriv('aes-256-gcm', await sleutelUit(String(wachtwoord ?? ''), salt), iv);
|
||
|
|
d.setAuthTag(tag);
|
||
|
|
let plat;
|
||
|
|
try {
|
||
|
|
plat = Buffer.concat([d.update(cijfer), d.final()]);
|
||
|
|
} catch {
|
||
|
|
fail('dbfile.decryptFailed', null, 'Decryption failed: wrong password, or the file is damaged.');
|
||
|
|
}
|
||
|
|
|
||
|
|
const nl = plat.indexOf(0x0a);
|
||
|
|
if (nl < 0) fail('dbfile.noManifest', null, 'Backup damaged: no manifest found.');
|
||
|
|
let manifest;
|
||
|
|
try { manifest = JSON.parse(plat.subarray(0, nl).toString('utf-8')); }
|
||
|
|
catch { fail('dbfile.manifestUnreadable', null, 'Backup damaged: the manifest is unreadable.'); }
|
||
|
|
const dbLen = Number(manifest.db), secretsLen = Number(manifest.secrets);
|
||
|
|
if (!Number.isInteger(dbLen) || !Number.isInteger(secretsLen)
|
||
|
|
|| nl + 1 + dbLen + secretsLen !== plat.length) {
|
||
|
|
fail('dbfile.manifestLengths', null, 'Backup damaged: the lengths in the manifest do not match the content.');
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
manifest,
|
||
|
|
db: plat.subarray(nl + 1, nl + 1 + dbLen),
|
||
|
|
secrets: plat.subarray(nl + 1 + dbLen),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function restoreBackup(bytes, wachtwoord) {
|
||
|
|
const { manifest, db, secrets } = await openBackup(bytes, wachtwoord);
|
||
|
|
|
||
|
|
const doel = dbFile();
|
||
|
|
const geheimenPad = secretsFile();
|
||
|
|
const incoming = `${doel}.new`;
|
||
|
|
await writeFile(incoming, db, { mode: 0o600 });
|
||
|
|
let keuring;
|
||
|
|
try {
|
||
|
|
keuring = keurBestand(incoming);
|
||
|
|
} catch (e) {
|
||
|
|
await rm(incoming, { force: true });
|
||
|
|
throw e;
|
||
|
|
}
|
||
|
|
let aantalGeheimen;
|
||
|
|
try { aantalGeheimen = Object.keys(JSON.parse(secrets.toString('utf-8'))).length; }
|
||
|
|
catch {
|
||
|
|
await rm(incoming, { force: true });
|
||
|
|
fail('dbfile.secretsUnreadable', null, 'Backup damaged: the secrets file is not readable JSON.');
|
||
|
|
}
|
||
|
|
|
||
|
|
const previousDb = `${doel}.previous`;
|
||
|
|
const previousSecrets = `${geheimenPad}.previous`;
|
||
|
|
await wisselDatabaseBestand(incoming, previousDb, async () => {
|
||
|
|
if (await bestaat(geheimenPad)) {
|
||
|
|
await rm(previousSecrets, { force: true });
|
||
|
|
await rename(geheimenPad, previousSecrets);
|
||
|
|
}
|
||
|
|
await writeFile(geheimenPad, secrets, { mode: 0o600 });
|
||
|
|
});
|
||
|
|
|
||
|
|
const regel = initDatabase();
|
||
|
|
return {
|
||
|
|
gemaakt: manifest.gemaakt ?? null,
|
||
|
|
versie: keuring.versie,
|
||
|
|
wezen: keuring.wezen,
|
||
|
|
geheimen: aantalGeheimen,
|
||
|
|
previousDb,
|
||
|
|
previousSecrets,
|
||
|
|
regel,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function isBackupBestand(bytes) {
|
||
|
|
return !!bytes && bytes.length >= MAGIC.length && bytes.subarray(0, MAGIC.length).equals(MAGIC);
|
||
|
|
}
|
||
|
|
|
||
|
|
export const FORMAAT = Object.freeze({ MAGIC, VERSIE, SALT_LEN, IV_LEN, TAG_LEN, KOP_LEN, SCRYPT });
|