@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,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Passkey / WebAuthn (FIDO2) — registration + authentication primitives.
|
|
4
|
+
*
|
|
5
|
+
* Built on the vendored @simplewebauthn/server (lib/vendor/simplewebauthn-
|
|
6
|
+
* server.cjs). This file is a thin wrapper that names the surface in the
|
|
7
|
+
* framework's auth-namespace style and validates inputs through
|
|
8
|
+
* AuthError so failures are framed consistently with auth.password and
|
|
9
|
+
* auth.totp. The vendor handles CBOR parsing, attestation statement
|
|
10
|
+
* validation, COSE key conversion, and signature verification across
|
|
11
|
+
* the WebAuthn algorithm set; we don't reimplement any of it.
|
|
12
|
+
*
|
|
13
|
+
* The four phases of a WebAuthn flow:
|
|
14
|
+
*
|
|
15
|
+
* Registration (user adds a passkey to their account):
|
|
16
|
+
* 1. server: startRegistration({ rpName, rpId, userName, userDisplayName,
|
|
17
|
+
* excludeCredentials? }) → { challenge, … }. Server stores the
|
|
18
|
+
* challenge in the session.
|
|
19
|
+
* 2. browser: navigator.credentials.create({ publicKey: <options> })
|
|
20
|
+
* → registration response with attestation.
|
|
21
|
+
* 3. server: verifyRegistration({ response, expectedChallenge,
|
|
22
|
+
* expectedOrigin, expectedRPID }) → { verified, registrationInfo:
|
|
23
|
+
* { credential: { id, publicKey, counter }, … } }. Persist
|
|
24
|
+
* credential.id (base64url) + credential.publicKey + counter.
|
|
25
|
+
*
|
|
26
|
+
* Authentication (user logs in with their passkey):
|
|
27
|
+
* 1. server: startAuthentication({ rpId, userVerification? })
|
|
28
|
+
* → { challenge, … }. Server stores challenge in session.
|
|
29
|
+
* 2. browser: navigator.credentials.get({ publicKey: <options> })
|
|
30
|
+
* → assertion response.
|
|
31
|
+
* 3. server: lookup the credential by response.id (base64url),
|
|
32
|
+
* then verifyAuthentication({ response, expectedChallenge,
|
|
33
|
+
* expectedOrigin, expectedRPID, credential: { id, publicKey,
|
|
34
|
+
* counter, transports? } }) → { verified, authenticationInfo:
|
|
35
|
+
* { newCounter } }. Persist newCounter (clone-detection).
|
|
36
|
+
*
|
|
37
|
+
* Public API (b.auth.passkey.*):
|
|
38
|
+
* await passkey.startRegistration(opts) → registration options
|
|
39
|
+
* await passkey.verifyRegistration(opts) → { verified, registrationInfo? }
|
|
40
|
+
* await passkey.startAuthentication(opts) → authentication options
|
|
41
|
+
* await passkey.verifyAuthentication(opts) → { verified, authenticationInfo? }
|
|
42
|
+
*
|
|
43
|
+
* Framework defaults:
|
|
44
|
+
* - attestationType: "none" — don't request attestation. Most apps
|
|
45
|
+
* don't need it, and "direct" or "enterprise" attestation has
|
|
46
|
+
* deployment friction (cert chains, MDS lookups). Operators who
|
|
47
|
+
* genuinely need attestation override.
|
|
48
|
+
* - residentKey: "preferred" — discoverable credentials when the
|
|
49
|
+
* authenticator supports them; falls back to non-discoverable.
|
|
50
|
+
* - userVerification: "preferred" — accept biometric / PIN when
|
|
51
|
+
* available; fall back to presence-only.
|
|
52
|
+
* - hints: ["client-device", "hybrid"] — surface platform
|
|
53
|
+
* authenticators (Touch ID, Windows Hello) AND cross-device
|
|
54
|
+
* (1Password / Bitwarden / mobile-as-roaming-authenticator).
|
|
55
|
+
*
|
|
56
|
+
* No middleware decisions made here — the wrapper does NOT touch
|
|
57
|
+
* sessions, audit, or DB. Routes integrate that themselves; the
|
|
58
|
+
* primitive stays the smallest correct surface.
|
|
59
|
+
*/
|
|
60
|
+
var { AuthError } = require("../framework-error");
|
|
61
|
+
|
|
62
|
+
// Lazy require: the bundle is ~400 KB and not every blamejs deployment
|
|
63
|
+
// uses passkeys. Loading on first call keeps boot fast for non-passkey
|
|
64
|
+
// apps without forcing operators to gate the import behind a flag.
|
|
65
|
+
var _wa = null;
|
|
66
|
+
function _vendor() {
|
|
67
|
+
if (!_wa) _wa = require("../vendor/simplewebauthn-server.cjs");
|
|
68
|
+
return _wa;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _requireString(v, name) {
|
|
72
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
73
|
+
throw new AuthError("auth-passkey/missing-" + name,
|
|
74
|
+
name + " is required (non-empty string)");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- Registration ----
|
|
79
|
+
|
|
80
|
+
async function startRegistration(opts) {
|
|
81
|
+
if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
|
|
82
|
+
_requireString(opts.rpName, "rpName");
|
|
83
|
+
_requireString(opts.rpId, "rpId");
|
|
84
|
+
_requireString(opts.userName, "userName");
|
|
85
|
+
|
|
86
|
+
var sel = opts.authenticatorSelection || {};
|
|
87
|
+
var options = await _vendor().generateRegistrationOptions({
|
|
88
|
+
rpName: opts.rpName,
|
|
89
|
+
rpID: opts.rpId,
|
|
90
|
+
userName: opts.userName,
|
|
91
|
+
userDisplayName: opts.userDisplayName || opts.userName,
|
|
92
|
+
attestationType: opts.attestationType || "none",
|
|
93
|
+
excludeCredentials: opts.excludeCredentials || [],
|
|
94
|
+
authenticatorSelection: {
|
|
95
|
+
residentKey: sel.residentKey || "preferred",
|
|
96
|
+
userVerification: sel.userVerification || "preferred",
|
|
97
|
+
authenticatorAttachment: sel.authenticatorAttachment,
|
|
98
|
+
requireResidentKey: sel.requireResidentKey,
|
|
99
|
+
},
|
|
100
|
+
timeout: opts.timeout,
|
|
101
|
+
extensions: opts.extensions,
|
|
102
|
+
});
|
|
103
|
+
// Hint the browser to surface platform + cross-device authenticators
|
|
104
|
+
// (Touch ID / Windows Hello AND 1Password / Bitwarden / phone-as-key).
|
|
105
|
+
if (!opts.hints) {
|
|
106
|
+
options.hints = ["client-device", "hybrid"];
|
|
107
|
+
} else {
|
|
108
|
+
options.hints = opts.hints;
|
|
109
|
+
}
|
|
110
|
+
return options;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function verifyRegistration(opts) {
|
|
114
|
+
if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
|
|
115
|
+
if (!opts.response) {
|
|
116
|
+
throw new AuthError("auth-passkey/missing-response", "opts.response is required");
|
|
117
|
+
}
|
|
118
|
+
_requireString(opts.expectedChallenge, "expectedChallenge");
|
|
119
|
+
_requireString(opts.expectedOrigin, "expectedOrigin");
|
|
120
|
+
_requireString(opts.expectedRPID, "expectedRPID");
|
|
121
|
+
|
|
122
|
+
return await _vendor().verifyRegistrationResponse({
|
|
123
|
+
response: opts.response,
|
|
124
|
+
expectedChallenge: opts.expectedChallenge,
|
|
125
|
+
expectedOrigin: opts.expectedOrigin,
|
|
126
|
+
expectedRPID: opts.expectedRPID,
|
|
127
|
+
requireUserVerification: opts.requireUserVerification !== false,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- Authentication ----
|
|
132
|
+
|
|
133
|
+
async function startAuthentication(opts) {
|
|
134
|
+
if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
|
|
135
|
+
_requireString(opts.rpId, "rpId");
|
|
136
|
+
|
|
137
|
+
var options = await _vendor().generateAuthenticationOptions({
|
|
138
|
+
rpID: opts.rpId,
|
|
139
|
+
userVerification: opts.userVerification || "preferred",
|
|
140
|
+
allowCredentials: opts.allowCredentials || [],
|
|
141
|
+
timeout: opts.timeout,
|
|
142
|
+
extensions: opts.extensions,
|
|
143
|
+
});
|
|
144
|
+
if (!opts.hints) {
|
|
145
|
+
options.hints = ["client-device", "hybrid"];
|
|
146
|
+
} else {
|
|
147
|
+
options.hints = opts.hints;
|
|
148
|
+
}
|
|
149
|
+
return options;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function verifyAuthentication(opts) {
|
|
153
|
+
if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
|
|
154
|
+
if (!opts.response) {
|
|
155
|
+
throw new AuthError("auth-passkey/missing-response", "opts.response is required");
|
|
156
|
+
}
|
|
157
|
+
_requireString(opts.expectedChallenge, "expectedChallenge");
|
|
158
|
+
_requireString(opts.expectedOrigin, "expectedOrigin");
|
|
159
|
+
_requireString(opts.expectedRPID, "expectedRPID");
|
|
160
|
+
if (!opts.credential || !opts.credential.id || !opts.credential.publicKey) {
|
|
161
|
+
throw new AuthError("auth-passkey/missing-credential",
|
|
162
|
+
"opts.credential { id, publicKey, counter? } is required");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return await _vendor().verifyAuthenticationResponse({
|
|
166
|
+
response: opts.response,
|
|
167
|
+
expectedChallenge: opts.expectedChallenge,
|
|
168
|
+
expectedOrigin: opts.expectedOrigin,
|
|
169
|
+
expectedRPID: opts.expectedRPID,
|
|
170
|
+
credential: {
|
|
171
|
+
id: opts.credential.id,
|
|
172
|
+
publicKey: opts.credential.publicKey,
|
|
173
|
+
counter: opts.credential.counter || 0,
|
|
174
|
+
transports: opts.credential.transports,
|
|
175
|
+
},
|
|
176
|
+
requireUserVerification: opts.requireUserVerification !== false,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
startRegistration: startRegistration,
|
|
182
|
+
verifyRegistration: verifyRegistration,
|
|
183
|
+
startAuthentication: startAuthentication,
|
|
184
|
+
verifyAuthentication: verifyAuthentication,
|
|
185
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Argon2id password hashing — public framework primitive.
|
|
4
|
+
*
|
|
5
|
+
* Different concern from lib/vault-wrap.js, which also uses Argon2id but
|
|
6
|
+
* for KEK derivation (the output is a KEY used to AEAD-wrap the vault
|
|
7
|
+
* file). This module is for application-layer password storage: the
|
|
8
|
+
* output is a verifiable digest in PHC format, never decrypted, used
|
|
9
|
+
* for "is this the password the user originally set?".
|
|
10
|
+
*
|
|
11
|
+
* Public API:
|
|
12
|
+
*
|
|
13
|
+
* await auth.password.hash(plain, opts?) → string (PHC format)
|
|
14
|
+
* await auth.password.verify(hash, plain) → boolean
|
|
15
|
+
* auth.password.needsRehash(hash, opts?) → boolean
|
|
16
|
+
*
|
|
17
|
+
* The PHC string captures the algorithm + parameters + salt + digest:
|
|
18
|
+
*
|
|
19
|
+
* $argon2id$v=19$m=65536,t=3,p=4$<base64-salt>$<base64-hash>
|
|
20
|
+
*
|
|
21
|
+
* That string is what callers store in the database. verify() parses
|
|
22
|
+
* it to recover the parameters, recomputes the digest with the
|
|
23
|
+
* supplied plaintext, and timing-safe compares.
|
|
24
|
+
*
|
|
25
|
+
* Defaults (memoryCost=64 MiB, timeCost=3, parallelism=4) target
|
|
26
|
+
* ~250–500ms on commodity 2026 hardware — painful for offline brute
|
|
27
|
+
* force, tolerable inside a login latency budget. Operators with
|
|
28
|
+
* tighter budgets (or beefier hardware) tune via opts; needsRehash
|
|
29
|
+
* surfaces when stored hashes lag behind the current defaults so the
|
|
30
|
+
* caller can transparently rehash on next successful login.
|
|
31
|
+
*
|
|
32
|
+
* Validation posture:
|
|
33
|
+
* - plain must be a non-empty string. Empty/whitespace passwords
|
|
34
|
+
* are operator bugs (UI should reject) — failing here surfaces
|
|
35
|
+
* them before they hit the DB.
|
|
36
|
+
* - plain length is capped at 4096 bytes (UTF-8). Same cap as the
|
|
37
|
+
* vault-wrap passphrase. A 5 GiB string fed to Argon2 would peg
|
|
38
|
+
* the box for minutes; the cap is sanity, not security.
|
|
39
|
+
* - hash must be a non-empty string starting with `$argon2id$`.
|
|
40
|
+
* Other Argon2 variants (`$argon2i$` / `$argon2d$`) are out of
|
|
41
|
+
* spec for this framework — verify() returns false rather than
|
|
42
|
+
* attempting to validate them. Callers using needsRehash on a
|
|
43
|
+
* non-id hash get true (forces rehash on next login).
|
|
44
|
+
*
|
|
45
|
+
* Errors are AuthError(code, message) with permanent=true. A failed
|
|
46
|
+
* verify is NOT an error — it returns false. Errors are reserved for
|
|
47
|
+
* "the call shape was wrong" (empty plain, oversize plain).
|
|
48
|
+
*/
|
|
49
|
+
var argon2 = require("../vendor/argon2");
|
|
50
|
+
var { AuthError } = require("../framework-error");
|
|
51
|
+
|
|
52
|
+
// Tuning targets ~250–500ms on commodity 2026 hardware. memoryCost
|
|
53
|
+
// is in KiB per Argon2's parameter convention.
|
|
54
|
+
var DEFAULT_PARAMS = Object.freeze({
|
|
55
|
+
memoryCost: 65536, // 64 MiB
|
|
56
|
+
timeCost: 3,
|
|
57
|
+
parallelism: 4,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
var MAX_PLAINTEXT_BYTES = 4096;
|
|
61
|
+
|
|
62
|
+
function _validatePlain(plain) {
|
|
63
|
+
if (typeof plain !== "string" || plain.length === 0) {
|
|
64
|
+
throw new AuthError("auth-password/invalid-plain",
|
|
65
|
+
"auth.password.hash requires a non-empty string");
|
|
66
|
+
}
|
|
67
|
+
if (Buffer.byteLength(plain, "utf8") > MAX_PLAINTEXT_BYTES) {
|
|
68
|
+
throw new AuthError("auth-password/plain-too-large",
|
|
69
|
+
"plaintext exceeds " + MAX_PLAINTEXT_BYTES + " bytes (UTF-8)");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _resolveParams(opts) {
|
|
74
|
+
var p = Object.assign({}, DEFAULT_PARAMS, opts || {});
|
|
75
|
+
if (typeof p.memoryCost !== "number" || p.memoryCost < 1024) {
|
|
76
|
+
throw new AuthError("auth-password/bad-params",
|
|
77
|
+
"memoryCost must be >= 1024 KiB (1 MiB)");
|
|
78
|
+
}
|
|
79
|
+
if (typeof p.timeCost !== "number" || p.timeCost < 1) {
|
|
80
|
+
throw new AuthError("auth-password/bad-params", "timeCost must be >= 1");
|
|
81
|
+
}
|
|
82
|
+
if (typeof p.parallelism !== "number" || p.parallelism < 1) {
|
|
83
|
+
throw new AuthError("auth-password/bad-params", "parallelism must be >= 1");
|
|
84
|
+
}
|
|
85
|
+
return p;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function hash(plain, opts) {
|
|
89
|
+
_validatePlain(plain);
|
|
90
|
+
var p = _resolveParams(opts);
|
|
91
|
+
return await argon2.hash(plain, {
|
|
92
|
+
type: argon2.argon2id,
|
|
93
|
+
memoryCost: p.memoryCost,
|
|
94
|
+
timeCost: p.timeCost,
|
|
95
|
+
parallelism: p.parallelism,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function verify(stored, plain) {
|
|
100
|
+
// verify intentionally tolerates malformed input by returning false
|
|
101
|
+
// rather than throwing — login flows already treat false as "credentials
|
|
102
|
+
// didn't match" and shouldn't have to wrap each call in try/catch.
|
|
103
|
+
if (typeof stored !== "string" || stored.length === 0) return false;
|
|
104
|
+
if (typeof plain !== "string" || plain.length === 0) return false;
|
|
105
|
+
if (!stored.indexOf || stored.indexOf("$argon2id$") !== 0) return false;
|
|
106
|
+
if (Buffer.byteLength(plain, "utf8") > MAX_PLAINTEXT_BYTES) return false;
|
|
107
|
+
try {
|
|
108
|
+
return await argon2.verify(stored, plain);
|
|
109
|
+
} catch (_e) {
|
|
110
|
+
// PHC-string parse failures from the vendor surface as throws —
|
|
111
|
+
// treat as "doesn't match" so a corrupted DB column can't break
|
|
112
|
+
// login flows with an unexpected exception type.
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function needsRehash(stored, opts) {
|
|
118
|
+
if (typeof stored !== "string" || stored.indexOf("$argon2id$") !== 0) {
|
|
119
|
+
// Non-id variant or malformed — force rehash on next successful login
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
var p = _resolveParams(opts);
|
|
123
|
+
try {
|
|
124
|
+
return argon2.needsRehash(stored, {
|
|
125
|
+
memoryCost: p.memoryCost,
|
|
126
|
+
timeCost: p.timeCost,
|
|
127
|
+
parallelism: p.parallelism,
|
|
128
|
+
});
|
|
129
|
+
} catch (_e) {
|
|
130
|
+
return true; // unparseable → rehash
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
hash: hash,
|
|
136
|
+
verify: verify,
|
|
137
|
+
needsRehash: needsRehash,
|
|
138
|
+
DEFAULT_PARAMS: DEFAULT_PARAMS,
|
|
139
|
+
};
|
package/lib/auth/totp.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* auth.totp — auth-namespace bridge to lib/totp.js.
|
|
4
|
+
*
|
|
5
|
+
* The TOTP primitive itself is in lib/totp.js so it can be used for
|
|
6
|
+
* non-auth time-based codes (idempotency tokens, signed-link freshness
|
|
7
|
+
* windows, etc.). This file exposes the same surface under the
|
|
8
|
+
* framework's `auth.*` namespace so application code reaches it via
|
|
9
|
+
* the natural auth-feature path: `b.auth.totp.generateSecret()`.
|
|
10
|
+
*
|
|
11
|
+
* No additional logic — this is a re-export. Keeping it as a separate
|
|
12
|
+
* module (rather than `auth.totp = require("../totp")`) makes the
|
|
13
|
+
* dependency graph explicit and gives a place to layer auth-specific
|
|
14
|
+
* defaults later (e.g. an auth-namespace-only step/algorithm policy)
|
|
15
|
+
* without touching the underlying primitive.
|
|
16
|
+
*/
|
|
17
|
+
module.exports = require("../totp");
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* auth-header — construct HTTP Authorization headers for the framework's
|
|
4
|
+
* outbound consumers.
|
|
5
|
+
*
|
|
6
|
+
* The previous shape was `_authHeaders(config)` reimplemented in three
|
|
7
|
+
* places (log-stream-webhook, object-store-http-put, object-store-gcs).
|
|
8
|
+
* Each handled the same { auth, token, username, password } shape with
|
|
9
|
+
* subtly different argument names and case semantics. This module
|
|
10
|
+
* collapses the construction.
|
|
11
|
+
*
|
|
12
|
+
* Public API:
|
|
13
|
+
*
|
|
14
|
+
* bearer(token) → { Authorization: "Bearer <token>" }
|
|
15
|
+
* basic(username, password) → { Authorization: "Basic <b64>" }
|
|
16
|
+
* fromConfig({ auth, token, ... }) → headers object for the named auth method
|
|
17
|
+
*
|
|
18
|
+
* fromConfig accepts:
|
|
19
|
+
*
|
|
20
|
+
* { auth: "none" } → {}
|
|
21
|
+
* { auth: "bearer", token } → bearer(token)
|
|
22
|
+
* { auth: "basic", username, password } → basic(username, password)
|
|
23
|
+
*
|
|
24
|
+
* Anything else throws AuthHeaderError. The "header" pass-through mode
|
|
25
|
+
* that consumers used to wedge into _authHeaders is NOT auth-header's job
|
|
26
|
+
* — that's just header merging. Consumers do `Object.assign({}, config.headers)`
|
|
27
|
+
* themselves and combine the two layers at the call site.
|
|
28
|
+
*
|
|
29
|
+
* Why this is a separate primitive vs. a helper inside http-client:
|
|
30
|
+
* - Some callers (log-stream-local) might emit auth headers without
|
|
31
|
+
* going through http-client (e.g. signed-URL inputs to a token).
|
|
32
|
+
* - Keeping it pure-string-construction with no I/O makes it test-only
|
|
33
|
+
* in 0ms and reusable from non-network contexts.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var { FrameworkError } = require("./framework-error");
|
|
37
|
+
|
|
38
|
+
class AuthHeaderError extends FrameworkError {
|
|
39
|
+
constructor(message, code) {
|
|
40
|
+
super(message, code || "auth-header/invalid");
|
|
41
|
+
this.name = "AuthHeaderError";
|
|
42
|
+
this.isAuthHeaderError = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function bearer(token) {
|
|
47
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
48
|
+
throw new AuthHeaderError("bearer: token must be a non-empty string");
|
|
49
|
+
}
|
|
50
|
+
return { Authorization: "Bearer " + token };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function basic(username, password) {
|
|
54
|
+
// Accepting empty username + empty password reflects RFC 7617's
|
|
55
|
+
// tolerance — some legacy endpoints want literally "Basic <b64('::')>".
|
|
56
|
+
// We accept the cast to string but reject undefined-as-username
|
|
57
|
+
// (which would silently produce "Basic <b64('undefined:...')>").
|
|
58
|
+
if (username == null) {
|
|
59
|
+
throw new AuthHeaderError("basic: username is required (pass empty string for credential-less basic)");
|
|
60
|
+
}
|
|
61
|
+
var pwd = password == null ? "" : password;
|
|
62
|
+
var b64 = Buffer.from(String(username) + ":" + String(pwd), "utf8").toString("base64");
|
|
63
|
+
return { Authorization: "Basic " + b64 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fromConfig(config) {
|
|
67
|
+
if (!config || !config.auth || config.auth === "none") return {};
|
|
68
|
+
if (config.auth === "bearer") return bearer(config.token);
|
|
69
|
+
if (config.auth === "basic") return basic(config.username, config.password);
|
|
70
|
+
throw new AuthHeaderError(
|
|
71
|
+
"unknown auth method '" + config.auth + "' (expected 'none' | 'bearer' | 'basic')",
|
|
72
|
+
"auth-header/unknown-method"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
bearer: bearer,
|
|
78
|
+
basic: basic,
|
|
79
|
+
fromConfig: fromConfig,
|
|
80
|
+
AuthHeaderError: AuthHeaderError,
|
|
81
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* backup-bundle — produce an encrypted backup bundle on disk.
|
|
4
|
+
*
|
|
5
|
+
* Given a dataDir + file include list + passphrase, walks each file,
|
|
6
|
+
* encrypts its bytes via backup-crypto, computes a sha3-512 checksum
|
|
7
|
+
* of the plaintext, and emits a bundle directory:
|
|
8
|
+
*
|
|
9
|
+
* <outDir>/manifest.json — backup-manifest schema
|
|
10
|
+
* <outDir>/files/<path>.enc — per-file encrypted blob
|
|
11
|
+
*
|
|
12
|
+
* Where <path> mirrors the file's relativePath under dataDir (subdirs
|
|
13
|
+
* preserved). The manifest is the only authoritative description of
|
|
14
|
+
* the bundle's contents — a restorer reads it first, then streams
|
|
15
|
+
* each blob into staging.
|
|
16
|
+
*
|
|
17
|
+
* await b.backupBundle.create({
|
|
18
|
+
* dataDir: "./data",
|
|
19
|
+
* outDir: "./backups/2026-04-27.bundle", // must NOT exist
|
|
20
|
+
* passphrase: Buffer.from("operator passphrase"),
|
|
21
|
+
* vaultKeyJson: "<vault.key contents>", // string; encrypted into manifest
|
|
22
|
+
* files: [
|
|
23
|
+
* { relativePath: "db.enc", kind: "raw", required: true },
|
|
24
|
+
* { relativePath: "db.key.enc", kind: "raw", required: true },
|
|
25
|
+
* { relativePath: "vault.key", kind: "raw", required: false },
|
|
26
|
+
* { relativePath: "ca.key.sealed", kind: "vault-sealed", required: false },
|
|
27
|
+
* ],
|
|
28
|
+
* metadata: { reason: "scheduled-daily" },
|
|
29
|
+
* progressCallback: function (event) { ... },
|
|
30
|
+
* });
|
|
31
|
+
* // → { manifest, manifestPath, outDir, bundleSize, fileCount, durationMs }
|
|
32
|
+
*
|
|
33
|
+
* vaultKeyJson is encrypted with the operator passphrase + a fresh
|
|
34
|
+
* salt and stored in the manifest's vaultKeyEnc. With only the
|
|
35
|
+
* passphrase, a restorer on a different machine can recover the
|
|
36
|
+
* framework's vault keypair and unseal the bundle's vault-sealed
|
|
37
|
+
* files post-restore. Without the passphrase, the bundle is opaque.
|
|
38
|
+
*
|
|
39
|
+
* Per-file salts: each file gets its own fresh salt. Argon2id is
|
|
40
|
+
* memory-hard but per-file fresh-salt means an attacker who recovers
|
|
41
|
+
* one file's key from the passphrase has no leverage on other files
|
|
42
|
+
* — the salt rotation forces the full Argon2 computation per file.
|
|
43
|
+
*
|
|
44
|
+
* The bundler does NOT compress files. Operators with large datasets
|
|
45
|
+
* who want compression run their backup pipeline through their own
|
|
46
|
+
* compressor (gzip, zstd) downstream of the framework primitive.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
var fs = require("fs");
|
|
50
|
+
var path = require("path");
|
|
51
|
+
var atomicFile = require("../atomic-file");
|
|
52
|
+
var backupCrypto = require("./crypto");
|
|
53
|
+
var backupManifest = require("./manifest");
|
|
54
|
+
var { defineClass } = require("../framework-error");
|
|
55
|
+
|
|
56
|
+
var BackupBundleError = defineClass("BackupBundleError", { alwaysPermanent: true });
|
|
57
|
+
|
|
58
|
+
function _emit(cb, ev) {
|
|
59
|
+
if (typeof cb === "function") {
|
|
60
|
+
try { cb(ev); } catch (_e) { /* progress-callback errors are non-fatal */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Map relativePath → encryptedPath inside the bundle. Mirrors the
|
|
65
|
+
// directory structure under files/ and appends .enc so every blob
|
|
66
|
+
// has a clear stride and isn't confused with the source file.
|
|
67
|
+
function _encryptedPathFor(relativePath) {
|
|
68
|
+
// POSIX-normalize separators in the bundle so manifests written on
|
|
69
|
+
// Windows and Linux look the same on disk.
|
|
70
|
+
var posix = relativePath.split(path.sep).join("/");
|
|
71
|
+
return "files/" + posix + ".enc";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function create(opts) {
|
|
75
|
+
var t0 = Date.now();
|
|
76
|
+
opts = opts || {};
|
|
77
|
+
if (typeof opts.dataDir !== "string" || !fs.existsSync(opts.dataDir)) {
|
|
78
|
+
throw new BackupBundleError("backup-bundle/no-datadir",
|
|
79
|
+
"create: opts.dataDir is required and must exist");
|
|
80
|
+
}
|
|
81
|
+
if (typeof opts.outDir !== "string" || opts.outDir.length === 0) {
|
|
82
|
+
throw new BackupBundleError("backup-bundle/no-outdir",
|
|
83
|
+
"create: opts.outDir is required");
|
|
84
|
+
}
|
|
85
|
+
if (fs.existsSync(opts.outDir)) {
|
|
86
|
+
throw new BackupBundleError("backup-bundle/outdir-exists",
|
|
87
|
+
"create: outDir already exists: " + opts.outDir +
|
|
88
|
+
" (refusing to overwrite — pick a fresh path)");
|
|
89
|
+
}
|
|
90
|
+
if (!Buffer.isBuffer(opts.passphrase) && typeof opts.passphrase !== "string") {
|
|
91
|
+
throw new BackupBundleError("backup-bundle/no-passphrase",
|
|
92
|
+
"create: opts.passphrase is required (Buffer or string)");
|
|
93
|
+
}
|
|
94
|
+
if (typeof opts.vaultKeyJson !== "string" || opts.vaultKeyJson.length === 0) {
|
|
95
|
+
throw new BackupBundleError("backup-bundle/no-vault-key-json",
|
|
96
|
+
"create: opts.vaultKeyJson is required (the in-memory vault keypair JSON; " +
|
|
97
|
+
"use vault.getKeysJson() or read vault.key from disk)");
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(opts.files) || opts.files.length === 0) {
|
|
100
|
+
throw new BackupBundleError("backup-bundle/no-files",
|
|
101
|
+
"create: opts.files must be a non-empty array of include entries");
|
|
102
|
+
}
|
|
103
|
+
var passphrase = opts.passphrase;
|
|
104
|
+
var dataDir = opts.dataDir;
|
|
105
|
+
var outDir = opts.outDir;
|
|
106
|
+
var progress = opts.progressCallback;
|
|
107
|
+
|
|
108
|
+
atomicFile.ensureDir(outDir);
|
|
109
|
+
atomicFile.ensureDir(path.join(outDir, "files"));
|
|
110
|
+
|
|
111
|
+
// 1. Encrypt the vault key JSON
|
|
112
|
+
_emit(progress, { phase: "wrap_vault_key" });
|
|
113
|
+
var wrappedVk = await backupCrypto.encryptWithFreshSalt(opts.vaultKeyJson, passphrase);
|
|
114
|
+
|
|
115
|
+
// 2. Walk each include entry, encrypt the bytes, emit a blob
|
|
116
|
+
var fileEntries = [];
|
|
117
|
+
var totalBytes = 0;
|
|
118
|
+
|
|
119
|
+
for (var i = 0; i < opts.files.length; i++) {
|
|
120
|
+
var entry = opts.files[i];
|
|
121
|
+
if (!entry || typeof entry.relativePath !== "string" || entry.relativePath.length === 0) {
|
|
122
|
+
throw new BackupBundleError("backup-bundle/bad-include",
|
|
123
|
+
"create: files[" + i + "] requires { relativePath: string }");
|
|
124
|
+
}
|
|
125
|
+
if (entry.relativePath.indexOf("..") !== -1 || /^[/\\]/.test(entry.relativePath)) {
|
|
126
|
+
throw new BackupBundleError("backup-bundle/bad-include",
|
|
127
|
+
"create: files[" + i + "].relativePath must be a relative path (got '" + entry.relativePath + "')");
|
|
128
|
+
}
|
|
129
|
+
var srcPath = path.join(dataDir, entry.relativePath);
|
|
130
|
+
if (!fs.existsSync(srcPath)) {
|
|
131
|
+
if (entry.required) {
|
|
132
|
+
throw new BackupBundleError("backup-bundle/missing-required",
|
|
133
|
+
"create: required file missing: " + entry.relativePath);
|
|
134
|
+
}
|
|
135
|
+
_emit(progress, { phase: "skip_missing", relativePath: entry.relativePath });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
var stat = fs.statSync(srcPath);
|
|
139
|
+
if (!stat.isFile()) {
|
|
140
|
+
// Directories aren't supported in this slice — the bundler
|
|
141
|
+
// operates on a flat list of files. Operator wanting a recursive
|
|
142
|
+
// sweep walks the dir themselves and passes the resulting list.
|
|
143
|
+
throw new BackupBundleError("backup-bundle/not-a-file",
|
|
144
|
+
"create: '" + entry.relativePath + "' is not a regular file");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_emit(progress, { phase: "read", relativePath: entry.relativePath, size: stat.size });
|
|
148
|
+
var plain = fs.readFileSync(srcPath);
|
|
149
|
+
var checksum = backupCrypto.checksum(plain);
|
|
150
|
+
var encResult = await backupCrypto.encryptWithFreshSalt(plain, passphrase);
|
|
151
|
+
var encPath = _encryptedPathFor(entry.relativePath);
|
|
152
|
+
var destFull = path.join(outDir, encPath);
|
|
153
|
+
atomicFile.ensureDir(path.dirname(destFull));
|
|
154
|
+
atomicFile.writeSync(destFull, encResult.encrypted, { fileMode: 0o600 });
|
|
155
|
+
|
|
156
|
+
var kind = entry.kind || "raw";
|
|
157
|
+
if (!backupManifest.VALID_KINDS[kind]) {
|
|
158
|
+
throw new BackupBundleError("backup-bundle/bad-kind",
|
|
159
|
+
"create: files[" + i + "].kind must be one of raw, vault-sealed, plaintext (got '" + kind + "')");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fileEntries.push({
|
|
163
|
+
relativePath: entry.relativePath,
|
|
164
|
+
encryptedPath: encPath,
|
|
165
|
+
size: plain.length,
|
|
166
|
+
encryptedSize: encResult.encrypted.length,
|
|
167
|
+
checksum: checksum,
|
|
168
|
+
salt: encResult.salt,
|
|
169
|
+
kind: kind,
|
|
170
|
+
});
|
|
171
|
+
totalBytes += encResult.encrypted.length;
|
|
172
|
+
_emit(progress, {
|
|
173
|
+
phase: "encrypted",
|
|
174
|
+
relativePath: entry.relativePath,
|
|
175
|
+
encryptedSize: encResult.encrypted.length,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (fileEntries.length === 0) {
|
|
180
|
+
// Nothing to write; refuse to emit an empty manifest. Operators
|
|
181
|
+
// who genuinely want an "empty backup" need to revisit their
|
|
182
|
+
// include list.
|
|
183
|
+
throw new BackupBundleError("backup-bundle/empty",
|
|
184
|
+
"create: no files included in bundle (every entry was missing or skipped)");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 3. Build the manifest and write it last (so a half-written bundle
|
|
188
|
+
// can be detected by absence of manifest.json — an integrity tell)
|
|
189
|
+
_emit(progress, { phase: "write_manifest" });
|
|
190
|
+
var manifest = backupManifest.create({
|
|
191
|
+
vaultKeySalt: wrappedVk.salt,
|
|
192
|
+
vaultKeyEnc: wrappedVk.encrypted.toString("base64"),
|
|
193
|
+
files: fileEntries,
|
|
194
|
+
metadata: opts.metadata || undefined,
|
|
195
|
+
});
|
|
196
|
+
var manifestPath = path.join(outDir, "manifest.json");
|
|
197
|
+
atomicFile.writeSync(manifestPath, backupManifest.serialize(manifest), { fileMode: 0o600 });
|
|
198
|
+
|
|
199
|
+
var durationMs = Date.now() - t0;
|
|
200
|
+
_emit(progress, {
|
|
201
|
+
phase: "done",
|
|
202
|
+
fileCount: fileEntries.length,
|
|
203
|
+
bundleSize: totalBytes,
|
|
204
|
+
durationMs: durationMs,
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
manifest: manifest,
|
|
208
|
+
manifestPath: manifestPath,
|
|
209
|
+
outDir: outDir,
|
|
210
|
+
bundleSize: totalBytes,
|
|
211
|
+
fileCount: fileEntries.length,
|
|
212
|
+
durationMs: durationMs,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = {
|
|
217
|
+
create: create,
|
|
218
|
+
BackupBundleError: BackupBundleError,
|
|
219
|
+
};
|