ldrouter 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.13.1] - 2026-09-11
8
+
9
+ ### Fixed
10
+
11
+ - **Update notification**: force a fresh npm registry check when the admin UI loads, so the update button is not hidden by the 15-minute cache.
12
+
13
+ ## [1.13.0] - 2026-09-11
14
+
15
+ ### Added
16
+
17
+ - **Passphrase-protected full backups**: backups now include administrator state and a master-key envelope protected by a user-entered six-digit passphrase; `/setup` can import them without recreating the admin account.
18
+
7
19
  ## [1.12.0] - 2026-09-05
8
20
 
9
21
  ### Added
@@ -0,0 +1,31 @@
1
+ import crypto from 'node:crypto';
2
+ function passphraseKey(passphrase, salt) {
3
+ if (!/^\d{6}$/.test(passphrase))
4
+ throw new Error('Backup passphrase must contain exactly six digits');
5
+ return crypto.scryptSync(passphrase, salt, 32, { N: 16_384, r: 8, p: 1 });
6
+ }
7
+ export function encryptBackupMasterKey(masterKey, passphrase) {
8
+ const salt = crypto.randomBytes(16);
9
+ const nonce = crypto.randomBytes(12);
10
+ const cipher = crypto.createCipheriv('aes-256-gcm', passphraseKey(passphrase, salt), nonce);
11
+ const ciphertext = Buffer.concat([cipher.update(masterKey), cipher.final()]);
12
+ return {
13
+ algorithm: 'scrypt-aes-256-gcm',
14
+ salt: salt.toString('base64'),
15
+ nonce: nonce.toString('base64'),
16
+ ciphertext: ciphertext.toString('base64'),
17
+ tag: cipher.getAuthTag().toString('base64'),
18
+ };
19
+ }
20
+ export function decryptBackupMasterKey(envelope, passphrase) {
21
+ if (envelope.algorithm !== 'scrypt-aes-256-gcm')
22
+ throw new Error('Unsupported backup key encryption');
23
+ try {
24
+ const decipher = crypto.createDecipheriv('aes-256-gcm', passphraseKey(passphrase, Buffer.from(envelope.salt, 'base64')), Buffer.from(envelope.nonce, 'base64'));
25
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
26
+ return Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64')), decipher.final()]);
27
+ }
28
+ catch {
29
+ throw new Error('Invalid backup passphrase');
30
+ }
31
+ }
@@ -40,6 +40,9 @@ export function getMasterKey() {
40
40
  export function isMasterKeyConfigured() {
41
41
  return Boolean(loadConfig().masterKey);
42
42
  }
43
+ export function resetMasterKeyCache() {
44
+ cachedKey = null;
45
+ }
43
46
  export function masterKeyVersion() {
44
47
  return cachedKeyVersion;
45
48
  }
@@ -7,11 +7,13 @@ import { getDb, closeDb, openDb, schema } from '../../db/index.js';
7
7
  import { requireAdminAuth } from '../../auth/middleware.js';
8
8
  import { sha256Hex } from '../../auth/ids.js';
9
9
  import { recordAudit } from '../../db/repositories/audit.js';
10
- import { loadConfig } from '../../config/index.js';
10
+ import { loadConfig, setConfigMasterKey } from '../../config/index.js';
11
11
  import { getSettings } from '../../db/repositories/settings.js';
12
12
  import { GatewayError } from '../../errors.js';
13
13
  import { getAppVersion } from '../../version.js';
14
14
  import { eq, sql } from 'drizzle-orm';
15
+ import { decryptBackupMasterKey, encryptBackupMasterKey } from '../../auth/backup-crypto.js';
16
+ import { parseMasterKey, resetMasterKeyCache } from '../../auth/crypto.js';
15
17
  const BACKUP_VERSION = 1;
16
18
  /** Reopen the in-process SQLite connection on the (possibly just-replaced)
17
19
  * database file. The old connection must already be closed: a hot restore
@@ -52,8 +54,16 @@ function assertDatabaseUsable(expectedSchemaVersion) {
52
54
  }
53
55
  }
54
56
  export async function registerBackupRoutes(app) {
55
- app.addHook('preHandler', requireAdminAuth);
57
+ app.addHook('preHandler', async (req, reply) => {
58
+ if (req.url === '/api/admin/backup/restore' && !getSettings().setupComplete)
59
+ return;
60
+ return requireAdminAuth(req, reply);
61
+ });
56
62
  app.post('/api/admin/backup/create', async (req, reply) => {
63
+ const passphrase = req.body?.passphrase ?? '';
64
+ if (!/^\d{6}$/.test(passphrase)) {
65
+ throw new GatewayError('invalid_request_error', 'Backup passphrase must contain exactly six digits', { status: 400 });
66
+ }
57
67
  const cfg = loadConfig();
58
68
  const temp = path.join(cfg.dataDir, `.backup-${Date.now()}.sqlite`);
59
69
  const Database = (await import('better-sqlite3')).default;
@@ -69,6 +79,7 @@ export async function registerBackupRoutes(app) {
69
79
  const compressed = zlib.gzipSync(buf, { level: 6 });
70
80
  const checksum = crypto.createHash('sha256').update(compressed).digest('hex');
71
81
  const settings = getSettings();
82
+ const masterKey = parseMasterKey(cfg.masterKey ?? fs.readFileSync(path.join(cfg.dataDir, 'master.key'), 'utf8').trim());
72
83
  const envelope = {
73
84
  format: 'latedev-backup',
74
85
  version: BACKUP_VERSION,
@@ -78,6 +89,7 @@ export async function registerBackupRoutes(app) {
78
89
  createdAt: new Date().toISOString(),
79
90
  payload: compressed.toString('base64'),
80
91
  checksum,
92
+ keyEnvelope: encryptBackupMasterKey(masterKey, passphrase),
81
93
  };
82
94
  const envBuf = Buffer.from(JSON.stringify(envelope), 'utf8');
83
95
  const fileName = `latedev-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.ldb.json`;
@@ -88,12 +100,17 @@ export async function registerBackupRoutes(app) {
88
100
  });
89
101
  app.post('/api/admin/backup/restore', async (req, reply) => {
90
102
  const cfg = loadConfig();
91
- // Expect raw JSON envelope in body
103
+ const incoming = req.body;
104
+ const wrapped = Boolean(incoming && 'backup' in incoming);
105
+ const wrappedBody = incoming;
106
+ const passphrase = wrapped ? wrappedBody.passphrase : undefined;
92
107
  let envelope;
93
108
  try {
94
- envelope = req.body;
109
+ envelope = (wrapped ? wrappedBody.backup : incoming);
95
110
  if (!envelope || envelope.format !== 'latedev-backup')
96
111
  throw new Error('not a backup envelope');
112
+ if (envelope.keyEnvelope && !/^\d{6}$/.test(passphrase ?? ''))
113
+ throw new Error('backup passphrase required');
97
114
  }
98
115
  catch {
99
116
  recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'invalid_envelope' } });
@@ -122,6 +139,17 @@ export async function registerBackupRoutes(app) {
122
139
  recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'not_sqlite' } });
123
140
  throw new GatewayError('invalid_request_error', 'Backup does not contain a valid SQLite database', { status: 400 });
124
141
  }
142
+ let restoredMasterKey;
143
+ if (envelope.keyEnvelope) {
144
+ try {
145
+ restoredMasterKey = decryptBackupMasterKey(envelope.keyEnvelope, passphrase);
146
+ parseMasterKey(restoredMasterKey.toString('base64'));
147
+ }
148
+ catch {
149
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'invalid_backup_passphrase' } });
150
+ throw new GatewayError('invalid_request_error', 'Invalid backup passphrase', { status: 400 });
151
+ }
152
+ }
125
153
  const liveDb = cfg.dbFile;
126
154
  // Snapshot current DB before restore (kept for manual rollback).
127
155
  const snapshot = path.join(cfg.dataDir, `pre-restore-${Date.now()}.sqlite`);
@@ -184,6 +212,12 @@ export async function registerBackupRoutes(app) {
184
212
  recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'validation_failed', err: e.message } });
185
213
  throw new GatewayError('gateway_error', `Restore failed: ${e.message}`, { status: 500 });
186
214
  }
215
+ if (restoredMasterKey) {
216
+ const restoredKey = restoredMasterKey.toString('base64');
217
+ fs.writeFileSync(path.join(cfg.dataDir, 'master.key'), restoredKey, { mode: 0o600, encoding: 'utf8' });
218
+ setConfigMasterKey(restoredKey);
219
+ resetMasterKeyCache();
220
+ }
187
221
  // The swap invalidates the previous admin session (its row lived in the
188
222
  // old database). Re-create the current session in the restored database so
189
223
  // the admin stays logged in across the hot restore.