@blamejs/core 0.7.18 → 0.7.19
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 +425 -423
- package/README.md +150 -150
- package/bin/blamejs.js +0 -0
- package/index.js +310 -308
- package/lib/api-key.js +660 -660
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +337 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +628 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -275
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -420
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -293
- package/lib/framework-schema.js +717 -717
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -369
- package/lib/mail.js +981 -981
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +1170 -1170
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +82 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -111
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +535 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -340
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/restore-bundle.js
CHANGED
|
@@ -1,237 +1,237 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* restore-bundle — extract an encrypted backup bundle to a staging dir.
|
|
4
|
-
*
|
|
5
|
-
* The mirror of backup-bundle. Reads manifest.json from a bundle
|
|
6
|
-
* directory, decrypts each per-file blob via backup-crypto, verifies
|
|
7
|
-
* each plaintext's sha3-512 checksum matches the manifest, and writes
|
|
8
|
-
* the recovered files to a staging directory the caller then atomically
|
|
9
|
-
* swaps into place. The bundle directory itself is read-only throughout.
|
|
10
|
-
*
|
|
11
|
-
* var r = await b.restoreBundle.extract({
|
|
12
|
-
* bundleDir: "./backups/2026-04-27.bundle",
|
|
13
|
-
* stagingDir: "./data.staging", // must NOT exist
|
|
14
|
-
* passphrase: Buffer.from("operator passphrase"),
|
|
15
|
-
* filter: function (entry) { return true; }, // optional
|
|
16
|
-
* progressCallback: function (event) { ... },
|
|
17
|
-
* });
|
|
18
|
-
* // → { manifest, vaultKeyJson, fileCount, totalBytes,
|
|
19
|
-
* // stagingDir, durationMs }
|
|
20
|
-
*
|
|
21
|
-
* vaultKeyJson is the decrypted vault keypair JSON the bundle carried
|
|
22
|
-
* in manifest.vaultKeyEnc. The caller decides what to do with it:
|
|
23
|
-
* write to stagingDir/vault.key for a fresh framework boot, hand to
|
|
24
|
-
* vault.init for an in-process load, etc. — restore-bundle's job ends
|
|
25
|
-
* at recovery; vault-key placement is operator policy.
|
|
26
|
-
*
|
|
27
|
-
* filter: optional predicate that lets a caller pull a subset (only
|
|
28
|
-
* the DB, only the TLS keys, etc.). The vault key is always recovered
|
|
29
|
-
* regardless of filter so the operator can read sealed values from a
|
|
30
|
-
* partial restore.
|
|
31
|
-
*
|
|
32
|
-
* Defense:
|
|
33
|
-
* - Wrong passphrase → AEAD tag check fails on first blob →
|
|
34
|
-
* restore-bundle/decrypt-failed (no plaintext leaked, no staging
|
|
35
|
-
* left behind)
|
|
36
|
-
* - Tampered blob (single byte flip in ciphertext) → same path
|
|
37
|
-
* - encryptedSize mismatch → restore-bundle/size-mismatch (cheap
|
|
38
|
-
* pre-decrypt check)
|
|
39
|
-
* - Plaintext sha3-512 != manifest.checksum → restore-bundle/
|
|
40
|
-
* checksum-mismatch (post-decrypt integrity guard)
|
|
41
|
-
* - Missing blob file → restore-bundle/missing-blob (manifest
|
|
42
|
-
* references a path the bundle dir doesn't have)
|
|
43
|
-
* - On any failure, the partially-built stagingDir is removed so a
|
|
44
|
-
* subsequent retry isn't blocked by a stale dir
|
|
45
|
-
*/
|
|
46
|
-
|
|
47
|
-
var fs = require("fs");
|
|
48
|
-
var path = require("path");
|
|
49
|
-
var atomicFile = require("./atomic-file");
|
|
50
|
-
var backupCrypto = require("./backup/crypto");
|
|
51
|
-
var backupManifest = require("./backup/manifest");
|
|
52
|
-
var validateOpts = require("./validate-opts");
|
|
53
|
-
var { defineClass } = require("./framework-error");
|
|
54
|
-
|
|
55
|
-
var RestoreBundleError = defineClass("RestoreBundleError", { alwaysPermanent: true });
|
|
56
|
-
|
|
57
|
-
function _emit(cb, ev) {
|
|
58
|
-
if (typeof cb === "function") {
|
|
59
|
-
try { cb(ev); } catch (_e) { /* progress-callback errors are non-fatal */ }
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function _cleanupStaging(stagingDir) {
|
|
64
|
-
// Best-effort recursive remove — if cleanup fails, surface that to
|
|
65
|
-
// the caller via stderr but never override the original error
|
|
66
|
-
// we're already throwing.
|
|
67
|
-
try { fs.rmSync(stagingDir, { recursive: true, force: true }); }
|
|
68
|
-
catch (_e) { /* best-effort */ }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function extract(opts) {
|
|
72
|
-
var t0 = Date.now();
|
|
73
|
-
opts = opts || {};
|
|
74
|
-
if (typeof opts.bundleDir !== "string" || !fs.existsSync(opts.bundleDir)) {
|
|
75
|
-
throw new RestoreBundleError("restore-bundle/no-bundle",
|
|
76
|
-
"extract: opts.bundleDir is required and must exist");
|
|
77
|
-
}
|
|
78
|
-
validateOpts.requireNonEmptyString(opts.stagingDir, "extract: opts.stagingDir", RestoreBundleError, "restore-bundle/no-staging");
|
|
79
|
-
if (fs.existsSync(opts.stagingDir)) {
|
|
80
|
-
throw new RestoreBundleError("restore-bundle/staging-exists",
|
|
81
|
-
"extract: stagingDir already exists: " + opts.stagingDir +
|
|
82
|
-
" (refusing to merge into existing directory — pick a fresh path)");
|
|
83
|
-
}
|
|
84
|
-
if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
|
|
85
|
-
throw new RestoreBundleError("restore-bundle/no-passphrase",
|
|
86
|
-
"extract: opts.passphrase is required (Buffer or string)");
|
|
87
|
-
}
|
|
88
|
-
var passphrase = opts.passphrase;
|
|
89
|
-
var bundleDir = opts.bundleDir;
|
|
90
|
-
var stagingDir = opts.stagingDir;
|
|
91
|
-
var filter = typeof opts.filter === "function" ? opts.filter : null;
|
|
92
|
-
var progress = opts.progressCallback;
|
|
93
|
-
|
|
94
|
-
// 1. Read + parse + validate manifest
|
|
95
|
-
_emit(progress, { phase: "read_manifest" });
|
|
96
|
-
var manifestPath = path.join(bundleDir, "manifest.json");
|
|
97
|
-
if (!fs.existsSync(manifestPath)) {
|
|
98
|
-
throw new RestoreBundleError("restore-bundle/missing-manifest",
|
|
99
|
-
"extract: bundleDir has no manifest.json — bundle is incomplete or not a blamejs backup");
|
|
100
|
-
}
|
|
101
|
-
var manifest;
|
|
102
|
-
try {
|
|
103
|
-
manifest = backupManifest.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
104
|
-
} catch (e) {
|
|
105
|
-
if (e && e.isBackupManifestError) throw e;
|
|
106
|
-
throw new RestoreBundleError("restore-bundle/bad-manifest",
|
|
107
|
-
"extract: manifest could not be parsed: " + ((e && e.message) || String(e)));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// 2. Recover the vault key (always, regardless of filter — the
|
|
111
|
-
// operator may need it to unseal post-restore even on partial
|
|
112
|
-
// restores)
|
|
113
|
-
_emit(progress, { phase: "unwrap_vault_key" });
|
|
114
|
-
var vaultKeyJson;
|
|
115
|
-
try {
|
|
116
|
-
var vkBuf = await backupCrypto.decryptWithPassphrase(
|
|
117
|
-
Buffer.from(manifest.vaultKeyEnc, "base64"),
|
|
118
|
-
passphrase,
|
|
119
|
-
manifest.vaultKeySalt);
|
|
120
|
-
vaultKeyJson = vkBuf.toString("utf8");
|
|
121
|
-
} catch (e) {
|
|
122
|
-
if (e && e.isBackupCryptoError && e.code === "backup-crypto/decrypt-failed") {
|
|
123
|
-
throw new RestoreBundleError("restore-bundle/decrypt-failed",
|
|
124
|
-
"extract: passphrase rejected (vault key did not decrypt). " +
|
|
125
|
-
"If you have multiple backup passphrases, double-check the one supplied.");
|
|
126
|
-
}
|
|
127
|
-
throw new RestoreBundleError("restore-bundle/vault-key-recovery-failed",
|
|
128
|
-
"extract: could not recover vault key from manifest: " + ((e && e.message) || String(e)));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
atomicFile.ensureDir(stagingDir);
|
|
132
|
-
|
|
133
|
-
// 3. Walk manifest.files; decrypt + verify + write each that passes filter
|
|
134
|
-
var fileCount = 0;
|
|
135
|
-
var totalBytes = 0;
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
for (var i = 0; i < manifest.files.length; i++) {
|
|
139
|
-
var entry = manifest.files[i];
|
|
140
|
-
if (filter && !filter(entry)) {
|
|
141
|
-
_emit(progress, { phase: "skip_filtered", relativePath: entry.relativePath });
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
var blobPath = path.join(bundleDir, entry.encryptedPath);
|
|
146
|
-
if (!fs.existsSync(blobPath)) {
|
|
147
|
-
throw new RestoreBundleError("restore-bundle/missing-blob",
|
|
148
|
-
"extract: manifest references '" + entry.encryptedPath +
|
|
149
|
-
"' but the bundle has no such file");
|
|
150
|
-
}
|
|
151
|
-
var blob = fs.readFileSync(blobPath);
|
|
152
|
-
if (blob.length !== entry.encryptedSize) {
|
|
153
|
-
throw new RestoreBundleError("restore-bundle/size-mismatch",
|
|
154
|
-
"extract: blob '" + entry.encryptedPath + "' has size " + blob.length +
|
|
155
|
-
" but manifest expected " + entry.encryptedSize);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
_emit(progress, {
|
|
159
|
-
phase: "decrypt", relativePath: entry.relativePath,
|
|
160
|
-
encryptedSize: entry.encryptedSize,
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
var plaintext;
|
|
164
|
-
try {
|
|
165
|
-
plaintext = await backupCrypto.decryptWithPassphrase(blob, passphrase, entry.salt);
|
|
166
|
-
} catch (e) {
|
|
167
|
-
if (e && e.isBackupCryptoError && e.code === "backup-crypto/decrypt-failed") {
|
|
168
|
-
throw new RestoreBundleError("restore-bundle/decrypt-failed",
|
|
169
|
-
"extract: blob '" + entry.encryptedPath + "' did not decrypt — " +
|
|
170
|
-
"passphrase rejected or ciphertext tampered");
|
|
171
|
-
}
|
|
172
|
-
throw e;
|
|
173
|
-
}
|
|
174
|
-
if (plaintext.length !== entry.size) {
|
|
175
|
-
throw new RestoreBundleError("restore-bundle/size-mismatch",
|
|
176
|
-
"extract: decrypted '" + entry.relativePath +
|
|
177
|
-
"' has " + plaintext.length + " bytes but manifest expected " + entry.size);
|
|
178
|
-
}
|
|
179
|
-
var actualChecksum = backupCrypto.checksum(plaintext);
|
|
180
|
-
if (actualChecksum !== entry.checksum) {
|
|
181
|
-
throw new RestoreBundleError("restore-bundle/checksum-mismatch",
|
|
182
|
-
"extract: decrypted '" + entry.relativePath + "' has checksum " + actualChecksum +
|
|
183
|
-
" but manifest declared " + entry.checksum +
|
|
184
|
-
" — bundle is corrupted or manifest tampered");
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
var destPath = path.join(stagingDir, entry.relativePath);
|
|
188
|
-
atomicFile.ensureDir(path.dirname(destPath));
|
|
189
|
-
atomicFile.writeSync(destPath, plaintext, { fileMode: 0o600 });
|
|
190
|
-
|
|
191
|
-
fileCount++;
|
|
192
|
-
totalBytes += plaintext.length;
|
|
193
|
-
}
|
|
194
|
-
} catch (e) {
|
|
195
|
-
_cleanupStaging(stagingDir);
|
|
196
|
-
throw e;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
var durationMs = Date.now() - t0;
|
|
200
|
-
_emit(progress, {
|
|
201
|
-
phase: "done",
|
|
202
|
-
fileCount: fileCount,
|
|
203
|
-
totalBytes: totalBytes,
|
|
204
|
-
durationMs: durationMs,
|
|
205
|
-
});
|
|
206
|
-
return {
|
|
207
|
-
manifest: manifest,
|
|
208
|
-
vaultKeyJson: vaultKeyJson,
|
|
209
|
-
fileCount: fileCount,
|
|
210
|
-
totalBytes: totalBytes,
|
|
211
|
-
stagingDir: stagingDir,
|
|
212
|
-
durationMs: durationMs,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Inspect a bundle without decrypting — read the manifest and return
|
|
217
|
-
// it. Useful for dashboards and pre-flight UI: list files, sizes,
|
|
218
|
-
// timestamps, kinds without prompting for the passphrase.
|
|
219
|
-
function inspect(opts) {
|
|
220
|
-
opts = opts || {};
|
|
221
|
-
if (typeof opts.bundleDir !== "string" || !fs.existsSync(opts.bundleDir)) {
|
|
222
|
-
throw new RestoreBundleError("restore-bundle/no-bundle",
|
|
223
|
-
"inspect: opts.bundleDir is required and must exist");
|
|
224
|
-
}
|
|
225
|
-
var manifestPath = path.join(opts.bundleDir, "manifest.json");
|
|
226
|
-
if (!fs.existsSync(manifestPath)) {
|
|
227
|
-
throw new RestoreBundleError("restore-bundle/missing-manifest",
|
|
228
|
-
"inspect: bundleDir has no manifest.json");
|
|
229
|
-
}
|
|
230
|
-
return backupManifest.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
module.exports = {
|
|
234
|
-
extract: extract,
|
|
235
|
-
inspect: inspect,
|
|
236
|
-
RestoreBundleError: RestoreBundleError,
|
|
237
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* restore-bundle — extract an encrypted backup bundle to a staging dir.
|
|
4
|
+
*
|
|
5
|
+
* The mirror of backup-bundle. Reads manifest.json from a bundle
|
|
6
|
+
* directory, decrypts each per-file blob via backup-crypto, verifies
|
|
7
|
+
* each plaintext's sha3-512 checksum matches the manifest, and writes
|
|
8
|
+
* the recovered files to a staging directory the caller then atomically
|
|
9
|
+
* swaps into place. The bundle directory itself is read-only throughout.
|
|
10
|
+
*
|
|
11
|
+
* var r = await b.restoreBundle.extract({
|
|
12
|
+
* bundleDir: "./backups/2026-04-27.bundle",
|
|
13
|
+
* stagingDir: "./data.staging", // must NOT exist
|
|
14
|
+
* passphrase: Buffer.from("operator passphrase"),
|
|
15
|
+
* filter: function (entry) { return true; }, // optional
|
|
16
|
+
* progressCallback: function (event) { ... },
|
|
17
|
+
* });
|
|
18
|
+
* // → { manifest, vaultKeyJson, fileCount, totalBytes,
|
|
19
|
+
* // stagingDir, durationMs }
|
|
20
|
+
*
|
|
21
|
+
* vaultKeyJson is the decrypted vault keypair JSON the bundle carried
|
|
22
|
+
* in manifest.vaultKeyEnc. The caller decides what to do with it:
|
|
23
|
+
* write to stagingDir/vault.key for a fresh framework boot, hand to
|
|
24
|
+
* vault.init for an in-process load, etc. — restore-bundle's job ends
|
|
25
|
+
* at recovery; vault-key placement is operator policy.
|
|
26
|
+
*
|
|
27
|
+
* filter: optional predicate that lets a caller pull a subset (only
|
|
28
|
+
* the DB, only the TLS keys, etc.). The vault key is always recovered
|
|
29
|
+
* regardless of filter so the operator can read sealed values from a
|
|
30
|
+
* partial restore.
|
|
31
|
+
*
|
|
32
|
+
* Defense:
|
|
33
|
+
* - Wrong passphrase → AEAD tag check fails on first blob →
|
|
34
|
+
* restore-bundle/decrypt-failed (no plaintext leaked, no staging
|
|
35
|
+
* left behind)
|
|
36
|
+
* - Tampered blob (single byte flip in ciphertext) → same path
|
|
37
|
+
* - encryptedSize mismatch → restore-bundle/size-mismatch (cheap
|
|
38
|
+
* pre-decrypt check)
|
|
39
|
+
* - Plaintext sha3-512 != manifest.checksum → restore-bundle/
|
|
40
|
+
* checksum-mismatch (post-decrypt integrity guard)
|
|
41
|
+
* - Missing blob file → restore-bundle/missing-blob (manifest
|
|
42
|
+
* references a path the bundle dir doesn't have)
|
|
43
|
+
* - On any failure, the partially-built stagingDir is removed so a
|
|
44
|
+
* subsequent retry isn't blocked by a stale dir
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
var fs = require("fs");
|
|
48
|
+
var path = require("path");
|
|
49
|
+
var atomicFile = require("./atomic-file");
|
|
50
|
+
var backupCrypto = require("./backup/crypto");
|
|
51
|
+
var backupManifest = require("./backup/manifest");
|
|
52
|
+
var validateOpts = require("./validate-opts");
|
|
53
|
+
var { defineClass } = require("./framework-error");
|
|
54
|
+
|
|
55
|
+
var RestoreBundleError = defineClass("RestoreBundleError", { alwaysPermanent: true });
|
|
56
|
+
|
|
57
|
+
function _emit(cb, ev) {
|
|
58
|
+
if (typeof cb === "function") {
|
|
59
|
+
try { cb(ev); } catch (_e) { /* progress-callback errors are non-fatal */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _cleanupStaging(stagingDir) {
|
|
64
|
+
// Best-effort recursive remove — if cleanup fails, surface that to
|
|
65
|
+
// the caller via stderr but never override the original error
|
|
66
|
+
// we're already throwing.
|
|
67
|
+
try { fs.rmSync(stagingDir, { recursive: true, force: true }); }
|
|
68
|
+
catch (_e) { /* best-effort */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function extract(opts) {
|
|
72
|
+
var t0 = Date.now();
|
|
73
|
+
opts = opts || {};
|
|
74
|
+
if (typeof opts.bundleDir !== "string" || !fs.existsSync(opts.bundleDir)) {
|
|
75
|
+
throw new RestoreBundleError("restore-bundle/no-bundle",
|
|
76
|
+
"extract: opts.bundleDir is required and must exist");
|
|
77
|
+
}
|
|
78
|
+
validateOpts.requireNonEmptyString(opts.stagingDir, "extract: opts.stagingDir", RestoreBundleError, "restore-bundle/no-staging");
|
|
79
|
+
if (fs.existsSync(opts.stagingDir)) {
|
|
80
|
+
throw new RestoreBundleError("restore-bundle/staging-exists",
|
|
81
|
+
"extract: stagingDir already exists: " + opts.stagingDir +
|
|
82
|
+
" (refusing to merge into existing directory — pick a fresh path)");
|
|
83
|
+
}
|
|
84
|
+
if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
|
|
85
|
+
throw new RestoreBundleError("restore-bundle/no-passphrase",
|
|
86
|
+
"extract: opts.passphrase is required (Buffer or string)");
|
|
87
|
+
}
|
|
88
|
+
var passphrase = opts.passphrase;
|
|
89
|
+
var bundleDir = opts.bundleDir;
|
|
90
|
+
var stagingDir = opts.stagingDir;
|
|
91
|
+
var filter = typeof opts.filter === "function" ? opts.filter : null;
|
|
92
|
+
var progress = opts.progressCallback;
|
|
93
|
+
|
|
94
|
+
// 1. Read + parse + validate manifest
|
|
95
|
+
_emit(progress, { phase: "read_manifest" });
|
|
96
|
+
var manifestPath = path.join(bundleDir, "manifest.json");
|
|
97
|
+
if (!fs.existsSync(manifestPath)) {
|
|
98
|
+
throw new RestoreBundleError("restore-bundle/missing-manifest",
|
|
99
|
+
"extract: bundleDir has no manifest.json — bundle is incomplete or not a blamejs backup");
|
|
100
|
+
}
|
|
101
|
+
var manifest;
|
|
102
|
+
try {
|
|
103
|
+
manifest = backupManifest.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (e && e.isBackupManifestError) throw e;
|
|
106
|
+
throw new RestoreBundleError("restore-bundle/bad-manifest",
|
|
107
|
+
"extract: manifest could not be parsed: " + ((e && e.message) || String(e)));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 2. Recover the vault key (always, regardless of filter — the
|
|
111
|
+
// operator may need it to unseal post-restore even on partial
|
|
112
|
+
// restores)
|
|
113
|
+
_emit(progress, { phase: "unwrap_vault_key" });
|
|
114
|
+
var vaultKeyJson;
|
|
115
|
+
try {
|
|
116
|
+
var vkBuf = await backupCrypto.decryptWithPassphrase(
|
|
117
|
+
Buffer.from(manifest.vaultKeyEnc, "base64"),
|
|
118
|
+
passphrase,
|
|
119
|
+
manifest.vaultKeySalt);
|
|
120
|
+
vaultKeyJson = vkBuf.toString("utf8");
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e && e.isBackupCryptoError && e.code === "backup-crypto/decrypt-failed") {
|
|
123
|
+
throw new RestoreBundleError("restore-bundle/decrypt-failed",
|
|
124
|
+
"extract: passphrase rejected (vault key did not decrypt). " +
|
|
125
|
+
"If you have multiple backup passphrases, double-check the one supplied.");
|
|
126
|
+
}
|
|
127
|
+
throw new RestoreBundleError("restore-bundle/vault-key-recovery-failed",
|
|
128
|
+
"extract: could not recover vault key from manifest: " + ((e && e.message) || String(e)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
atomicFile.ensureDir(stagingDir);
|
|
132
|
+
|
|
133
|
+
// 3. Walk manifest.files; decrypt + verify + write each that passes filter
|
|
134
|
+
var fileCount = 0;
|
|
135
|
+
var totalBytes = 0;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
for (var i = 0; i < manifest.files.length; i++) {
|
|
139
|
+
var entry = manifest.files[i];
|
|
140
|
+
if (filter && !filter(entry)) {
|
|
141
|
+
_emit(progress, { phase: "skip_filtered", relativePath: entry.relativePath });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
var blobPath = path.join(bundleDir, entry.encryptedPath);
|
|
146
|
+
if (!fs.existsSync(blobPath)) {
|
|
147
|
+
throw new RestoreBundleError("restore-bundle/missing-blob",
|
|
148
|
+
"extract: manifest references '" + entry.encryptedPath +
|
|
149
|
+
"' but the bundle has no such file");
|
|
150
|
+
}
|
|
151
|
+
var blob = fs.readFileSync(blobPath);
|
|
152
|
+
if (blob.length !== entry.encryptedSize) {
|
|
153
|
+
throw new RestoreBundleError("restore-bundle/size-mismatch",
|
|
154
|
+
"extract: blob '" + entry.encryptedPath + "' has size " + blob.length +
|
|
155
|
+
" but manifest expected " + entry.encryptedSize);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
_emit(progress, {
|
|
159
|
+
phase: "decrypt", relativePath: entry.relativePath,
|
|
160
|
+
encryptedSize: entry.encryptedSize,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
var plaintext;
|
|
164
|
+
try {
|
|
165
|
+
plaintext = await backupCrypto.decryptWithPassphrase(blob, passphrase, entry.salt);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
if (e && e.isBackupCryptoError && e.code === "backup-crypto/decrypt-failed") {
|
|
168
|
+
throw new RestoreBundleError("restore-bundle/decrypt-failed",
|
|
169
|
+
"extract: blob '" + entry.encryptedPath + "' did not decrypt — " +
|
|
170
|
+
"passphrase rejected or ciphertext tampered");
|
|
171
|
+
}
|
|
172
|
+
throw e;
|
|
173
|
+
}
|
|
174
|
+
if (plaintext.length !== entry.size) {
|
|
175
|
+
throw new RestoreBundleError("restore-bundle/size-mismatch",
|
|
176
|
+
"extract: decrypted '" + entry.relativePath +
|
|
177
|
+
"' has " + plaintext.length + " bytes but manifest expected " + entry.size);
|
|
178
|
+
}
|
|
179
|
+
var actualChecksum = backupCrypto.checksum(plaintext);
|
|
180
|
+
if (actualChecksum !== entry.checksum) {
|
|
181
|
+
throw new RestoreBundleError("restore-bundle/checksum-mismatch",
|
|
182
|
+
"extract: decrypted '" + entry.relativePath + "' has checksum " + actualChecksum +
|
|
183
|
+
" but manifest declared " + entry.checksum +
|
|
184
|
+
" — bundle is corrupted or manifest tampered");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
var destPath = path.join(stagingDir, entry.relativePath);
|
|
188
|
+
atomicFile.ensureDir(path.dirname(destPath));
|
|
189
|
+
atomicFile.writeSync(destPath, plaintext, { fileMode: 0o600 });
|
|
190
|
+
|
|
191
|
+
fileCount++;
|
|
192
|
+
totalBytes += plaintext.length;
|
|
193
|
+
}
|
|
194
|
+
} catch (e) {
|
|
195
|
+
_cleanupStaging(stagingDir);
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
var durationMs = Date.now() - t0;
|
|
200
|
+
_emit(progress, {
|
|
201
|
+
phase: "done",
|
|
202
|
+
fileCount: fileCount,
|
|
203
|
+
totalBytes: totalBytes,
|
|
204
|
+
durationMs: durationMs,
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
manifest: manifest,
|
|
208
|
+
vaultKeyJson: vaultKeyJson,
|
|
209
|
+
fileCount: fileCount,
|
|
210
|
+
totalBytes: totalBytes,
|
|
211
|
+
stagingDir: stagingDir,
|
|
212
|
+
durationMs: durationMs,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Inspect a bundle without decrypting — read the manifest and return
|
|
217
|
+
// it. Useful for dashboards and pre-flight UI: list files, sizes,
|
|
218
|
+
// timestamps, kinds without prompting for the passphrase.
|
|
219
|
+
function inspect(opts) {
|
|
220
|
+
opts = opts || {};
|
|
221
|
+
if (typeof opts.bundleDir !== "string" || !fs.existsSync(opts.bundleDir)) {
|
|
222
|
+
throw new RestoreBundleError("restore-bundle/no-bundle",
|
|
223
|
+
"inspect: opts.bundleDir is required and must exist");
|
|
224
|
+
}
|
|
225
|
+
var manifestPath = path.join(opts.bundleDir, "manifest.json");
|
|
226
|
+
if (!fs.existsSync(manifestPath)) {
|
|
227
|
+
throw new RestoreBundleError("restore-bundle/missing-manifest",
|
|
228
|
+
"inspect: bundleDir has no manifest.json");
|
|
229
|
+
}
|
|
230
|
+
return backupManifest.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = {
|
|
234
|
+
extract: extract,
|
|
235
|
+
inspect: inspect,
|
|
236
|
+
RestoreBundleError: RestoreBundleError,
|
|
237
|
+
};
|