Files

154 lines
5.9 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// 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 });