@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/LICENSE +201 -0
  3. package/LTS-CALENDAR.md +29 -0
  4. package/MIGRATING.md +7 -0
  5. package/NOTICE +59 -0
  6. package/README.md +100 -0
  7. package/bin/blamejs.js +13 -0
  8. package/index.js +253 -0
  9. package/lib/api-key.js +705 -0
  10. package/lib/api-snapshot.js +335 -0
  11. package/lib/app-shutdown.js +381 -0
  12. package/lib/app.js +364 -0
  13. package/lib/atomic-file.js +525 -0
  14. package/lib/audit-chain.js +168 -0
  15. package/lib/audit-sign.js +319 -0
  16. package/lib/audit-tools.js +682 -0
  17. package/lib/audit.js +753 -0
  18. package/lib/auth/jwt.js +280 -0
  19. package/lib/auth/oauth.js +691 -0
  20. package/lib/auth/passkey.js +185 -0
  21. package/lib/auth/password.js +139 -0
  22. package/lib/auth/totp.js +17 -0
  23. package/lib/auth-header.js +81 -0
  24. package/lib/backup/bundle.js +219 -0
  25. package/lib/backup/crypto.js +174 -0
  26. package/lib/backup/index.js +490 -0
  27. package/lib/backup/manifest.js +275 -0
  28. package/lib/bundler.js +295 -0
  29. package/lib/cache.js +819 -0
  30. package/lib/chain-writer.js +234 -0
  31. package/lib/cli-helpers.js +201 -0
  32. package/lib/cli.js +1377 -0
  33. package/lib/cluster-provider-db.js +245 -0
  34. package/lib/cluster-storage.js +166 -0
  35. package/lib/cluster.js +691 -0
  36. package/lib/consent.js +222 -0
  37. package/lib/constants.js +186 -0
  38. package/lib/cookies.js +293 -0
  39. package/lib/credential-hash.js +303 -0
  40. package/lib/crypto-field.js +159 -0
  41. package/lib/crypto.js +250 -0
  42. package/lib/db-query.js +297 -0
  43. package/lib/db-schema.js +250 -0
  44. package/lib/db.js +1054 -0
  45. package/lib/deprecate.js +226 -0
  46. package/lib/dev.js +324 -0
  47. package/lib/error-page.js +424 -0
  48. package/lib/events.js +135 -0
  49. package/lib/external-db.js +422 -0
  50. package/lib/forms.js +378 -0
  51. package/lib/framework-error.js +189 -0
  52. package/lib/framework-schema.js +604 -0
  53. package/lib/handlers.js +350 -0
  54. package/lib/html-balance.js +227 -0
  55. package/lib/http-client.js +615 -0
  56. package/lib/i18n.js +780 -0
  57. package/lib/jobs.js +181 -0
  58. package/lib/lazy-require.js +48 -0
  59. package/lib/log-stream-local.js +137 -0
  60. package/lib/log-stream-webhook.js +170 -0
  61. package/lib/log-stream.js +211 -0
  62. package/lib/log.js +355 -0
  63. package/lib/mail-bounce.js +507 -0
  64. package/lib/mail.js +701 -0
  65. package/lib/metrics.js +647 -0
  66. package/lib/middleware/api-encrypt.js +553 -0
  67. package/lib/middleware/attach-user.js +156 -0
  68. package/lib/middleware/body-parser.js +883 -0
  69. package/lib/middleware/bot-guard.js +148 -0
  70. package/lib/middleware/compression.js +436 -0
  71. package/lib/middleware/cors.js +236 -0
  72. package/lib/middleware/csp-nonce.js +332 -0
  73. package/lib/middleware/csrf-protect.js +275 -0
  74. package/lib/middleware/error-handler.js +46 -0
  75. package/lib/middleware/health.js +358 -0
  76. package/lib/middleware/index.js +52 -0
  77. package/lib/middleware/rate-limit.js +319 -0
  78. package/lib/middleware/request-id.js +53 -0
  79. package/lib/middleware/require-auth.js +95 -0
  80. package/lib/middleware/security-headers.js +91 -0
  81. package/lib/migrations.js +353 -0
  82. package/lib/mtls-ca.js +333 -0
  83. package/lib/mtls-engine-default.js +285 -0
  84. package/lib/nonce-store.js +177 -0
  85. package/lib/notify.js +643 -0
  86. package/lib/ntp-check.js +178 -0
  87. package/lib/object-store/azure-blob.js +467 -0
  88. package/lib/object-store/gcs.js +469 -0
  89. package/lib/object-store/http-put.js +153 -0
  90. package/lib/object-store/index.js +140 -0
  91. package/lib/object-store/local.js +163 -0
  92. package/lib/object-store/retry.js +15 -0
  93. package/lib/object-store/sigv4.js +535 -0
  94. package/lib/observability.js +114 -0
  95. package/lib/pagination.js +371 -0
  96. package/lib/parsers/index.js +64 -0
  97. package/lib/parsers/safe-csv.js +224 -0
  98. package/lib/parsers/safe-env.js +614 -0
  99. package/lib/parsers/safe-toml.js +745 -0
  100. package/lib/parsers/safe-xml.js +379 -0
  101. package/lib/parsers/safe-yaml.js +977 -0
  102. package/lib/permissions.js +430 -0
  103. package/lib/pqc-agent.js +85 -0
  104. package/lib/pqc-gate.js +266 -0
  105. package/lib/protocol-dispatcher.js +144 -0
  106. package/lib/queue-local.js +327 -0
  107. package/lib/queue.js +430 -0
  108. package/lib/redact.js +192 -0
  109. package/lib/render.js +193 -0
  110. package/lib/request-helpers.js +178 -0
  111. package/lib/restore-bundle.js +239 -0
  112. package/lib/restore-rollback.js +254 -0
  113. package/lib/restore.js +301 -0
  114. package/lib/retry.js +329 -0
  115. package/lib/router.js +437 -0
  116. package/lib/safe-async.js +520 -0
  117. package/lib/safe-buffer.js +162 -0
  118. package/lib/safe-json.js +532 -0
  119. package/lib/safe-schema.js +1176 -0
  120. package/lib/safe-sql.js +157 -0
  121. package/lib/safe-url.js +109 -0
  122. package/lib/scheduler.js +680 -0
  123. package/lib/seeders.js +622 -0
  124. package/lib/session.js +304 -0
  125. package/lib/slug.js +243 -0
  126. package/lib/static.js +268 -0
  127. package/lib/storage.js +470 -0
  128. package/lib/subject.js +281 -0
  129. package/lib/template.js +781 -0
  130. package/lib/testing.js +621 -0
  131. package/lib/totp.js +285 -0
  132. package/lib/tracing.js +484 -0
  133. package/lib/validate-opts.js +56 -0
  134. package/lib/vault/index.js +299 -0
  135. package/lib/vault/passphrase-ops.js +311 -0
  136. package/lib/vault/passphrase-source.js +198 -0
  137. package/lib/vault/rotate.js +761 -0
  138. package/lib/vault/wrap.js +289 -0
  139. package/lib/vendor/MANIFEST.json +84 -0
  140. package/lib/vendor/argon2/argon2.cjs +466 -0
  141. package/lib/vendor/argon2/argon2.d.cts +62 -0
  142. package/lib/vendor/argon2/package.json +1 -0
  143. package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
  144. package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
  145. package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
  146. package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
  147. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
  148. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
  149. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
  150. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
  151. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
  152. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
  153. package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
  154. package/lib/vendor/noble-ciphers.cjs +9 -0
  155. package/lib/vendor/pki.cjs +181 -0
  156. package/lib/vendor/simplewebauthn-server.cjs +328 -0
  157. package/lib/webhook.js +632 -0
  158. package/lib/websocket-channels.js +413 -0
  159. package/lib/websocket.js +833 -0
  160. 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
+ };