@blamejs/core 0.4.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 +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- package/package.json +39 -0
|
@@ -0,0 +1,299 @@
|
|
|
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
|
+
|
|
45
|
+
var VAULT_PREFIX = C.VAULT_PREFIX;
|
|
46
|
+
|
|
47
|
+
// Module-local cache populated by init().
|
|
48
|
+
var keys = null;
|
|
49
|
+
var initialized = false;
|
|
50
|
+
// Passphrase retained post-init (best-effort) for operations that need it
|
|
51
|
+
// later — vault rotation, backup re-wrap. Already in JS heap during unwrap;
|
|
52
|
+
// retaining doesn't change the threat model meaningfully.
|
|
53
|
+
var currentPassphrase = null;
|
|
54
|
+
// Resolved paths (set by init based on dataDir option)
|
|
55
|
+
var paths = null;
|
|
56
|
+
var currentMode = null;
|
|
57
|
+
|
|
58
|
+
var log = boot("vault");
|
|
59
|
+
|
|
60
|
+
function resolvePaths(dataDir) {
|
|
61
|
+
return {
|
|
62
|
+
dataDir: dataDir,
|
|
63
|
+
plaintext: path.join(dataDir, "vault.key"),
|
|
64
|
+
sealed: path.join(dataDir, "vault.key.sealed"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- Init dispatch ----
|
|
69
|
+
|
|
70
|
+
async function init(opts) {
|
|
71
|
+
if (initialized) return;
|
|
72
|
+
opts = opts || {};
|
|
73
|
+
|
|
74
|
+
if (!opts.dataDir) {
|
|
75
|
+
throw new Error("vault.init({ dataDir }) is required");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
var mode = (opts.mode || "wrapped").toLowerCase();
|
|
79
|
+
if (mode !== "wrapped" && mode !== "plaintext") {
|
|
80
|
+
throw new Error("vault.init: mode must be 'wrapped' or 'plaintext', got: " + opts.mode);
|
|
81
|
+
}
|
|
82
|
+
currentMode = mode;
|
|
83
|
+
paths = resolvePaths(opts.dataDir);
|
|
84
|
+
|
|
85
|
+
if (!fs.existsSync(paths.dataDir)) {
|
|
86
|
+
fs.mkdirSync(paths.dataDir, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sweep tmp files left behind by a previously-crashed write
|
|
90
|
+
atomicFile.cleanOrphans(paths.sealed);
|
|
91
|
+
atomicFile.cleanOrphans(paths.plaintext);
|
|
92
|
+
|
|
93
|
+
var hasPlaintext = fs.existsSync(paths.plaintext);
|
|
94
|
+
var hasSealed = fs.existsSync(paths.sealed);
|
|
95
|
+
|
|
96
|
+
// Refuse to guess when both files coexist
|
|
97
|
+
if (hasPlaintext && hasSealed) {
|
|
98
|
+
log.error("FATAL: both vault.key and vault.key.sealed exist in " + paths.dataDir);
|
|
99
|
+
log.error("Delete the one you do NOT want to keep, then restart.");
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Mode-vs-state mismatches
|
|
104
|
+
if (hasSealed && mode === "plaintext") {
|
|
105
|
+
log.error("FATAL: vault.key.sealed exists but vault.init({ mode: 'plaintext' }) was requested.");
|
|
106
|
+
log.error("Either run with mode: 'wrapped', or remove the sealed file (after migration).");
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
if (hasPlaintext && mode === "wrapped") {
|
|
110
|
+
log.error("FATAL: vault.key (plaintext) exists but vault.init({ mode: 'wrapped' }) was requested.");
|
|
111
|
+
log.error("Either run with mode: 'plaintext', or migrate the key to a wrapped form.");
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (mode === "wrapped") {
|
|
116
|
+
if (hasSealed) await initWrapped();
|
|
117
|
+
else await initFirstRunWrapped();
|
|
118
|
+
} else {
|
|
119
|
+
// mode === "plaintext"
|
|
120
|
+
log.warn("WARNING: running in PLAINTEXT mode — vault.key is unprotected on disk.");
|
|
121
|
+
log.warn(" Use mode: 'wrapped' (default) for any deployment that holds real data.");
|
|
122
|
+
log.warn(" See https://github.com/blamejs/blamejs#vault-modes for details.");
|
|
123
|
+
initPlaintext();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
initialized = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function initPlaintext() {
|
|
130
|
+
if (fs.existsSync(paths.plaintext)) {
|
|
131
|
+
var loaded;
|
|
132
|
+
try {
|
|
133
|
+
loaded = safeJson.parse(atomicFile.readSync(paths.plaintext), {
|
|
134
|
+
schema: {
|
|
135
|
+
type: "object",
|
|
136
|
+
required: ["publicKey", "privateKey", "ecPublicKey", "ecPrivateKey"],
|
|
137
|
+
properties: {
|
|
138
|
+
publicKey: { type: "string" },
|
|
139
|
+
privateKey: { type: "string" },
|
|
140
|
+
ecPublicKey: { type: "string" },
|
|
141
|
+
ecPrivateKey: { type: "string" },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
} catch (e) {
|
|
146
|
+
log.error("FATAL: vault.key corrupted, unreadable, or schema-invalid at " + paths.plaintext + " — " + e.message);
|
|
147
|
+
log.error("All sealed data requires the original key. Restore from backup, then restart.");
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
keys = loaded;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// First run, plaintext mode
|
|
154
|
+
keys = generateEncryptionKeyPair();
|
|
155
|
+
atomicFile.writeSync(paths.plaintext, JSON.stringify(keys, null, 2), { fileMode: 0o600 });
|
|
156
|
+
log("plaintext vault keypair generated at " + paths.plaintext);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function initWrapped() {
|
|
160
|
+
log("unsealing vault.key.sealed...");
|
|
161
|
+
var sealedBytes;
|
|
162
|
+
try {
|
|
163
|
+
sealedBytes = atomicFile.readSync(paths.sealed);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
log.error("FATAL: cannot read " + paths.sealed + ": " + e.message);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
var passphrase;
|
|
170
|
+
try {
|
|
171
|
+
passphrase = await vaultPassphraseSource.getPassphrase({ prompt: "Vault passphrase: " });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
log.error("FATAL: " + e.message);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
var plaintextJson;
|
|
178
|
+
var plaintextBuf;
|
|
179
|
+
try {
|
|
180
|
+
plaintextBuf = await vaultWrap.unwrap(sealedBytes, passphrase);
|
|
181
|
+
plaintextJson = plaintextBuf.toString("utf8");
|
|
182
|
+
} catch (e) {
|
|
183
|
+
log.error("FATAL: passphrase rejected or sealed file corrupted (" + e.message + ")");
|
|
184
|
+
process.exit(1);
|
|
185
|
+
} finally {
|
|
186
|
+
// The Buffer holding the unwrapped key JSON is no longer needed once
|
|
187
|
+
// toString has copied the bytes into plaintextJson. The string itself
|
|
188
|
+
// is referenced by the JSON parser below; can't be zeroed (V8 strings
|
|
189
|
+
// are GC-managed). secureZero on the Buffer at least removes one
|
|
190
|
+
// copy of the secret from the heap.
|
|
191
|
+
if (plaintextBuf) safeBuffer.secureZero(plaintextBuf);
|
|
192
|
+
}
|
|
193
|
+
currentPassphrase = passphrase;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
keys = safeJson.parse(plaintextJson, {
|
|
197
|
+
schema: {
|
|
198
|
+
type: "object",
|
|
199
|
+
required: ["publicKey", "privateKey", "ecPublicKey", "ecPrivateKey"],
|
|
200
|
+
properties: {
|
|
201
|
+
publicKey: { type: "string" },
|
|
202
|
+
privateKey: { type: "string" },
|
|
203
|
+
ecPublicKey: { type: "string" },
|
|
204
|
+
ecPrivateKey: { type: "string" },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
} catch (e) {
|
|
209
|
+
log.error("FATAL: unwrapped vault key invalid: " + e.message);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
log("unsealed successfully.");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function initFirstRunWrapped() {
|
|
216
|
+
log("first run with mode: 'wrapped' — generating wrapped keypair...");
|
|
217
|
+
|
|
218
|
+
var passphrase;
|
|
219
|
+
try {
|
|
220
|
+
passphrase = await vaultPassphraseSource.getPassphrase({
|
|
221
|
+
prompt: "Choose a vault passphrase (loss = data loss, store it safely): ",
|
|
222
|
+
});
|
|
223
|
+
} catch (e) {
|
|
224
|
+
log.error("FATAL: " + e.message);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
currentPassphrase = passphrase;
|
|
228
|
+
|
|
229
|
+
keys = generateEncryptionKeyPair();
|
|
230
|
+
var plaintextJson = JSON.stringify(keys, null, 2);
|
|
231
|
+
var sealed;
|
|
232
|
+
try {
|
|
233
|
+
sealed = await vaultWrap.wrap(plaintextJson, passphrase);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
log.error("FATAL: failed to wrap new vault key: " + e.message);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Atomic write via the framework's atomic-file primitive (temp + fsync +
|
|
240
|
+
// rename + dir fsync — same flow this code used to inline manually).
|
|
241
|
+
atomicFile.writeSync(paths.sealed, sealed, { fileMode: 0o600 });
|
|
242
|
+
|
|
243
|
+
log("generated and sealed new vault keypair (ML-KEM-1024 + P-384 hybrid)");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- Sync API — operates against the populated cache ----
|
|
247
|
+
|
|
248
|
+
function _requireInit() {
|
|
249
|
+
if (!initialized) {
|
|
250
|
+
throw new Error("vault.init() must be awaited before vault.seal/unseal/getKeysJson");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function seal(plaintext) {
|
|
255
|
+
if (!plaintext) return plaintext;
|
|
256
|
+
if (String(plaintext).startsWith(VAULT_PREFIX)) return plaintext;
|
|
257
|
+
_requireInit();
|
|
258
|
+
return observability.tap("vault.seal", null, function () {
|
|
259
|
+
return VAULT_PREFIX + encrypt(String(plaintext), keys);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function unseal(value) {
|
|
264
|
+
if (!value || !String(value).startsWith(VAULT_PREFIX)) return value;
|
|
265
|
+
_requireInit();
|
|
266
|
+
return observability.tap("vault.unseal", null, function () {
|
|
267
|
+
var payload = String(value).substring(VAULT_PREFIX.length);
|
|
268
|
+
return decrypt(payload, keys);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function getKeysJson() {
|
|
273
|
+
_requireInit();
|
|
274
|
+
return JSON.stringify(keys, null, 2);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getCurrentPassphrase() {
|
|
278
|
+
return currentPassphrase;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getMode() {
|
|
282
|
+
return currentMode;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
init: init,
|
|
287
|
+
seal: seal,
|
|
288
|
+
unseal: unseal,
|
|
289
|
+
getKeysJson: getKeysJson,
|
|
290
|
+
getCurrentPassphrase: getCurrentPassphrase,
|
|
291
|
+
getMode: getMode,
|
|
292
|
+
// Testing helpers — not part of the public contract
|
|
293
|
+
_resetForTest: function () {
|
|
294
|
+
if (currentPassphrase) safeBuffer.secureZero(currentPassphrase);
|
|
295
|
+
keys = null; initialized = false; currentPassphrase = null; paths = null; currentMode = null;
|
|
296
|
+
},
|
|
297
|
+
_getKeysForTest: function () { return keys; },
|
|
298
|
+
_getPathsForTest: function () { return paths; },
|
|
299
|
+
};
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vault-passphrase-ops — seal / unseal / rotate the vault's passphrase wrap.
|
|
4
|
+
*
|
|
5
|
+
* Operator workflows the framework needs to support without forcing
|
|
6
|
+
* everyone through a CLI: turn a plaintext vault.key into a passphrase-
|
|
7
|
+
* wrapped vault.key.sealed (seal), the reverse (unseal), or change the
|
|
8
|
+
* passphrase on an already-sealed file (rotate). Crash-safe: each
|
|
9
|
+
* operation goes through .tmp + fsync + atomic rename so a power-loss
|
|
10
|
+
* mid-operation leaves either the OLD file or the NEW file intact, never
|
|
11
|
+
* a half-written one.
|
|
12
|
+
*
|
|
13
|
+
* var ops = b.vaultPassphraseOps;
|
|
14
|
+
*
|
|
15
|
+
* var pre = ops.preflightSealable({ dataDir });
|
|
16
|
+
* // → { ok: true } or { ok: false, reason: "..." }
|
|
17
|
+
*
|
|
18
|
+
* await ops.seal({ dataDir, passphrase, keepPlaintext: false });
|
|
19
|
+
* // → { sealedPath, plaintextDeleted }
|
|
20
|
+
*
|
|
21
|
+
* await ops.unseal({ dataDir, passphrase });
|
|
22
|
+
* // → { plaintextPath }
|
|
23
|
+
*
|
|
24
|
+
* await ops.rotate({ dataDir, oldPassphrase, newPassphrase });
|
|
25
|
+
* // → { sealedPath }
|
|
26
|
+
*
|
|
27
|
+
* Filenames the framework looks at (relative to dataDir):
|
|
28
|
+
* vault.key — plaintext keypair JSON
|
|
29
|
+
* vault.key.sealed — passphrase-wrapped keypair (Argon2id +
|
|
30
|
+
* XChaCha20-Poly1305, see lib/vault-wrap.js)
|
|
31
|
+
*
|
|
32
|
+
* Round-trip verification: every seal/unseal re-reads the .tmp it just
|
|
33
|
+
* wrote and confirms the bytes match before the atomic rename. If the
|
|
34
|
+
* filesystem reordered or corrupted the write, the operation aborts
|
|
35
|
+
* with the original file untouched.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
var fs = require("fs");
|
|
39
|
+
var path = require("path");
|
|
40
|
+
var atomicFile = require("../atomic-file");
|
|
41
|
+
var vaultWrap = require("./wrap");
|
|
42
|
+
var { defineClass } = require("../framework-error");
|
|
43
|
+
|
|
44
|
+
var VaultPassphraseError = defineClass("VaultPassphraseError", { alwaysPermanent: true });
|
|
45
|
+
|
|
46
|
+
var PLAINTEXT_NAME = "vault.key";
|
|
47
|
+
var SEALED_NAME = "vault.key.sealed";
|
|
48
|
+
|
|
49
|
+
function _paths(dataDir) {
|
|
50
|
+
return {
|
|
51
|
+
plaintext: path.join(dataDir, PLAINTEXT_NAME),
|
|
52
|
+
plaintextTmp: path.join(dataDir, PLAINTEXT_NAME + ".tmp"),
|
|
53
|
+
sealed: path.join(dataDir, SEALED_NAME),
|
|
54
|
+
sealedTmp: path.join(dataDir, SEALED_NAME + ".tmp"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _requireDataDir(opts) {
|
|
59
|
+
if (!opts || typeof opts.dataDir !== "string" || opts.dataDir.length === 0) {
|
|
60
|
+
throw new VaultPassphraseError("vault-passphrase/no-datadir",
|
|
61
|
+
"opts.dataDir is required (path to the framework data directory)");
|
|
62
|
+
}
|
|
63
|
+
if (!fs.existsSync(opts.dataDir)) {
|
|
64
|
+
throw new VaultPassphraseError("vault-passphrase/no-datadir",
|
|
65
|
+
"opts.dataDir does not exist: " + opts.dataDir);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _requirePassphrase(opts, fieldName) {
|
|
70
|
+
var name = fieldName || "passphrase";
|
|
71
|
+
if (!opts || !Buffer.isBuffer(opts[name])) {
|
|
72
|
+
throw new VaultPassphraseError("vault-passphrase/no-passphrase",
|
|
73
|
+
"opts." + name + " is required and must be a Buffer (the operator passphrase bytes)");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// fsync-by-path semantic: open then sync then close. atomicFile.fsync
|
|
78
|
+
// expects an already-open fd; this wrapper opens the file we just
|
|
79
|
+
// wrote, flushes its contents, and closes — the right shape when we
|
|
80
|
+
// don't have the original write fd around.
|
|
81
|
+
function _fsyncPath(p) {
|
|
82
|
+
try {
|
|
83
|
+
var fd = fs.openSync(p, "r+");
|
|
84
|
+
try { atomicFile.fsync(fd); } finally { fs.closeSync(fd); }
|
|
85
|
+
} catch (_e) { /* best-effort across filesystems */ }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- Pre-flight checks (no side effects) ----
|
|
89
|
+
|
|
90
|
+
function preflightSealable(opts) {
|
|
91
|
+
_requireDataDir(opts);
|
|
92
|
+
var p = _paths(opts.dataDir);
|
|
93
|
+
if (!fs.existsSync(p.plaintext)) {
|
|
94
|
+
return { ok: false, reason: "plaintext " + PLAINTEXT_NAME + " does not exist — nothing to seal" };
|
|
95
|
+
}
|
|
96
|
+
if (fs.existsSync(p.sealed)) {
|
|
97
|
+
return { ok: false, reason: SEALED_NAME + " already exists; refusing to overwrite" };
|
|
98
|
+
}
|
|
99
|
+
if (fs.existsSync(p.sealedTmp)) {
|
|
100
|
+
return { ok: false, reason: "stale " + SEALED_NAME + ".tmp from a previous crash; remove it manually after verifying the directory state" };
|
|
101
|
+
}
|
|
102
|
+
return { ok: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function preflightUnsealable(opts) {
|
|
106
|
+
_requireDataDir(opts);
|
|
107
|
+
var p = _paths(opts.dataDir);
|
|
108
|
+
if (!fs.existsSync(p.sealed)) {
|
|
109
|
+
return { ok: false, reason: SEALED_NAME + " does not exist — nothing to unseal" };
|
|
110
|
+
}
|
|
111
|
+
if (fs.existsSync(p.plaintext)) {
|
|
112
|
+
return { ok: false, reason: "plaintext " + PLAINTEXT_NAME + " already exists; refusing to overwrite" };
|
|
113
|
+
}
|
|
114
|
+
if (fs.existsSync(p.plaintextTmp)) {
|
|
115
|
+
return { ok: false, reason: "stale " + PLAINTEXT_NAME + ".tmp from a previous crash; remove it manually after verifying the directory state" };
|
|
116
|
+
}
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function preflightRotatable(opts) {
|
|
121
|
+
_requireDataDir(opts);
|
|
122
|
+
var p = _paths(opts.dataDir);
|
|
123
|
+
if (!fs.existsSync(p.sealed)) {
|
|
124
|
+
return { ok: false, reason: SEALED_NAME + " does not exist — rotate has nothing to operate on" };
|
|
125
|
+
}
|
|
126
|
+
if (fs.existsSync(p.sealedTmp)) {
|
|
127
|
+
return { ok: false, reason: "stale " + SEALED_NAME + ".tmp from a previous crash; remove it manually after verifying the directory state" };
|
|
128
|
+
}
|
|
129
|
+
return { ok: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- Seal: plaintext vault.key → vault.key.sealed ----
|
|
133
|
+
|
|
134
|
+
async function seal(opts) {
|
|
135
|
+
_requireDataDir(opts);
|
|
136
|
+
_requirePassphrase(opts, "passphrase");
|
|
137
|
+
var pre = preflightSealable(opts);
|
|
138
|
+
if (!pre.ok) {
|
|
139
|
+
throw new VaultPassphraseError("vault-passphrase/preflight-failed", pre.reason);
|
|
140
|
+
}
|
|
141
|
+
var p = _paths(opts.dataDir);
|
|
142
|
+
var keepPlaintext = !!opts.keepPlaintext;
|
|
143
|
+
|
|
144
|
+
var plainBytes = fs.readFileSync(p.plaintext);
|
|
145
|
+
var sealedBytes = await vaultWrap.wrap(plainBytes, opts.passphrase);
|
|
146
|
+
|
|
147
|
+
// Step 1: write sealed.tmp + fsync
|
|
148
|
+
fs.writeFileSync(p.sealedTmp, sealedBytes, { mode: 0o600 });
|
|
149
|
+
_fsyncPath(p.sealedTmp);
|
|
150
|
+
atomicFile.fsyncDir(opts.dataDir);
|
|
151
|
+
|
|
152
|
+
// Step 2: round-trip verify the .tmp before committing the rename
|
|
153
|
+
var verifyBytes = fs.readFileSync(p.sealedTmp);
|
|
154
|
+
var unwrapped;
|
|
155
|
+
try {
|
|
156
|
+
unwrapped = await vaultWrap.unwrap(verifyBytes, opts.passphrase);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
try { fs.unlinkSync(p.sealedTmp); } catch (_e) { /* cleanup */ }
|
|
159
|
+
throw new VaultPassphraseError("vault-passphrase/verify-failed",
|
|
160
|
+
"round-trip verification of sealed file failed: " + ((e && e.message) || String(e)) +
|
|
161
|
+
" — original " + PLAINTEXT_NAME + " is UNCHANGED");
|
|
162
|
+
}
|
|
163
|
+
if (Buffer.compare(unwrapped, plainBytes) !== 0) {
|
|
164
|
+
try { fs.unlinkSync(p.sealedTmp); } catch (_e) { /* cleanup */ }
|
|
165
|
+
throw new VaultPassphraseError("vault-passphrase/verify-mismatch",
|
|
166
|
+
"round-trip produced different bytes than the original — original " + PLAINTEXT_NAME +
|
|
167
|
+
" is UNCHANGED. Filesystem may be faulty.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Step 3: atomic rename sealed.tmp → sealed
|
|
171
|
+
fs.renameSync(p.sealedTmp, p.sealed);
|
|
172
|
+
atomicFile.fsyncDir(opts.dataDir);
|
|
173
|
+
|
|
174
|
+
// Step 4: delete plaintext (unless keepPlaintext)
|
|
175
|
+
if (!keepPlaintext) {
|
|
176
|
+
fs.unlinkSync(p.plaintext);
|
|
177
|
+
atomicFile.fsyncDir(opts.dataDir);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
sealedPath: p.sealed,
|
|
182
|
+
plaintextDeleted: !keepPlaintext,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- Unseal: vault.key.sealed → plaintext vault.key ----
|
|
187
|
+
|
|
188
|
+
async function unseal(opts) {
|
|
189
|
+
_requireDataDir(opts);
|
|
190
|
+
_requirePassphrase(opts, "passphrase");
|
|
191
|
+
var pre = preflightUnsealable(opts);
|
|
192
|
+
if (!pre.ok) {
|
|
193
|
+
throw new VaultPassphraseError("vault-passphrase/preflight-failed", pre.reason);
|
|
194
|
+
}
|
|
195
|
+
var p = _paths(opts.dataDir);
|
|
196
|
+
|
|
197
|
+
var sealedBytes = fs.readFileSync(p.sealed);
|
|
198
|
+
var plainBytes;
|
|
199
|
+
try {
|
|
200
|
+
plainBytes = await vaultWrap.unwrap(sealedBytes, opts.passphrase);
|
|
201
|
+
} catch (e) {
|
|
202
|
+
throw new VaultPassphraseError("vault-passphrase/passphrase-rejected",
|
|
203
|
+
"passphrase rejected: " + ((e && e.message) || String(e)) +
|
|
204
|
+
" — " + SEALED_NAME + " is UNCHANGED");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Step 1: write plaintext.tmp + fsync
|
|
208
|
+
fs.writeFileSync(p.plaintextTmp, plainBytes, { mode: 0o600 });
|
|
209
|
+
_fsyncPath(p.plaintextTmp);
|
|
210
|
+
atomicFile.fsyncDir(opts.dataDir);
|
|
211
|
+
|
|
212
|
+
// Step 2: round-trip sanity — re-read tmp and verify
|
|
213
|
+
var verifyBytes = fs.readFileSync(p.plaintextTmp);
|
|
214
|
+
if (Buffer.compare(verifyBytes, plainBytes) !== 0) {
|
|
215
|
+
try { fs.unlinkSync(p.plaintextTmp); } catch (_e) { /* cleanup */ }
|
|
216
|
+
throw new VaultPassphraseError("vault-passphrase/verify-mismatch",
|
|
217
|
+
"plaintext.tmp re-read differs from in-memory bytes — filesystem may be faulty. " +
|
|
218
|
+
SEALED_NAME + " is UNCHANGED");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Step 3: atomic rename plaintext.tmp → plaintext
|
|
222
|
+
fs.renameSync(p.plaintextTmp, p.plaintext);
|
|
223
|
+
atomicFile.fsyncDir(opts.dataDir);
|
|
224
|
+
|
|
225
|
+
// Step 4: delete sealed file
|
|
226
|
+
fs.unlinkSync(p.sealed);
|
|
227
|
+
atomicFile.fsyncDir(opts.dataDir);
|
|
228
|
+
|
|
229
|
+
return { plaintextPath: p.plaintext };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---- Rotate: change passphrase on an already-sealed file ----
|
|
233
|
+
//
|
|
234
|
+
// Implemented as unwrap-with-old + wrap-with-new + atomic-rename. The
|
|
235
|
+
// underlying vault keypair bytes are unchanged — only the passphrase
|
|
236
|
+
// wrapping (Argon2id KDF + XChaCha20-Poly1305 nonce/key) rotates.
|
|
237
|
+
|
|
238
|
+
async function rotate(opts) {
|
|
239
|
+
_requireDataDir(opts);
|
|
240
|
+
_requirePassphrase(opts, "oldPassphrase");
|
|
241
|
+
_requirePassphrase(opts, "newPassphrase");
|
|
242
|
+
var pre = preflightRotatable(opts);
|
|
243
|
+
if (!pre.ok) {
|
|
244
|
+
throw new VaultPassphraseError("vault-passphrase/preflight-failed", pre.reason);
|
|
245
|
+
}
|
|
246
|
+
var p = _paths(opts.dataDir);
|
|
247
|
+
|
|
248
|
+
var sealedBytes = fs.readFileSync(p.sealed);
|
|
249
|
+
var plainBytes;
|
|
250
|
+
try {
|
|
251
|
+
plainBytes = await vaultWrap.unwrap(sealedBytes, opts.oldPassphrase);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
throw new VaultPassphraseError("vault-passphrase/passphrase-rejected",
|
|
254
|
+
"old passphrase rejected: " + ((e && e.message) || String(e)) +
|
|
255
|
+
" — " + SEALED_NAME + " is UNCHANGED");
|
|
256
|
+
}
|
|
257
|
+
var newSealedBytes = await vaultWrap.wrap(plainBytes, opts.newPassphrase);
|
|
258
|
+
|
|
259
|
+
// Step 1: write new sealed.tmp + fsync
|
|
260
|
+
fs.writeFileSync(p.sealedTmp, newSealedBytes, { mode: 0o600 });
|
|
261
|
+
_fsyncPath(p.sealedTmp);
|
|
262
|
+
atomicFile.fsyncDir(opts.dataDir);
|
|
263
|
+
|
|
264
|
+
// Step 2: round-trip verify with NEW passphrase, AND assert unwrap
|
|
265
|
+
// with the OLD passphrase fails — otherwise the rotation didn't take.
|
|
266
|
+
var verifyBytes = fs.readFileSync(p.sealedTmp);
|
|
267
|
+
var verifyPlain;
|
|
268
|
+
try { verifyPlain = await vaultWrap.unwrap(verifyBytes, opts.newPassphrase); }
|
|
269
|
+
catch (e) {
|
|
270
|
+
try { fs.unlinkSync(p.sealedTmp); } catch (_e) { /* cleanup */ }
|
|
271
|
+
throw new VaultPassphraseError("vault-passphrase/verify-failed",
|
|
272
|
+
"round-trip with new passphrase failed: " + ((e && e.message) || String(e)) +
|
|
273
|
+
" — " + SEALED_NAME + " is UNCHANGED");
|
|
274
|
+
}
|
|
275
|
+
if (Buffer.compare(verifyPlain, plainBytes) !== 0) {
|
|
276
|
+
try { fs.unlinkSync(p.sealedTmp); } catch (_e) { /* cleanup */ }
|
|
277
|
+
throw new VaultPassphraseError("vault-passphrase/verify-mismatch",
|
|
278
|
+
"rotated sealed file decrypts under new passphrase but to different bytes — " +
|
|
279
|
+
SEALED_NAME + " is UNCHANGED. Filesystem may be faulty.");
|
|
280
|
+
}
|
|
281
|
+
// Best-effort regression check: the same bytes must NOT unwrap under
|
|
282
|
+
// the old passphrase. If they do, the wrap library handed back the
|
|
283
|
+
// input unchanged — refuse to commit.
|
|
284
|
+
try {
|
|
285
|
+
await vaultWrap.unwrap(verifyBytes, opts.oldPassphrase);
|
|
286
|
+
try { fs.unlinkSync(p.sealedTmp); } catch (_e) { /* cleanup */ }
|
|
287
|
+
throw new VaultPassphraseError("vault-passphrase/rotate-noop",
|
|
288
|
+
"old passphrase still unwraps the new sealed bytes — rotation did not take effect");
|
|
289
|
+
} catch (e) {
|
|
290
|
+
if (e && e.code === "vault-passphrase/rotate-noop") throw e;
|
|
291
|
+
// any other error means old passphrase fails on new sealed → expected
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Step 3: atomic rename — swap in the new sealed file
|
|
295
|
+
fs.renameSync(p.sealedTmp, p.sealed);
|
|
296
|
+
atomicFile.fsyncDir(opts.dataDir);
|
|
297
|
+
|
|
298
|
+
return { sealedPath: p.sealed };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
preflightSealable: preflightSealable,
|
|
303
|
+
preflightUnsealable: preflightUnsealable,
|
|
304
|
+
preflightRotatable: preflightRotatable,
|
|
305
|
+
seal: seal,
|
|
306
|
+
unseal: unseal,
|
|
307
|
+
rotate: rotate,
|
|
308
|
+
VaultPassphraseError: VaultPassphraseError,
|
|
309
|
+
PLAINTEXT_NAME: PLAINTEXT_NAME,
|
|
310
|
+
SEALED_NAME: SEALED_NAME,
|
|
311
|
+
};
|