@blamejs/core 0.7.4 → 0.7.18
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 +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- 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.js +311 -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 +594 -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/codepoint-class.js +196 -0
- 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 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- 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/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- 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 -362
- package/lib/mail.js +981 -962
- 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/body-parser.js +1170 -1091
- 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 +79 -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 -91
- 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 +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- 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 -319
- 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/vault/index.js
CHANGED
|
@@ -1,308 +1,308 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Vault — sealed keystore for the framework's encryption keys.
|
|
4
|
-
*
|
|
5
|
-
* Holds the ML-KEM-1024 + P-384 hybrid keypair used by every other framework
|
|
6
|
-
* subsystem that calls vault.seal() / vault.unseal() (db field encryption,
|
|
7
|
-
* session storage, audit log signing, etc.). Keys never leave the process
|
|
8
|
-
* after init() in any decrypted form except via the vault.seal/unseal API.
|
|
9
|
-
*
|
|
10
|
-
* Modes (default is 'wrapped' — highest-security; 'plaintext' is opt-out
|
|
11
|
-
* with explicit boot warning per the framework's modernity stance):
|
|
12
|
-
*
|
|
13
|
-
* wrapped — vault.key.sealed file, passphrase-derived AEAD wrap (lib/vault-wrap.js).
|
|
14
|
-
* Argon2id → SHAKE256 → XChaCha20-Poly1305. Default.
|
|
15
|
-
* plaintext — vault.key file (JSON, mode 0o600). For development only.
|
|
16
|
-
* Emits console.warn at boot. Opt-out only.
|
|
17
|
-
*
|
|
18
|
-
* Two-API contract (sync seal/unseal, async init):
|
|
19
|
-
*
|
|
20
|
-
* await vault.init({ dataDir, mode? }) ← call once at app bootstrap
|
|
21
|
-
* vault.seal(value) ← sync, post-init
|
|
22
|
-
* vault.unseal(value) ← sync, post-init
|
|
23
|
-
*
|
|
24
|
-
* Why two APIs: seal/unseal have hundreds of call sites across a typical app,
|
|
25
|
-
* many at module-require time. Making them async would require an invasive
|
|
26
|
-
* refactor of every consumer. Instead, the bootstrap awaits init() once, then
|
|
27
|
-
* everything runs synchronously against the in-process key cache.
|
|
28
|
-
*
|
|
29
|
-
* Sealed-value format: "vault:" prefix + base64 envelope from lib/crypto.js.
|
|
30
|
-
* Old envelopes always remain readable (envelope versioning); new writes use
|
|
31
|
-
* the active KEM/CIPHER/KDF.
|
|
32
|
-
*/
|
|
33
|
-
var fs = require("fs");
|
|
34
|
-
var path = require("path");
|
|
35
|
-
var atomicFile = require("../atomic-file");
|
|
36
|
-
var C = require("../constants");
|
|
37
|
-
var { generateEncryptionKeyPair, encrypt, decrypt } = require("../crypto");
|
|
38
|
-
var { boot } = require("../log");
|
|
39
|
-
var safeBuffer = require("../safe-buffer");
|
|
40
|
-
var safeJson = require("../safe-json");
|
|
41
|
-
var observability = require("../observability");
|
|
42
|
-
var vaultPassphraseSource = require("./passphrase-source");
|
|
43
|
-
var vaultWrap = require("./wrap");
|
|
44
|
-
var { defineClass } = require("../framework-error");
|
|
45
|
-
|
|
46
|
-
// VaultError — thrown by init() for fatal boot-time conditions
|
|
47
|
-
// (corrupt sealed file, schema mismatch, mode/state conflicts). The
|
|
48
|
-
// CLI / app entry point catches it and exits; lib code never calls
|
|
49
|
-
// process.exit() unilaterally.
|
|
50
|
-
var VaultError = defineClass("VaultError", { alwaysPermanent: true });
|
|
51
|
-
|
|
52
|
-
var VAULT_PREFIX = C.VAULT_PREFIX;
|
|
53
|
-
|
|
54
|
-
// Module-local cache populated by init().
|
|
55
|
-
var keys = null;
|
|
56
|
-
var initialized = false;
|
|
57
|
-
// Passphrase retained post-init (best-effort) for vault rotation +
|
|
58
|
-
// backup re-wrap. Already in JS heap during unwrap; retaining doesn't
|
|
59
|
-
// change the threat model meaningfully.
|
|
60
|
-
var currentPassphrase = null;
|
|
61
|
-
// Resolved paths (set by init based on dataDir option)
|
|
62
|
-
var paths = null;
|
|
63
|
-
var currentMode = null;
|
|
64
|
-
|
|
65
|
-
var log = boot("vault");
|
|
66
|
-
|
|
67
|
-
function resolvePaths(dataDir) {
|
|
68
|
-
return {
|
|
69
|
-
dataDir: dataDir,
|
|
70
|
-
plaintext: path.join(dataDir, "vault.key"),
|
|
71
|
-
sealed: path.join(dataDir, "vault.key.sealed"),
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ---- Init dispatch ----
|
|
76
|
-
|
|
77
|
-
async function init(opts) {
|
|
78
|
-
if (initialized) return;
|
|
79
|
-
opts = opts || {};
|
|
80
|
-
|
|
81
|
-
if (!opts.dataDir) {
|
|
82
|
-
throw new VaultError("vault/bad-init", "vault.init({ dataDir }) is required");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
var mode = (opts.mode || "wrapped").toLowerCase();
|
|
86
|
-
if (mode !== "wrapped" && mode !== "plaintext") {
|
|
87
|
-
throw new VaultError("vault/bad-mode",
|
|
88
|
-
"vault.init: mode must be 'wrapped' or 'plaintext', got: " + opts.mode);
|
|
89
|
-
}
|
|
90
|
-
currentMode = mode;
|
|
91
|
-
paths = resolvePaths(opts.dataDir);
|
|
92
|
-
|
|
93
|
-
if (!fs.existsSync(paths.dataDir)) {
|
|
94
|
-
fs.mkdirSync(paths.dataDir, { recursive: true });
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Sweep tmp files left behind by a previously-crashed write
|
|
98
|
-
atomicFile.cleanOrphans(paths.sealed);
|
|
99
|
-
atomicFile.cleanOrphans(paths.plaintext);
|
|
100
|
-
|
|
101
|
-
var hasPlaintext = fs.existsSync(paths.plaintext);
|
|
102
|
-
var hasSealed = fs.existsSync(paths.sealed);
|
|
103
|
-
|
|
104
|
-
// Refuse to guess when both files coexist
|
|
105
|
-
if (hasPlaintext && hasSealed) {
|
|
106
|
-
throw new VaultError("vault/both-files-exist",
|
|
107
|
-
"both vault.key and vault.key.sealed exist in " + paths.dataDir +
|
|
108
|
-
" — delete the one you do NOT want to keep, then restart");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Mode-vs-state mismatches
|
|
112
|
-
if (hasSealed && mode === "plaintext") {
|
|
113
|
-
throw new VaultError("vault/mode-mismatch",
|
|
114
|
-
"vault.key.sealed exists but vault.init({ mode: 'plaintext' }) was requested — " +
|
|
115
|
-
"either run with mode: 'wrapped', or remove the sealed file (after migration)");
|
|
116
|
-
}
|
|
117
|
-
if (hasPlaintext && mode === "wrapped") {
|
|
118
|
-
throw new VaultError("vault/mode-mismatch",
|
|
119
|
-
"vault.key (plaintext) exists but vault.init({ mode: 'wrapped' }) was requested — " +
|
|
120
|
-
"either run with mode: 'plaintext', or migrate the key to a wrapped form");
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (mode === "wrapped") {
|
|
124
|
-
if (hasSealed) await initWrapped();
|
|
125
|
-
else await initFirstRunWrapped();
|
|
126
|
-
} else {
|
|
127
|
-
// mode === "plaintext"
|
|
128
|
-
log.warn("WARNING: running in PLAINTEXT mode — vault.key is unprotected on disk.");
|
|
129
|
-
log.warn(" Use mode: 'wrapped' (default) for any deployment that holds real data.");
|
|
130
|
-
log.warn(" See https://github.com/blamejs/blamejs#vault-modes for details.");
|
|
131
|
-
initPlaintext();
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
initialized = true;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function initPlaintext() {
|
|
138
|
-
if (fs.existsSync(paths.plaintext)) {
|
|
139
|
-
var loaded;
|
|
140
|
-
try {
|
|
141
|
-
loaded = safeJson.parse(atomicFile.readSync(paths.plaintext), {
|
|
142
|
-
schema: {
|
|
143
|
-
type: "object",
|
|
144
|
-
required: ["publicKey", "privateKey", "ecPublicKey", "ecPrivateKey"],
|
|
145
|
-
properties: {
|
|
146
|
-
publicKey: { type: "string" },
|
|
147
|
-
privateKey: { type: "string" },
|
|
148
|
-
ecPublicKey: { type: "string" },
|
|
149
|
-
ecPrivateKey: { type: "string" },
|
|
150
|
-
},
|
|
151
|
-
},
|
|
152
|
-
});
|
|
153
|
-
} catch (e) {
|
|
154
|
-
throw new VaultError("vault/key-corrupt",
|
|
155
|
-
"vault.key corrupted, unreadable, or schema-invalid at " + paths.plaintext +
|
|
156
|
-
" — " + e.message +
|
|
157
|
-
" — all sealed data requires the original key; restore from backup, then restart");
|
|
158
|
-
}
|
|
159
|
-
keys = loaded;
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
// First run, plaintext mode
|
|
163
|
-
keys = generateEncryptionKeyPair();
|
|
164
|
-
atomicFile.writeSync(paths.plaintext, JSON.stringify(keys, null, 2), { fileMode: 0o600 });
|
|
165
|
-
log("plaintext vault keypair generated at " + paths.plaintext);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async function initWrapped() {
|
|
169
|
-
log("unsealing vault.key.sealed...");
|
|
170
|
-
var sealedBytes;
|
|
171
|
-
try {
|
|
172
|
-
sealedBytes = atomicFile.readSync(paths.sealed);
|
|
173
|
-
} catch (e) {
|
|
174
|
-
throw new VaultError("vault/sealed-unreadable",
|
|
175
|
-
"cannot read " + paths.sealed + ": " + e.message);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
var passphrase;
|
|
179
|
-
try {
|
|
180
|
-
passphrase = await vaultPassphraseSource.getPassphrase({ prompt: "Vault passphrase: " });
|
|
181
|
-
} catch (e) {
|
|
182
|
-
throw new VaultError("vault/passphrase-error", e.message);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
var plaintextJson;
|
|
186
|
-
var plaintextBuf;
|
|
187
|
-
try {
|
|
188
|
-
plaintextBuf = await vaultWrap.unwrap(sealedBytes, passphrase);
|
|
189
|
-
plaintextJson = plaintextBuf.toString("utf8");
|
|
190
|
-
} catch (e) {
|
|
191
|
-
throw new VaultError("vault/unwrap-failed",
|
|
192
|
-
"passphrase rejected or sealed file corrupted (" + e.message + ")");
|
|
193
|
-
} finally {
|
|
194
|
-
// The Buffer holding the unwrapped key JSON is no longer needed once
|
|
195
|
-
// toString has copied the bytes into plaintextJson. The string itself
|
|
196
|
-
// is referenced by the JSON parser below; can't be zeroed (V8 strings
|
|
197
|
-
// are GC-managed). secureZero on the Buffer at least removes one
|
|
198
|
-
// copy of the secret from the heap.
|
|
199
|
-
if (plaintextBuf) safeBuffer.secureZero(plaintextBuf);
|
|
200
|
-
}
|
|
201
|
-
currentPassphrase = passphrase;
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
keys = safeJson.parse(plaintextJson, {
|
|
205
|
-
schema: {
|
|
206
|
-
type: "object",
|
|
207
|
-
required: ["publicKey", "privateKey", "ecPublicKey", "ecPrivateKey"],
|
|
208
|
-
properties: {
|
|
209
|
-
publicKey: { type: "string" },
|
|
210
|
-
privateKey: { type: "string" },
|
|
211
|
-
ecPublicKey: { type: "string" },
|
|
212
|
-
ecPrivateKey: { type: "string" },
|
|
213
|
-
},
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
} catch (e) {
|
|
217
|
-
throw new VaultError("vault/unwrapped-invalid",
|
|
218
|
-
"unwrapped vault key invalid: " + e.message);
|
|
219
|
-
}
|
|
220
|
-
log("unsealed successfully.");
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async function initFirstRunWrapped() {
|
|
224
|
-
log("first run with mode: 'wrapped' — generating wrapped keypair...");
|
|
225
|
-
|
|
226
|
-
var passphrase;
|
|
227
|
-
try {
|
|
228
|
-
passphrase = await vaultPassphraseSource.getPassphrase({
|
|
229
|
-
prompt: "Choose a vault passphrase (loss = data loss, store it safely): ",
|
|
230
|
-
});
|
|
231
|
-
} catch (e) {
|
|
232
|
-
throw new VaultError("vault/passphrase-error", e.message);
|
|
233
|
-
}
|
|
234
|
-
currentPassphrase = passphrase;
|
|
235
|
-
|
|
236
|
-
keys = generateEncryptionKeyPair();
|
|
237
|
-
var plaintextJson = JSON.stringify(keys, null, 2);
|
|
238
|
-
var sealed;
|
|
239
|
-
try {
|
|
240
|
-
sealed = await vaultWrap.wrap(plaintextJson, passphrase);
|
|
241
|
-
} catch (e) {
|
|
242
|
-
throw new VaultError("vault/wrap-failed",
|
|
243
|
-
"failed to wrap new vault key: " + e.message);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Atomic write via the framework's atomic-file primitive (temp + fsync +
|
|
247
|
-
// rename + dir fsync — same flow this code used to inline manually).
|
|
248
|
-
atomicFile.writeSync(paths.sealed, sealed, { fileMode: 0o600 });
|
|
249
|
-
|
|
250
|
-
log("generated and sealed new vault keypair (ML-KEM-1024 + P-384 hybrid)");
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ---- Sync API — operates against the populated cache ----
|
|
254
|
-
|
|
255
|
-
function _requireInit() {
|
|
256
|
-
if (!initialized) {
|
|
257
|
-
throw new VaultError("vault/not-initialized",
|
|
258
|
-
"vault.init() must be awaited before vault.seal/unseal/getKeysJson");
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function seal(plaintext) {
|
|
263
|
-
if (!plaintext) return plaintext;
|
|
264
|
-
if (String(plaintext).startsWith(VAULT_PREFIX)) return plaintext;
|
|
265
|
-
_requireInit();
|
|
266
|
-
return observability.tap("vault.seal", null, function () {
|
|
267
|
-
return VAULT_PREFIX + encrypt(String(plaintext), keys);
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function unseal(value) {
|
|
272
|
-
if (!value || !String(value).startsWith(VAULT_PREFIX)) return value;
|
|
273
|
-
_requireInit();
|
|
274
|
-
return observability.tap("vault.unseal", null, function () {
|
|
275
|
-
var payload = String(value).substring(VAULT_PREFIX.length);
|
|
276
|
-
return decrypt(payload, keys);
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function getKeysJson() {
|
|
281
|
-
_requireInit();
|
|
282
|
-
return JSON.stringify(keys, null, 2);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function getCurrentPassphrase() {
|
|
286
|
-
return currentPassphrase;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function getMode() {
|
|
290
|
-
return currentMode;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
module.exports = {
|
|
294
|
-
init: init,
|
|
295
|
-
seal: seal,
|
|
296
|
-
unseal: unseal,
|
|
297
|
-
getKeysJson: getKeysJson,
|
|
298
|
-
getCurrentPassphrase: getCurrentPassphrase,
|
|
299
|
-
getMode: getMode,
|
|
300
|
-
VaultError: VaultError,
|
|
301
|
-
// Testing helpers — not part of the public contract
|
|
302
|
-
_resetForTest: function () {
|
|
303
|
-
if (currentPassphrase) safeBuffer.secureZero(currentPassphrase);
|
|
304
|
-
keys = null; initialized = false; currentPassphrase = null; paths = null; currentMode = null;
|
|
305
|
-
},
|
|
306
|
-
_getKeysForTest: function () { return keys; },
|
|
307
|
-
_getPathsForTest: function () { return paths; },
|
|
308
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Vault — sealed keystore for the framework's encryption keys.
|
|
4
|
+
*
|
|
5
|
+
* Holds the ML-KEM-1024 + P-384 hybrid keypair used by every other framework
|
|
6
|
+
* subsystem that calls vault.seal() / vault.unseal() (db field encryption,
|
|
7
|
+
* session storage, audit log signing, etc.). Keys never leave the process
|
|
8
|
+
* after init() in any decrypted form except via the vault.seal/unseal API.
|
|
9
|
+
*
|
|
10
|
+
* Modes (default is 'wrapped' — highest-security; 'plaintext' is opt-out
|
|
11
|
+
* with explicit boot warning per the framework's modernity stance):
|
|
12
|
+
*
|
|
13
|
+
* wrapped — vault.key.sealed file, passphrase-derived AEAD wrap (lib/vault-wrap.js).
|
|
14
|
+
* Argon2id → SHAKE256 → XChaCha20-Poly1305. Default.
|
|
15
|
+
* plaintext — vault.key file (JSON, mode 0o600). For development only.
|
|
16
|
+
* Emits console.warn at boot. Opt-out only.
|
|
17
|
+
*
|
|
18
|
+
* Two-API contract (sync seal/unseal, async init):
|
|
19
|
+
*
|
|
20
|
+
* await vault.init({ dataDir, mode? }) ← call once at app bootstrap
|
|
21
|
+
* vault.seal(value) ← sync, post-init
|
|
22
|
+
* vault.unseal(value) ← sync, post-init
|
|
23
|
+
*
|
|
24
|
+
* Why two APIs: seal/unseal have hundreds of call sites across a typical app,
|
|
25
|
+
* many at module-require time. Making them async would require an invasive
|
|
26
|
+
* refactor of every consumer. Instead, the bootstrap awaits init() once, then
|
|
27
|
+
* everything runs synchronously against the in-process key cache.
|
|
28
|
+
*
|
|
29
|
+
* Sealed-value format: "vault:" prefix + base64 envelope from lib/crypto.js.
|
|
30
|
+
* Old envelopes always remain readable (envelope versioning); new writes use
|
|
31
|
+
* the active KEM/CIPHER/KDF.
|
|
32
|
+
*/
|
|
33
|
+
var fs = require("fs");
|
|
34
|
+
var path = require("path");
|
|
35
|
+
var atomicFile = require("../atomic-file");
|
|
36
|
+
var C = require("../constants");
|
|
37
|
+
var { generateEncryptionKeyPair, encrypt, decrypt } = require("../crypto");
|
|
38
|
+
var { boot } = require("../log");
|
|
39
|
+
var safeBuffer = require("../safe-buffer");
|
|
40
|
+
var safeJson = require("../safe-json");
|
|
41
|
+
var observability = require("../observability");
|
|
42
|
+
var vaultPassphraseSource = require("./passphrase-source");
|
|
43
|
+
var vaultWrap = require("./wrap");
|
|
44
|
+
var { defineClass } = require("../framework-error");
|
|
45
|
+
|
|
46
|
+
// VaultError — thrown by init() for fatal boot-time conditions
|
|
47
|
+
// (corrupt sealed file, schema mismatch, mode/state conflicts). The
|
|
48
|
+
// CLI / app entry point catches it and exits; lib code never calls
|
|
49
|
+
// process.exit() unilaterally.
|
|
50
|
+
var VaultError = defineClass("VaultError", { alwaysPermanent: true });
|
|
51
|
+
|
|
52
|
+
var VAULT_PREFIX = C.VAULT_PREFIX;
|
|
53
|
+
|
|
54
|
+
// Module-local cache populated by init().
|
|
55
|
+
var keys = null;
|
|
56
|
+
var initialized = false;
|
|
57
|
+
// Passphrase retained post-init (best-effort) for vault rotation +
|
|
58
|
+
// backup re-wrap. Already in JS heap during unwrap; retaining doesn't
|
|
59
|
+
// change the threat model meaningfully.
|
|
60
|
+
var currentPassphrase = null;
|
|
61
|
+
// Resolved paths (set by init based on dataDir option)
|
|
62
|
+
var paths = null;
|
|
63
|
+
var currentMode = null;
|
|
64
|
+
|
|
65
|
+
var log = boot("vault");
|
|
66
|
+
|
|
67
|
+
function resolvePaths(dataDir) {
|
|
68
|
+
return {
|
|
69
|
+
dataDir: dataDir,
|
|
70
|
+
plaintext: path.join(dataDir, "vault.key"),
|
|
71
|
+
sealed: path.join(dataDir, "vault.key.sealed"),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- Init dispatch ----
|
|
76
|
+
|
|
77
|
+
async function init(opts) {
|
|
78
|
+
if (initialized) return;
|
|
79
|
+
opts = opts || {};
|
|
80
|
+
|
|
81
|
+
if (!opts.dataDir) {
|
|
82
|
+
throw new VaultError("vault/bad-init", "vault.init({ dataDir }) is required");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
var mode = (opts.mode || "wrapped").toLowerCase();
|
|
86
|
+
if (mode !== "wrapped" && mode !== "plaintext") {
|
|
87
|
+
throw new VaultError("vault/bad-mode",
|
|
88
|
+
"vault.init: mode must be 'wrapped' or 'plaintext', got: " + opts.mode);
|
|
89
|
+
}
|
|
90
|
+
currentMode = mode;
|
|
91
|
+
paths = resolvePaths(opts.dataDir);
|
|
92
|
+
|
|
93
|
+
if (!fs.existsSync(paths.dataDir)) {
|
|
94
|
+
fs.mkdirSync(paths.dataDir, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Sweep tmp files left behind by a previously-crashed write
|
|
98
|
+
atomicFile.cleanOrphans(paths.sealed);
|
|
99
|
+
atomicFile.cleanOrphans(paths.plaintext);
|
|
100
|
+
|
|
101
|
+
var hasPlaintext = fs.existsSync(paths.plaintext);
|
|
102
|
+
var hasSealed = fs.existsSync(paths.sealed);
|
|
103
|
+
|
|
104
|
+
// Refuse to guess when both files coexist
|
|
105
|
+
if (hasPlaintext && hasSealed) {
|
|
106
|
+
throw new VaultError("vault/both-files-exist",
|
|
107
|
+
"both vault.key and vault.key.sealed exist in " + paths.dataDir +
|
|
108
|
+
" — delete the one you do NOT want to keep, then restart");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Mode-vs-state mismatches
|
|
112
|
+
if (hasSealed && mode === "plaintext") {
|
|
113
|
+
throw new VaultError("vault/mode-mismatch",
|
|
114
|
+
"vault.key.sealed exists but vault.init({ mode: 'plaintext' }) was requested — " +
|
|
115
|
+
"either run with mode: 'wrapped', or remove the sealed file (after migration)");
|
|
116
|
+
}
|
|
117
|
+
if (hasPlaintext && mode === "wrapped") {
|
|
118
|
+
throw new VaultError("vault/mode-mismatch",
|
|
119
|
+
"vault.key (plaintext) exists but vault.init({ mode: 'wrapped' }) was requested — " +
|
|
120
|
+
"either run with mode: 'plaintext', or migrate the key to a wrapped form");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (mode === "wrapped") {
|
|
124
|
+
if (hasSealed) await initWrapped();
|
|
125
|
+
else await initFirstRunWrapped();
|
|
126
|
+
} else {
|
|
127
|
+
// mode === "plaintext"
|
|
128
|
+
log.warn("WARNING: running in PLAINTEXT mode — vault.key is unprotected on disk.");
|
|
129
|
+
log.warn(" Use mode: 'wrapped' (default) for any deployment that holds real data.");
|
|
130
|
+
log.warn(" See https://github.com/blamejs/blamejs#vault-modes for details.");
|
|
131
|
+
initPlaintext();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
initialized = true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function initPlaintext() {
|
|
138
|
+
if (fs.existsSync(paths.plaintext)) {
|
|
139
|
+
var loaded;
|
|
140
|
+
try {
|
|
141
|
+
loaded = safeJson.parse(atomicFile.readSync(paths.plaintext), {
|
|
142
|
+
schema: {
|
|
143
|
+
type: "object",
|
|
144
|
+
required: ["publicKey", "privateKey", "ecPublicKey", "ecPrivateKey"],
|
|
145
|
+
properties: {
|
|
146
|
+
publicKey: { type: "string" },
|
|
147
|
+
privateKey: { type: "string" },
|
|
148
|
+
ecPublicKey: { type: "string" },
|
|
149
|
+
ecPrivateKey: { type: "string" },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
} catch (e) {
|
|
154
|
+
throw new VaultError("vault/key-corrupt",
|
|
155
|
+
"vault.key corrupted, unreadable, or schema-invalid at " + paths.plaintext +
|
|
156
|
+
" — " + e.message +
|
|
157
|
+
" — all sealed data requires the original key; restore from backup, then restart");
|
|
158
|
+
}
|
|
159
|
+
keys = loaded;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// First run, plaintext mode
|
|
163
|
+
keys = generateEncryptionKeyPair();
|
|
164
|
+
atomicFile.writeSync(paths.plaintext, JSON.stringify(keys, null, 2), { fileMode: 0o600 });
|
|
165
|
+
log("plaintext vault keypair generated at " + paths.plaintext);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function initWrapped() {
|
|
169
|
+
log("unsealing vault.key.sealed...");
|
|
170
|
+
var sealedBytes;
|
|
171
|
+
try {
|
|
172
|
+
sealedBytes = atomicFile.readSync(paths.sealed);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
throw new VaultError("vault/sealed-unreadable",
|
|
175
|
+
"cannot read " + paths.sealed + ": " + e.message);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
var passphrase;
|
|
179
|
+
try {
|
|
180
|
+
passphrase = await vaultPassphraseSource.getPassphrase({ prompt: "Vault passphrase: " });
|
|
181
|
+
} catch (e) {
|
|
182
|
+
throw new VaultError("vault/passphrase-error", e.message);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
var plaintextJson;
|
|
186
|
+
var plaintextBuf;
|
|
187
|
+
try {
|
|
188
|
+
plaintextBuf = await vaultWrap.unwrap(sealedBytes, passphrase);
|
|
189
|
+
plaintextJson = plaintextBuf.toString("utf8");
|
|
190
|
+
} catch (e) {
|
|
191
|
+
throw new VaultError("vault/unwrap-failed",
|
|
192
|
+
"passphrase rejected or sealed file corrupted (" + e.message + ")");
|
|
193
|
+
} finally {
|
|
194
|
+
// The Buffer holding the unwrapped key JSON is no longer needed once
|
|
195
|
+
// toString has copied the bytes into plaintextJson. The string itself
|
|
196
|
+
// is referenced by the JSON parser below; can't be zeroed (V8 strings
|
|
197
|
+
// are GC-managed). secureZero on the Buffer at least removes one
|
|
198
|
+
// copy of the secret from the heap.
|
|
199
|
+
if (plaintextBuf) safeBuffer.secureZero(plaintextBuf);
|
|
200
|
+
}
|
|
201
|
+
currentPassphrase = passphrase;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
keys = safeJson.parse(plaintextJson, {
|
|
205
|
+
schema: {
|
|
206
|
+
type: "object",
|
|
207
|
+
required: ["publicKey", "privateKey", "ecPublicKey", "ecPrivateKey"],
|
|
208
|
+
properties: {
|
|
209
|
+
publicKey: { type: "string" },
|
|
210
|
+
privateKey: { type: "string" },
|
|
211
|
+
ecPublicKey: { type: "string" },
|
|
212
|
+
ecPrivateKey: { type: "string" },
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
} catch (e) {
|
|
217
|
+
throw new VaultError("vault/unwrapped-invalid",
|
|
218
|
+
"unwrapped vault key invalid: " + e.message);
|
|
219
|
+
}
|
|
220
|
+
log("unsealed successfully.");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function initFirstRunWrapped() {
|
|
224
|
+
log("first run with mode: 'wrapped' — generating wrapped keypair...");
|
|
225
|
+
|
|
226
|
+
var passphrase;
|
|
227
|
+
try {
|
|
228
|
+
passphrase = await vaultPassphraseSource.getPassphrase({
|
|
229
|
+
prompt: "Choose a vault passphrase (loss = data loss, store it safely): ",
|
|
230
|
+
});
|
|
231
|
+
} catch (e) {
|
|
232
|
+
throw new VaultError("vault/passphrase-error", e.message);
|
|
233
|
+
}
|
|
234
|
+
currentPassphrase = passphrase;
|
|
235
|
+
|
|
236
|
+
keys = generateEncryptionKeyPair();
|
|
237
|
+
var plaintextJson = JSON.stringify(keys, null, 2);
|
|
238
|
+
var sealed;
|
|
239
|
+
try {
|
|
240
|
+
sealed = await vaultWrap.wrap(plaintextJson, passphrase);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
throw new VaultError("vault/wrap-failed",
|
|
243
|
+
"failed to wrap new vault key: " + e.message);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Atomic write via the framework's atomic-file primitive (temp + fsync +
|
|
247
|
+
// rename + dir fsync — same flow this code used to inline manually).
|
|
248
|
+
atomicFile.writeSync(paths.sealed, sealed, { fileMode: 0o600 });
|
|
249
|
+
|
|
250
|
+
log("generated and sealed new vault keypair (ML-KEM-1024 + P-384 hybrid)");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- Sync API — operates against the populated cache ----
|
|
254
|
+
|
|
255
|
+
function _requireInit() {
|
|
256
|
+
if (!initialized) {
|
|
257
|
+
throw new VaultError("vault/not-initialized",
|
|
258
|
+
"vault.init() must be awaited before vault.seal/unseal/getKeysJson");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function seal(plaintext) {
|
|
263
|
+
if (!plaintext) return plaintext;
|
|
264
|
+
if (String(plaintext).startsWith(VAULT_PREFIX)) return plaintext;
|
|
265
|
+
_requireInit();
|
|
266
|
+
return observability.tap("vault.seal", null, function () {
|
|
267
|
+
return VAULT_PREFIX + encrypt(String(plaintext), keys);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function unseal(value) {
|
|
272
|
+
if (!value || !String(value).startsWith(VAULT_PREFIX)) return value;
|
|
273
|
+
_requireInit();
|
|
274
|
+
return observability.tap("vault.unseal", null, function () {
|
|
275
|
+
var payload = String(value).substring(VAULT_PREFIX.length);
|
|
276
|
+
return decrypt(payload, keys);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getKeysJson() {
|
|
281
|
+
_requireInit();
|
|
282
|
+
return JSON.stringify(keys, null, 2);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getCurrentPassphrase() {
|
|
286
|
+
return currentPassphrase;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getMode() {
|
|
290
|
+
return currentMode;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
init: init,
|
|
295
|
+
seal: seal,
|
|
296
|
+
unseal: unseal,
|
|
297
|
+
getKeysJson: getKeysJson,
|
|
298
|
+
getCurrentPassphrase: getCurrentPassphrase,
|
|
299
|
+
getMode: getMode,
|
|
300
|
+
VaultError: VaultError,
|
|
301
|
+
// Testing helpers — not part of the public contract
|
|
302
|
+
_resetForTest: function () {
|
|
303
|
+
if (currentPassphrase) safeBuffer.secureZero(currentPassphrase);
|
|
304
|
+
keys = null; initialized = false; currentPassphrase = null; paths = null; currentMode = null;
|
|
305
|
+
},
|
|
306
|
+
_getKeysForTest: function () { return keys; },
|
|
307
|
+
_getPathsForTest: function () { return paths; },
|
|
308
|
+
};
|