@blamejs/core 0.6.13 → 0.6.20
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 +7 -0
- package/NOTICE +16 -0
- package/README.md +9 -8
- package/index.js +12 -0
- package/lib/audit.js +4 -0
- package/lib/auth/password.js +449 -4
- package/lib/cli.js +598 -4
- package/lib/config-drift.js +309 -0
- package/lib/crypto-field.js +37 -0
- package/lib/crypto.js +8 -0
- package/lib/db.js +17 -2
- package/lib/dual-control.js +475 -0
- package/lib/file-type.js +265 -0
- package/lib/http-client.js +77 -0
- package/lib/internal-sha1-hibp.js +34 -0
- package/lib/middleware/csp-nonce.js +7 -4
- package/lib/middleware/index.js +2 -0
- package/lib/middleware/network-allowlist.js +199 -0
- package/lib/network-dns.js +469 -0
- package/lib/network-heartbeat.js +290 -0
- package/lib/network-nts.js +552 -0
- package/lib/network-proxy.js +246 -0
- package/lib/network-tls.js +326 -0
- package/lib/network.js +233 -0
- package/lib/ntp-check.js +50 -4
- package/lib/object-store/azure-blob.js +16 -42
- package/lib/permissions.js +223 -9
- package/lib/pqc-agent.js +4 -4
- package/lib/retention.js +439 -0
- package/lib/security-assert.js +368 -0
- package/lib/session.js +138 -8
- package/lib/ssrf-guard.js +9 -0
- package/lib/vendor/MANIFEST.json +12 -0
- package/lib/vendor/common-passwords-top-10000.txt +10000 -0
- package/package.json +3 -2
- package/sbom.cyclonedx.json +61 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,13 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.6.x
|
|
10
10
|
|
|
11
|
+
- **0.6.20** (2026-05-02) — CI / packaging fix-up. The npm-publish workflow's "Attach SBOM as release asset" step started failing with HTTP 422 ("Cannot upload assets to an immutable release") because the operator's manual `gh release create` had already been published when the workflow ran. Two changes: (1) `sbom.cyclonedx.json` is now bundled into the npm tarball (`files` block in package.json), so `npm install @blamejs/core && cat node_modules/@blamejs/core/sbom.cyclonedx.json` is the canonical SBOM access path. The prepack guard's known-allowed list covers the just-in-time generation. (2) The workflow's GH-release-attach step is now non-fatal: it tries to upload, logs a warning if the release is immutable, and lets the publish proceed regardless. The npm tarball is the load-bearing artifact; the GitHub release attachment was only ever supplementary. `.gitignore` adds `sbom.cyclonedx.json` so a stray local `npm sbom` doesn't pollute the repo.
|
|
12
|
+
- **0.6.19** (2026-05-02) — closes the critical + high gaps surfaced by the v0.6.18 audit. **Critical**: `b.network.ntp.nts.querySingle` now actually verifies the server reply with the s2cKey — extracts the AUTHENTICATOR_AND_ENC extension, AEAD-decrypts with AAD = bytes-before-authenticator, fails closed (`nts/auth-failed` / `nts/no-authenticator`) when verification fails. Server-supplied new cookies in the encrypted plaintext are appended to the cookie pool and the consumed cookie popped (real RFC 8915 cookie rotation). Previously the function returned `authenticated: true` while only checking the unique-identifier echo — any MITM that mirrored the request's 32-byte unique field could spoof timestamps. **High**: `azure-blob.presignedUploadPolicy` now throws `PRESIGN_NOT_SUPPORTED` instead of silently returning a SAS PUT URL when operators asked for POST policy semantics — Azure SAS has no body-size cap and the previous shape was a misleading mismatch (operator error message points at presignedUploadUrl + post-upload HEAD as the alternative). `b.auth.password.policy` now ships the SecLists top-10000 common-password list bundled (CC-BY-3.0, `lib/vendor/common-passwords-top-10000.txt`), loaded lazily on first `policy.check()` call; `password` / `dragon` / `qwerty` / etc. now reject with `policy/forbidden-common`; `useBundledCommon: false` per-policy bypasses if operator ships their own list. `b.network.tls` adds `removeCa(fingerprint256)` / `removeCaByLabel(label)` / `clearAll()` / `purgeExpired()` / `expiringSoon(windowMs)` so operators can rotate corp DPI CAs without process restart; every removal audits with subject + fingerprint + reason. `b.network.dns.setResultOrder("ipv6first")` now flips the order on the DoH / DoT dual-stack fallback paths too (was only sorting OS-resolver results). `b.network.dns.resolve4` / `resolve6` / `resolveAaaa` now use real DNS-protocol queries (`dns.promises.resolve4` / `_dohLookup` / `_dotLookup`) instead of aliasing `lookup()` — operator semantics now match Node's standard library (skips `/etc/hosts`, mDNS). `b.network.socket.setDefaultLinger` removed from the silent no-op path; now throws `socket/linger-not-supported` with operator guidance to use `socket.destroy()` (abort) vs `socket.end()` (graceful) since Node's public `net.Socket` has no `setLinger()`. **Internal**: every `new XxxError(...)` call across `lib/network*.js` was passing args in the wrong order (message-then-code instead of code-then-message), making `e.code` return human messages and `e.message` return slash-codes — operators relying on `e.code` for error handling got the wrong field. All 50+ throws fixed across `lib/network.js` / `network-dns.js` / `network-proxy.js` / `network-tls.js` / `network-heartbeat.js` / `network-nts.js`. **CI**: `.gitleaks.toml` adds `test/smoke.js` to the path allowlist and pins the historical commit + fingerprint that tripped the jwt rule on a `REDACTED` placeholder; `npm-publish.yml` job permissions bumped from `contents: read` to `contents: write` so `gh release upload sbom.cyclonedx.json` no longer 403s.
|
|
13
|
+
- **0.6.18** (2026-05-02) — `b.network` primitive — single namespace for runtime-configurable network behaviour. `b.network.ntp` adds tunable warn / fatal drift thresholds, env-var bindings (`BLAMEJS_NTP_SERVERS` / `BLAMEJS_NTP_TIMEOUT_MS` / `BLAMEJS_NTP_DRIFT_WARN_MS` / `BLAMEJS_NTP_DRIFT_FATAL_MS`), and authenticated-time support: `b.network.ntp.nts.query(opts)` performs an NTS-KE handshake (RFC 8915) over TLS 1.3 with the framework's PQC-hybrid group preference, extracts C2S / S2C keys via the standardized TLS exporter, and authenticates NTPv4 packets with AES-SIV-CMAC-256 (mandatory-to-implement, in-house) or AEAD-CHACHA20-POLY1305 — no extra vendored deps. `b.network.dns` exposes operator-pinned resolvers, IPv4 / IPv6 / dual-stack family selection, ipv4first / verbatim / ipv6first ordering, DNS lookup timeout (Node's native `dns.lookup` has none), in-memory positive + negative cache, and DoH / DoT (cloudflare / google / quad9 / custom URL) — `b.ssrfGuard` and `b.httpClient` route through it when configured. `b.network.proxy` honours `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` / `ALL_PROXY` (lower- and upper-case) with CIDR + suffix + wildcard `NO_PROXY` matching, basic-auth via `BLAMEJS_PROXY_AUTH`, CONNECT tunnels for HTTPS through HTTP proxies; `b.httpClient` picks up the agent automatically. `b.network.tls` is a runtime-overridable trust store: `addCa(pemOrPath)` / `addCaBundle(path)` / `useSystemTrust()` / `getTrustStore()` for deep-packet-inspection deploys behind Zscaler / Netskope / corporate Squid + custom CA — Node's `NODE_EXTRA_CA_CERTS` only works at boot; this primitive accepts adds at any time and `b.pqcAgent` picks them up immediately. Every `addCa` audits with subject + issuer + fingerprint256 + validity + isSelfSigned. `b.security.assertProduction({ allowDpiTrust })` refuses to boot in production with installed CAs unless explicitly allowed. `b.network.heartbeat` adds application-level liveness probes for upstream services (HTTP / TCP / NTP probe types, healthy → degraded → down state machine with consecutive-failure threshold, audit on state change, observability counters per probe). `b.network.socket` gives operator-tunable defaults for `TCP_NODELAY` / `SO_KEEPALIVE` / `SO_LINGER`. `b.network.bootFromEnv()` reads every supported env var at startup and applies in the right order so configuration takes effect before the first outbound socket; the wiki app's docker-compose configs ship every knob with a default-empty value (production overlay tightens DNS lookup timeout + cache TTL + `NTP_STRICT=1` + `SOCKET_NO_DELAY=1`). `.gitleaks.toml` was previously gitignored by the deny-all-dotfiles allowlist; it is now allowlisted so CI's secret-scan job loads the framework's allowlist and stops failing on `.gitleaks.toml: no such file or directory`. New wiki page `/network-config` documents all six sub-primitives.
|
|
14
|
+
- **0.6.17** (2026-05-02) — six new CLI subcommands wrapping the v0.6.14 primitives so operators can drive them from runbooks / cron / one-off ops: `blamejs security assert` (boot the framework + run `b.security.assertProduction()` against the live posture; aggregates failures with their codes, exit 1 on any failure), `blamejs config-drift inspect/verify` (read the signed sidecar without rebooting; `verify` returns exit 1 on tamper or missing), `blamejs file-type detect <file>` (magic-byte content classification with `--allowlist` for upload debugging — pure utility, no boot), `blamejs password check --plaintext "..."` (test `b.auth.password.policy` with `--profile pci-4.0` / `nist-aal2` / `hipaa-aal2`, `--breach-check` for HIBP, `--email` / `--username` context — pure utility), `blamejs erase --table X --row-id Y --confirm` (one-off cryptographic erasure for GDPR Art. 17 — replaces sealed columns + derived hashes with NULL + audits via `system.erase`), `blamejs retention preview/run` (ad-hoc rule from CLI flags: `--table` + `--age-field` + `--ttl-ms` + `--action`, with `preview` for dry-run reporting). Also: per-subcommand `--help` now reaches each handler's USAGE block (the previous main() dispatch short-circuited every `<sub> --help` to top-level help); fix applies to all existing subcommands too (`api-key --help`, `vault --help`, etc.). The `b.dualControl` primitive is intentionally NOT in the CLI surface — its grants live in an operator-supplied `b.cache` instance the CLI can't bind to without operator wiring; admins approve / revoke from the operator's app instead. Hadolint action pinned to `@master` (the published `@v3` floating tag doesn't exist; the maintainer ships only patch tags + master).
|
|
15
|
+
- **0.6.16** (2026-05-02) — CI secret-scan job switched from `gitleaks/gitleaks-action` (which started requiring a paid `GITLEAKS_LICENSE` for organization repositories, breaking the v0.6.14 publish workflow) to a direct install of the OSS gitleaks binary (Apache-2.0); resolves the latest release at job time so new ruleset improvements ship automatically. Same forward-track posture applied to other CI tools where pinning would silently miss security improvements: `aquasecurity/trivy-action` swapped from `@v0.36.0` to `@master`, `hadolint/hadolint-action` from `@v3.3.0` to the floating `@v3` major tag, `ludeeus/action-shellcheck` from `@2.0.0` to `@master`, ESLint runner from `eslint@10` to `eslint@latest` in both `ci.yml` and `npm-publish.yml`. Vendored crypto libraries (@noble/ciphers, @simplewebauthn/server, argon2, peculiar-pki) verified at latest upstream — no bundle refresh needed. No framework code changes; npm publish workflow unblocked for v0.6.14 / v0.6.15 / this release.
|
|
16
|
+
- **0.6.15** (2026-05-02) — wiki sidebar nav: every concern group is now a `<details>` section starting collapsed; the section containing the current page is rendered with `open` server-side so the operator's current location is always visible. Custom CSS-only disclosure glyph (▸ → ▾ via rotation) — works under the wiki's strict CSP without inline JS. Wiki app gains three new env knobs that ship with the docker-compose configs out of the box: `WIKI_TRUST_PROXY` (cookie Secure-flag detection through TLS terminator; pre-existing in v0.6.12 lib code, now in docker-compose with documentation), `WIKI_ADMIN_ALLOWED_CIDRS` / `WIKI_ADMIN_DENIED_CIDRS` (in-process CIDR fence on `/admin` paths via `b.middleware.networkAllowlist`), `WIKI_REQUIRE_PROD_ASSERTS` (boot-time `b.security.assertProduction()` gate that refuses to boot when production posture is incomplete). The production overlay (`docker-compose.prod.yml`) defaults the latter two on (TRUST_PROXY=1 since Caddy terminates TLS, REQUIRE_PROD_ASSERTS=1 so a misconfigured prod deploy fails fast); the dev overlay defaults them off.
|
|
17
|
+
- **0.6.14** (2026-05-02) — new operator-facing primitives, each shipping the v1-defensible compliance surface: `b.security.assertProduction(opts)` boot-time policy engine (vault / dbAtRest / auditSigning posture, NTP strict, Node minimum major, TLS minimum version, required + forbidden env vars, NODE_ENV pinning, dataDir POSIX-mode check, CORS-allow-all detection, operator-supplied extra asserts); `b.permissions.policy(scope, predicate)` ABAC layer that evaluates per-scope predicates after RBAC passes (single + requireAll + requireAny modes); `b.permissions` role-spec `requireMfa` / `mfaWindowMs` AND per-route MFA enforcement; `b.session` IP/UA fingerprint capture + drift detection with operator-supplied scorer for anomaly scoring (strict modes: `requireFingerprintMatch` AND `maxAnomalyScore`); `b.auth.password.policy(opts)` with NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 named profiles, length / common / context / dictionary / complexity (categories + min-run + min-sequence) rules, HIBP k-anonymity breach check (SHA-1 lives in `lib/internal-sha1-hibp.js`, NOT exported on `b.crypto`), rotation (`shouldRotate`) and history-reuse (`reuseProhibited`); `b.dualControl.create(opts)` two-person-rule with m-of-n quorum, cooling-off lock between approval and consume, approver-role gate, requester cancellation, minimum reason length, notification hook; `b.retention.create(opts)` with multi-stage rules (warn → archive → erase), legal-hold per-row exemption, dry-run `preview()`, soft-delete vs hard-delete vs erase, cross-table cascade, per-rule concurrency lock; `b.configDrift.create(opts)` with multi-baseline support, critical-keys severity classification, ignore-keys allowlist, signed sidecar via SLH-DSA + diff vs prev snapshot; `b.fileType.detect/.assertOneOf` magic-byte content classification (image / document / archive / executable / etc.); `b.middleware.networkAllowlist({ paths, allowedCidrs, deniedCidrs })` deny-then-allow CIDR fence; `b.httpClient.request({ allowedHosts })` with exact / suffix / wildcard / per-method entries and audit emit on disallowed; `b.cryptoField.eraseRow(table, row)` cryptographic-erasure helper for sealed columns + derived hashes. CI gains gitleaks secret scan + CycloneDX SBOM via `npm sbom` (no vendoring). README + SECURITY operator-checklist updated end to end for the new surface; wiki gains alerting-rule reference table for the framework's emitted events. Backwards-compatible — every new opt is opt-in.
|
|
11
18
|
- **0.6.13** (2026-05-02) — wiki primitive-section docs catch up to the v0.6.12 surface (`safeUrl.parse` allowUserinfo, `session.touch` extendBy ceiling, `queue.consume` rateLimit validation, `mail.transports.console` redactBcc, `logStream` webhook-sink onDrop, `backup.create` requireFlush, `restore.create` maxPulledBytes / maxPulledFiles); restore default cap stated as `C.BYTES.gib(4)` instead of a raw byte literal; numeric-check predicates (`isPositiveInt`, `isFiniteNonNegative`, `isPositiveFinite`) consolidated into `lib/numeric-checks.js` — were duplicated across api-key, cache, notify, queue, restore, retry, slug, testing, webhook; api-snapshot.json refreshed
|
|
12
19
|
- **0.6.12** (2026-05-02) — `b.safeUrl.parse` now rejects URLs with `user:pass@` userinfo by default (opt in per-call via `allowUserinfo: true`); `b.session.touch({ extendBy })` enforces the same `MAX_TTL_MS` ceiling as `create` / `rotate`; `b.queue.consume({ rateLimit })` rejects negative / zero / `NaN` / `Infinity` / fractional `max`; `b.middleware.requireAuth` no longer treats request `Content-Type: application/json` as a JSON-preference signal (only `Accept` and `X-Requested-With` count); `b.backup.create({ requireFlush: true })` opt-in fails the backup if pre-flush fails instead of producing a stale snapshot; `b.restore.create({ maxPulledBytes, maxPulledFiles })` preflight bounds bundle footprint before and after pull (defaults 4 GiB / 100K files); `b.mail.transports.console({ redactBcc: true })` opt-in prints recipient count instead of addresses; `b.logStream.transports.webhook({ onDrop })` callback fires on overflow + retry-exhausted batch drops. Wiki: admin login wired through `b.auth.lockout` (exponential-backoff after bad-cred attempts) and cookie `Secure` flag now routes through `b.requestHelpers.requestProtocol` with `WIKI_TRUST_PROXY` opt-in instead of trusting raw `x-forwarded-proto`. Wiki README documents the trust model for editable page bodies and the sanitization pattern operators should adopt before expanding the editor surface.
|
|
13
20
|
- **0.6.11** (2026-05-01) — wiki example-execution validator: fixture init no longer reaches across module realms (unblocks the npm-publish workflow's wiki-e2e gate, which `npm install --install-links` copies the framework into the wiki's node_modules — two distinct singletons before this fix)
|
package/NOTICE
CHANGED
|
@@ -57,3 +57,19 @@ Used for: Pure-JS mTLS CA. Self-signed CA generation (ECDSA P-384, SHA-384),
|
|
|
57
57
|
asn1js, pvtsutils, pvutils, and the @peculiar/asn1-* schema
|
|
58
58
|
chain).
|
|
59
59
|
--------------------------------------------------------------------------------
|
|
60
|
+
Component: SecLists — 10k-most-common.txt
|
|
61
|
+
Version: master snapshot (bundled 2026-05-02)
|
|
62
|
+
Source: https://github.com/danielmiessler/SecLists
|
|
63
|
+
https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt
|
|
64
|
+
License: CC-BY-3.0
|
|
65
|
+
Copyright: Copyright (c) Daniel Miessler and SecLists contributors
|
|
66
|
+
Used for: Top-10000 most-common (breach-derived) passwords. Loaded by
|
|
67
|
+
b.auth.password.policy() to satisfy NIST 800-63B §5.1.1.2's
|
|
68
|
+
"previously breached, dictionary, repetitive/sequential,
|
|
69
|
+
context-specific" check. Operators with deeper enforcement
|
|
70
|
+
(HIBP downloads, NCSC 100k) layer on top via opts.forbidCommon
|
|
71
|
+
— the bundled set is additive. Bundled at lib/vendor/
|
|
72
|
+
common-passwords-top-10000.txt; thank you to the SecLists
|
|
73
|
+
project maintainers for keeping a curated, freely-redistributable
|
|
74
|
+
baseline.
|
|
75
|
+
--------------------------------------------------------------------------------
|
package/README.md
CHANGED
|
@@ -42,15 +42,15 @@ var b = require("@blamejs/core");
|
|
|
42
42
|
The framework bundles the surface a typical Node app reaches for. Every primitive listed is callable today; nothing is a stub.
|
|
43
43
|
|
|
44
44
|
- **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; bring-your-own external Postgres / MySQL / etc. with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket ops (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
|
|
45
|
-
- **Identity & access** — passwords (Argon2id), passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions, brute-force lockout (`b.auth.*`, `b.session`); RBAC
|
|
46
|
-
- **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto, signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA, PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
|
|
47
|
-
- **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, request-time DB role binding via `b.middleware.dbRoleFor`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`).
|
|
48
|
-
- **Defensive parsers** — `b.safeJson`, `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env).
|
|
45
|
+
- **Identity & access** — passwords (Argon2id) + policy primitive (NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles, HaveIBeenPwned k-anonymity breach check, length / context / dictionary / complexity rules, rotation + history) (`b.auth.password`); passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions with optional IP / UA fingerprint drift detection + anomaly scoring, brute-force lockout (`b.auth.*`, `b.session`); RBAC + optional per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule approval workflow with m-of-n quorum + cooling-off lock + approver-role gate + cancellation (`b.dualControl`).
|
|
46
|
+
- **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto + cryptographic erasure (`b.cryptoField.eraseRow`), signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA, PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
|
|
47
|
+
- **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, request-time DB role binding via `b.middleware.dbRoleFor`, in-process CIDR fence via `b.middleware.networkAllowlist`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate, scheme + userinfo + per-host (wildcard / per-method) destination allowlist, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`); operator-tunable network configurability — env-driven NTP / NTS (RFC 8915 authenticated time), DNS with IPv6 / DoH / DoT / cache / lookup timeout, outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`), runtime DPI trust-store CA additions, application-level heartbeats, TCP socket defaults (`b.network`).
|
|
48
|
+
- **Defensive parsers** — `b.safeJson`, `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env), `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.).
|
|
49
49
|
- **Communication** — WebSockets with channel/room fan-out across cluster replicas (`b.websocket`, `b.websocketChannels`); mail with multipart + attachments + DKIM + calendar invites + bounce intake (`b.mail`, `b.mailBounce`); generic notification dispatcher with operator-supplied transports (`b.notify`).
|
|
50
|
-
- **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks, OTLP/HTTP-JSON exporter for any OTel-compatible backend (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`).
|
|
50
|
+
- **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks, OTLP/HTTP-JSON exporter for any OTel-compatible backend (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`); operator-callable boot-time security policy assertions (`b.security.assertProduction`) and tamper-evident config-baseline drift detection signed with the audit-signing key (`b.configDrift`).
|
|
51
51
|
- **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`).
|
|
52
52
|
- **Format helpers** — RFC 4180 CSV with Excel formula-injection prevention (`b.csv`), RFC 9562 UUID v4 + v7 (`b.uuid`), URL-safe slugs (`b.slug`), TZ-aware datetime (`b.time`), ZIP creation (`b.archive`), HMAC-signed cursor pagination (`b.pagination`), HTML form rendering + validation + CSRF (`b.forms`).
|
|
53
|
-
- **Production** — cluster leader election with fenced leases over Postgres/SQLite (`b.cluster`); cron + interval scheduler that runs exactly-once globally (`b.scheduler`); retry with full-jitter backoff + circuit breaker (`b.retry`); graceful shutdown (`b.appShutdown`); NTP boot check (`b.ntpCheck`); end-to-end-encrypted backup bundles (`b.backup`).
|
|
53
|
+
- **Production** — cluster leader election with fenced leases over Postgres/SQLite (`b.cluster`); cron + interval scheduler that runs exactly-once globally (`b.scheduler`); retry with full-jitter backoff + circuit breaker (`b.retry`); graceful shutdown (`b.appShutdown`); NTP boot check (`b.ntpCheck`); end-to-end-encrypted backup bundles with pre-flush fail-closed mode (`b.backup`); restore with pulled-bundle footprint preflight (`b.restore`); GDPR / PCI / HIPAA-shaped retention rules with multi-stage warn → archive → erase, legal-hold exemptions, dry-run preview, cross-table cascade (`b.retention`).
|
|
54
54
|
|
|
55
55
|
## Documentation
|
|
56
56
|
|
|
@@ -59,7 +59,7 @@ Full primitive-by-primitive docs live at [blamejs.com](https://blamejs.com), whi
|
|
|
59
59
|
- **Data** — [Database](https://blamejs.com/database) · [Object Store](https://blamejs.com/object-store) · [Queue & Cache](https://blamejs.com/queue-cache)
|
|
60
60
|
- **Identity** — [Authentication](https://blamejs.com/auth) · [Access Control](https://blamejs.com/access-control)
|
|
61
61
|
- **Crypto** — [Crypto & Vault](https://blamejs.com/crypto-vault) · [Network Crypto](https://blamejs.com/network-crypto)
|
|
62
|
-
- **HTTP** — [Routing](https://blamejs.com/routing) · [Middleware](https://blamejs.com/middleware) · [Outbound HTTP](https://blamejs.com/outbound-http)
|
|
62
|
+
- **HTTP** — [Routing](https://blamejs.com/routing) · [Middleware](https://blamejs.com/middleware) · [Outbound HTTP](https://blamejs.com/outbound-http) · [Network Configurability](https://blamejs.com/network-config)
|
|
63
63
|
- **Validation** — [Safe Parsers](https://blamejs.com/safe-parsers)
|
|
64
64
|
- **Communication** — [WebSockets](https://blamejs.com/websockets) · [Mail](https://blamejs.com/mail) · [Notifications](https://blamejs.com/notifications)
|
|
65
65
|
- **Tools** — [Observability](https://blamejs.com/observability) · [Testing](https://blamejs.com/testing) · [i18n & Locale](https://blamejs.com/i18n-locale) · [Format Helpers](https://blamejs.com/format-helpers)
|
|
@@ -106,9 +106,10 @@ All runtime dependencies are committed to the repo — no transitive npm install
|
|
|
106
106
|
| [`@simplewebauthn/server`](https://github.com/MasterKale/SimpleWebAuthn) | 13.3.0 | [Matthew Miller](https://github.com/MasterKale) | WebAuthn / passkey verification |
|
|
107
107
|
| [`argon2`](https://github.com/ranisalt/node-argon2) | 0.44.0 | [Ranieri Althoff](https://github.com/ranisalt) | Password hashing (native prebuilds, 8 platforms) |
|
|
108
108
|
| [`@peculiar/x509`](https://github.com/PeculiarVentures/x509) + [`pkijs`](https://github.com/PeculiarVentures/PKI.js) | 2.0.0 + 3.4.0 | [Peculiar Ventures](https://github.com/PeculiarVentures) | Pure-JS mTLS CA — ECDSA P-384 cert signing, PKCS#12 packaging (no openssl CLI) |
|
|
109
|
+
| [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
|
|
109
110
|
| [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
|
|
110
111
|
|
|
111
|
-
These libraries are exceptional work — blamejs wouldn't exist without them. All are MIT licensed. Per-package version, license, and provenance live in two manifests: [`lib/vendor/MANIFEST.json`](lib/vendor/MANIFEST.json) for the framework's server-side bundles and [`examples/wiki/public/vendor/MANIFEST.json`](examples/wiki/public/vendor/MANIFEST.json) for the wiki app's browser-side bundle. The framework's [`NOTICE`](NOTICE) file carries the upstream attributions.
|
|
112
|
+
These libraries are exceptional work — blamejs wouldn't exist without them. All are MIT licensed (the SecLists password list is CC-BY-3.0). Per-package version, license, and provenance live in two manifests: [`lib/vendor/MANIFEST.json`](lib/vendor/MANIFEST.json) for the framework's server-side bundles and [`examples/wiki/public/vendor/MANIFEST.json`](examples/wiki/public/vendor/MANIFEST.json) for the wiki app's browser-side bundle. The framework's [`NOTICE`](NOTICE) file carries the upstream attributions.
|
|
112
113
|
|
|
113
114
|
## Why "blamejs"
|
|
114
115
|
|
package/index.js
CHANGED
|
@@ -159,6 +159,12 @@ var seeders = require("./lib/seeders");
|
|
|
159
159
|
var i18n = require("./lib/i18n");
|
|
160
160
|
var notify = require("./lib/notify");
|
|
161
161
|
var testing = require("./lib/testing");
|
|
162
|
+
var configDrift = require("./lib/config-drift");
|
|
163
|
+
var security = require("./lib/security-assert");
|
|
164
|
+
var fileType = require("./lib/file-type");
|
|
165
|
+
var dualControl = require("./lib/dual-control");
|
|
166
|
+
var retention = require("./lib/retention");
|
|
167
|
+
var network = require("./lib/network");
|
|
162
168
|
|
|
163
169
|
module.exports = {
|
|
164
170
|
crypto: crypto,
|
|
@@ -266,6 +272,12 @@ module.exports = {
|
|
|
266
272
|
i18n: i18n,
|
|
267
273
|
notify: notify,
|
|
268
274
|
testing: testing,
|
|
275
|
+
configDrift: configDrift,
|
|
276
|
+
security: security,
|
|
277
|
+
fileType: fileType,
|
|
278
|
+
dualControl: dualControl,
|
|
279
|
+
retention: retention,
|
|
280
|
+
network: network,
|
|
269
281
|
ntpCheck: ntpCheck,
|
|
270
282
|
version: constants.version,
|
|
271
283
|
};
|
package/lib/audit.js
CHANGED
|
@@ -203,13 +203,17 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
203
203
|
"backup", // b.backup
|
|
204
204
|
"breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
|
|
205
205
|
"cache", // b.cache
|
|
206
|
+
"config", // b.configDrift (config.baseline.captured / config.drift.detected / config.baseline.tamper / config.baseline.unreadable)
|
|
206
207
|
"db", // b.db / b.middleware.dbRoleFor / b.externalDb.runAs
|
|
207
208
|
// (role-switching, RLS-shaped events)
|
|
208
209
|
"dkim", // b.mail.dkim (DKIM-Signature generation events)
|
|
210
|
+
"dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
|
|
209
211
|
"mail", // b.mail (b.mail-bounce uses "system.mail.*")
|
|
212
|
+
"network", // b.middleware.networkAllowlist (network.gate.denied)
|
|
210
213
|
"notify", // b.notify
|
|
211
214
|
"permissions", // b.permissions
|
|
212
215
|
"restore", // b.restore
|
|
216
|
+
"retention", // b.retention (retention.rule.declared / sweep.started / row.processed / sweep.completed / sweep.failed)
|
|
213
217
|
"scheduler", // b.scheduler (lifecycle: scheduler.start / scheduler.stop;
|
|
214
218
|
// tick/task events use "system.scheduler.*")
|
|
215
219
|
"seeders", // b.seeders
|
package/lib/auth/password.js
CHANGED
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
* "the call shape was wrong" (empty plain, oversize plain).
|
|
48
48
|
*/
|
|
49
49
|
var argon2 = require("../vendor/argon2");
|
|
50
|
+
var httpClient = require("../http-client");
|
|
51
|
+
var internalSha1Hibp = require("../internal-sha1-hibp");
|
|
52
|
+
var safeUrl = require("../safe-url");
|
|
53
|
+
var timingSafeEqual = require("../crypto").timingSafeEqual;
|
|
50
54
|
var { AuthError } = require("../framework-error");
|
|
51
55
|
|
|
52
56
|
// Tuning targets ~250–500ms on commodity 2026 hardware. memoryCost
|
|
@@ -59,6 +63,444 @@ var DEFAULT_PARAMS = Object.freeze({
|
|
|
59
63
|
|
|
60
64
|
var MAX_PLAINTEXT_BYTES = 4096;
|
|
61
65
|
|
|
66
|
+
// ---- Policy primitive ----------------------------------------------
|
|
67
|
+
//
|
|
68
|
+
// Argon2id covers the OFFLINE attack model: even with the DB stolen,
|
|
69
|
+
// each guess costs ~250ms of CPU + 64 MiB of RAM. It does NOT cover:
|
|
70
|
+
// - Online weak-credential attacks (operator phishes / reuses)
|
|
71
|
+
// - Periodic rotation requirements (PCI 8.3, NYDFS, some HIPAA)
|
|
72
|
+
// - History reuse (PCI 8.3 last-4 floor)
|
|
73
|
+
// - Operator-tunable composition rules (HIPAA / industry-specific
|
|
74
|
+
// where AAL2-equivalent posture is mandated)
|
|
75
|
+
//
|
|
76
|
+
// b.auth.password.policy(opts) returns:
|
|
77
|
+
// - check(plaintext, context?) → presentation-time gate
|
|
78
|
+
// - shouldRotate(passwordSetAt) → per-account rotation check
|
|
79
|
+
// - reuseProhibited(plaintext, history)→ history-reuse check
|
|
80
|
+
//
|
|
81
|
+
// Standards mapped:
|
|
82
|
+
// - NIST 800-63B §5.1.1.2: 8-char min floor, 64-char min max,
|
|
83
|
+
// breach check, NO MANDATORY composition. (Default posture.)
|
|
84
|
+
// - PCI-DSS 8.3.6 / 8.3.7: 12-char min, 90-day rotation, history
|
|
85
|
+
// of 4. (Operator opts in via { profile: "pci-8.3" }.)
|
|
86
|
+
// - HIPAA 164.308(a)(5)(ii)(D): "procedures for creating, changing,
|
|
87
|
+
// and safeguarding passwords" — addressed via composition opts.
|
|
88
|
+
// - GDPR Art. 32: storage shape (sealed via Argon2id); no
|
|
89
|
+
// additional policy requirement here.
|
|
90
|
+
// - NYDFS 23 NYCRR 500.12 / NIST AAL2: rotation + breach + length.
|
|
91
|
+
//
|
|
92
|
+
// The defaults follow NIST 800-63B (no mandatory composition,
|
|
93
|
+
// length-and-breach over rules). Every other regime layers on
|
|
94
|
+
// opt-in opts; the framework refuses to surprise an operator who
|
|
95
|
+
// followed the defaults.
|
|
96
|
+
//
|
|
97
|
+
// var policy = b.auth.password.policy({
|
|
98
|
+
// minLength: 12,
|
|
99
|
+
// breachCheck: "haveibeenpwned",
|
|
100
|
+
// mustRotateAfterMs: C.TIME.days(90), // PCI 8.3.9
|
|
101
|
+
// historyMinDistance: 4, // PCI 8.3.7
|
|
102
|
+
// complexity: {
|
|
103
|
+
// minCategories: 0, // NIST: don't enforce. opt in for HIPAA-flavoured.
|
|
104
|
+
// categories: ["lower", "upper", "digit", "special"],
|
|
105
|
+
// minRunRepeat: 3, // reject "aaaa…"
|
|
106
|
+
// minSequenceLength: 3, // reject "abcd"/"1234"
|
|
107
|
+
// },
|
|
108
|
+
// dictionary: ["companyName", "productName"],
|
|
109
|
+
// });
|
|
110
|
+
//
|
|
111
|
+
// await policy.check(plain, { email, username, deny: [...], passwordSetAt });
|
|
112
|
+
// policy.shouldRotate(passwordSetAt);
|
|
113
|
+
// await policy.reuseProhibited(plain, [oldHash1, oldHash2, oldHash3]);
|
|
114
|
+
//
|
|
115
|
+
// breachCheck:"haveibeenpwned" uses the HIBP k-anonymity API; the
|
|
116
|
+
// SHA-1 hash is computed in-process via lib/internal-sha1-hibp.js
|
|
117
|
+
// (NOT exported on b.crypto — see comment in lib/crypto.js). Only
|
|
118
|
+
// the first 5 hex chars cross the wire. Rate-limit and failure-mode
|
|
119
|
+
// are operator's call: an HIBP outage returns
|
|
120
|
+
// { ok: true, breachCheckSkipped: true } by default; failClosed:true
|
|
121
|
+
// rejects.
|
|
122
|
+
var DEFAULT_POLICY = Object.freeze({
|
|
123
|
+
minLength: 8, // NIST floor
|
|
124
|
+
maxLength: MAX_PLAINTEXT_BYTES,
|
|
125
|
+
forbidCommon: [],
|
|
126
|
+
// The bundled top-10000 list ships in lib/vendor/common-passwords-top-10000.txt
|
|
127
|
+
// (SecLists, CC-BY-3.0). Set false to skip — operators with a richer
|
|
128
|
+
// breach-list (HIBP downloads, NCSC 100k) layered via forbidCommon
|
|
129
|
+
// typically leave this on; it's additive.
|
|
130
|
+
useBundledCommon: true,
|
|
131
|
+
denyContextSubstrings: true,
|
|
132
|
+
breachCheck: null, // null | "haveibeenpwned"
|
|
133
|
+
breachThreshold: 1,
|
|
134
|
+
failClosed: false,
|
|
135
|
+
hibpEndpoint: "https://api.pwnedpasswords.com",
|
|
136
|
+
hibpTimeoutMs: 1500,
|
|
137
|
+
// Rotation policy (PCI 8.3.9 / NYDFS / industry-specific). null = no rotation.
|
|
138
|
+
mustRotateAfterMs: null,
|
|
139
|
+
// History reuse (PCI 8.3.7 floor: last-4). 0 = disabled.
|
|
140
|
+
// Operator passes the actual stored hash list to reuseProhibited().
|
|
141
|
+
historyMinDistance: 0,
|
|
142
|
+
// Composition rules (NIST 800-63B explicitly says NOT to enforce
|
|
143
|
+
// these; HIPAA / older standards still ask for them. Default is OFF
|
|
144
|
+
// so the NIST-aligned posture is the default; operators opt in.)
|
|
145
|
+
complexity: null,
|
|
146
|
+
// Dictionary terms forbidden as substrings (operator brand names,
|
|
147
|
+
// product names, etc.). Substring match, case-insensitive. Empty
|
|
148
|
+
// by default.
|
|
149
|
+
dictionary: [],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
var COMPLEXITY_DEFAULT = Object.freeze({
|
|
153
|
+
minCategories: 0, // NIST default off; HIPAA-flavoured ops set 3 or 4
|
|
154
|
+
categories: ["lower", "upper", "digit", "special"],
|
|
155
|
+
minRunRepeat: 0, // reject N+ identical chars in a row; 0 = off
|
|
156
|
+
minSequenceLength: 0, // reject N+ ascending or descending chars; 0 = off
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Predefined profiles operators can opt into. Each spreads onto the
|
|
160
|
+
// policy opts so the operator can still override individual fields.
|
|
161
|
+
var POLICY_PROFILES = Object.freeze({
|
|
162
|
+
// NIST 800-63B AAL2 baseline — length + breach, no composition.
|
|
163
|
+
"nist-aal2": Object.freeze({
|
|
164
|
+
minLength: 8,
|
|
165
|
+
breachCheck: "haveibeenpwned",
|
|
166
|
+
}),
|
|
167
|
+
// PCI-DSS 4.0 §8.3 — 12-char min, 90-day rotation, history of 4.
|
|
168
|
+
// Composition is NOT required by PCI 4.0 (it dropped the older
|
|
169
|
+
// version's composition rule); breach check + length covers it.
|
|
170
|
+
"pci-4.0": Object.freeze({
|
|
171
|
+
minLength: 12,
|
|
172
|
+
breachCheck: "haveibeenpwned",
|
|
173
|
+
mustRotateAfterMs: 90 * 24 * 60 * 60 * 1000,
|
|
174
|
+
historyMinDistance: 4,
|
|
175
|
+
}),
|
|
176
|
+
// HIPAA 164.308 — "procedures for creating, changing, and
|
|
177
|
+
// safeguarding". The standard is intentionally vague; the
|
|
178
|
+
// commonly-implemented profile pairs length + composition +
|
|
179
|
+
// rotation + lockout (lockout is b.auth.lockout, separate
|
|
180
|
+
// primitive).
|
|
181
|
+
"hipaa-aal2": Object.freeze({
|
|
182
|
+
minLength: 12,
|
|
183
|
+
breachCheck: "haveibeenpwned",
|
|
184
|
+
mustRotateAfterMs: 180 * 24 * 60 * 60 * 1000,
|
|
185
|
+
historyMinDistance: 4,
|
|
186
|
+
complexity: {
|
|
187
|
+
minCategories: 3,
|
|
188
|
+
minRunRepeat: 3,
|
|
189
|
+
minSequenceLength: 3,
|
|
190
|
+
},
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Top-10000 common-password set vendored from SecLists
|
|
195
|
+
// (CC-BY-3.0 by Daniel Miessler). Loaded lazily on first policy.check
|
|
196
|
+
// call — keeps boot fast for apps that never invoke the dictionary.
|
|
197
|
+
// Operators wanting deeper enforcement supply opts.forbidCommon (set
|
|
198
|
+
// of additional plaintexts) and/or opts.forbidCommonExtra (operator's
|
|
199
|
+
// own breach list); both layer additively on top of the bundled set.
|
|
200
|
+
var path = require("node:path");
|
|
201
|
+
var fs = require("node:fs");
|
|
202
|
+
var _bundledCommonPasswords = null;
|
|
203
|
+
function _loadBundledCommon() {
|
|
204
|
+
if (_bundledCommonPasswords) return _bundledCommonPasswords;
|
|
205
|
+
var p = path.join(__dirname, "..", "vendor", "common-passwords-top-10000.txt");
|
|
206
|
+
var text = fs.readFileSync(p, "utf8");
|
|
207
|
+
var set = new Set();
|
|
208
|
+
var lines = text.split(/\r?\n/);
|
|
209
|
+
for (var i = 0; i < lines.length; i++) {
|
|
210
|
+
var line = lines[i].trim();
|
|
211
|
+
if (line.length > 0) set.add(line.toLowerCase());
|
|
212
|
+
}
|
|
213
|
+
_bundledCommonPasswords = set;
|
|
214
|
+
return _bundledCommonPasswords;
|
|
215
|
+
}
|
|
216
|
+
function _commonPasswordsSize() {
|
|
217
|
+
return _loadBundledCommon().size;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function _ok(extra) { return Object.assign({ ok: true }, extra || {}); }
|
|
221
|
+
function _fail(code, message) {
|
|
222
|
+
return { ok: false, code: "policy/" + code, message: message };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Argon2id verify with a known stored hash — used by reuseProhibited
|
|
226
|
+
// to compare a candidate plaintext against history entries without
|
|
227
|
+
// the operator having to wire verify() per row.
|
|
228
|
+
async function _argon2Verify(stored, plaintext) {
|
|
229
|
+
if (typeof stored !== "string" || stored.indexOf("$argon2id$") !== 0) return false;
|
|
230
|
+
try { return await argon2.verify(stored, plaintext); }
|
|
231
|
+
catch (_e) { return false; }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _hasCategory(plaintext, category) {
|
|
235
|
+
if (category === "lower") return /[a-z]/.test(plaintext);
|
|
236
|
+
if (category === "upper") return /[A-Z]/.test(plaintext);
|
|
237
|
+
if (category === "digit") return /[0-9]/.test(plaintext);
|
|
238
|
+
if (category === "special") return /[^A-Za-z0-9]/.test(plaintext);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _hasRunOfLength(plaintext, n) {
|
|
243
|
+
if (n < 2) return false;
|
|
244
|
+
for (var i = 0; i + n <= plaintext.length; i++) {
|
|
245
|
+
var c = plaintext.charCodeAt(i);
|
|
246
|
+
var allSame = true;
|
|
247
|
+
for (var j = 1; j < n; j++) {
|
|
248
|
+
if (plaintext.charCodeAt(i + j) !== c) { allSame = false; break; }
|
|
249
|
+
}
|
|
250
|
+
if (allSame) return true;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _hasSequenceOfLength(plaintext, n) {
|
|
256
|
+
if (n < 3) return false;
|
|
257
|
+
for (var i = 0; i + n <= plaintext.length; i++) {
|
|
258
|
+
var ascending = true, descending = true;
|
|
259
|
+
for (var j = 1; j < n; j++) {
|
|
260
|
+
var diff = plaintext.charCodeAt(i + j) - plaintext.charCodeAt(i + j - 1);
|
|
261
|
+
if (diff !== 1) ascending = false;
|
|
262
|
+
if (diff !== -1) descending = false;
|
|
263
|
+
}
|
|
264
|
+
if (ascending || descending) return true;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function policy(opts) {
|
|
270
|
+
opts = opts || {};
|
|
271
|
+
// Apply named profile FIRST, then operator opts on top so the
|
|
272
|
+
// operator can override profile defaults per-field.
|
|
273
|
+
if (typeof opts.profile === "string" && opts.profile.length > 0) {
|
|
274
|
+
if (!POLICY_PROFILES[opts.profile]) {
|
|
275
|
+
throw new AuthError("auth-password/bad-policy",
|
|
276
|
+
"policy.profile must be one of " + Object.keys(POLICY_PROFILES).join("/") +
|
|
277
|
+
", got " + JSON.stringify(opts.profile));
|
|
278
|
+
}
|
|
279
|
+
opts = Object.assign({}, POLICY_PROFILES[opts.profile], opts);
|
|
280
|
+
delete opts.profile;
|
|
281
|
+
}
|
|
282
|
+
var p = Object.assign({}, DEFAULT_POLICY, opts);
|
|
283
|
+
if (typeof p.minLength !== "number" || p.minLength < 1 || p.minLength > MAX_PLAINTEXT_BYTES) {
|
|
284
|
+
throw new AuthError("auth-password/bad-policy",
|
|
285
|
+
"policy.minLength must be in [1, " + MAX_PLAINTEXT_BYTES + "]");
|
|
286
|
+
}
|
|
287
|
+
if (typeof p.maxLength !== "number" || p.maxLength < p.minLength || p.maxLength > MAX_PLAINTEXT_BYTES) {
|
|
288
|
+
throw new AuthError("auth-password/bad-policy",
|
|
289
|
+
"policy.maxLength must be in [minLength, " + MAX_PLAINTEXT_BYTES + "]");
|
|
290
|
+
}
|
|
291
|
+
if (p.breachCheck !== null && p.breachCheck !== "haveibeenpwned") {
|
|
292
|
+
throw new AuthError("auth-password/bad-policy",
|
|
293
|
+
"policy.breachCheck must be null or 'haveibeenpwned', got " + JSON.stringify(p.breachCheck));
|
|
294
|
+
}
|
|
295
|
+
if (p.hibpEndpoint) {
|
|
296
|
+
safeUrl.parse(p.hibpEndpoint, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS, errorClass: AuthError });
|
|
297
|
+
}
|
|
298
|
+
if (p.mustRotateAfterMs !== null &&
|
|
299
|
+
(typeof p.mustRotateAfterMs !== "number" || !isFinite(p.mustRotateAfterMs) || p.mustRotateAfterMs <= 0)) {
|
|
300
|
+
throw new AuthError("auth-password/bad-policy",
|
|
301
|
+
"policy.mustRotateAfterMs must be a positive finite number or null");
|
|
302
|
+
}
|
|
303
|
+
if (typeof p.historyMinDistance !== "number" || !isFinite(p.historyMinDistance) ||
|
|
304
|
+
p.historyMinDistance < 0 || Math.floor(p.historyMinDistance) !== p.historyMinDistance) {
|
|
305
|
+
throw new AuthError("auth-password/bad-policy",
|
|
306
|
+
"policy.historyMinDistance must be a non-negative integer");
|
|
307
|
+
}
|
|
308
|
+
if (p.complexity !== null && typeof p.complexity !== "object") {
|
|
309
|
+
throw new AuthError("auth-password/bad-policy",
|
|
310
|
+
"policy.complexity must be null or an object");
|
|
311
|
+
}
|
|
312
|
+
var complexity = p.complexity ? Object.assign({}, COMPLEXITY_DEFAULT, p.complexity) : null;
|
|
313
|
+
if (complexity) {
|
|
314
|
+
if (typeof complexity.minCategories !== "number" || complexity.minCategories < 0 ||
|
|
315
|
+
complexity.minCategories > complexity.categories.length) {
|
|
316
|
+
throw new AuthError("auth-password/bad-policy",
|
|
317
|
+
"policy.complexity.minCategories must be in [0, " + complexity.categories.length + "]");
|
|
318
|
+
}
|
|
319
|
+
for (var ci = 0; ci < complexity.categories.length; ci++) {
|
|
320
|
+
if (["lower", "upper", "digit", "special"].indexOf(complexity.categories[ci]) === -1) {
|
|
321
|
+
throw new AuthError("auth-password/bad-policy",
|
|
322
|
+
"policy.complexity.categories[" + ci + "] must be lower / upper / digit / special, got " +
|
|
323
|
+
JSON.stringify(complexity.categories[ci]));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
var forbidLower = (Array.isArray(p.forbidCommon) ? p.forbidCommon : [])
|
|
328
|
+
.map(function (s) { return String(s).toLowerCase(); });
|
|
329
|
+
var bundledSet = p.useBundledCommon === false ? null : _loadBundledCommon();
|
|
330
|
+
var dictionaryLower = (Array.isArray(p.dictionary) ? p.dictionary : [])
|
|
331
|
+
.filter(function (s) { return typeof s === "string" && s.length >= 3; })
|
|
332
|
+
.map(function (s) { return s.toLowerCase(); });
|
|
333
|
+
|
|
334
|
+
async function check(plaintext, context) {
|
|
335
|
+
if (typeof plaintext !== "string") {
|
|
336
|
+
return _fail("bad-input", "plaintext must be a string");
|
|
337
|
+
}
|
|
338
|
+
var byteLen = Buffer.byteLength(plaintext, "utf8");
|
|
339
|
+
if (byteLen < p.minLength) {
|
|
340
|
+
return _fail("too-short", "plaintext is shorter than " + p.minLength + " bytes");
|
|
341
|
+
}
|
|
342
|
+
if (byteLen > p.maxLength) {
|
|
343
|
+
return _fail("too-long", "plaintext exceeds " + p.maxLength + " bytes");
|
|
344
|
+
}
|
|
345
|
+
var lower = plaintext.toLowerCase();
|
|
346
|
+
if (bundledSet && bundledSet.has(lower)) {
|
|
347
|
+
return _fail("forbidden-common", "plaintext matches a known breached / common password (bundled top-10000)");
|
|
348
|
+
}
|
|
349
|
+
for (var i = 0; i < forbidLower.length; i++) {
|
|
350
|
+
if (lower === forbidLower[i]) {
|
|
351
|
+
return _fail("forbidden-common", "plaintext matches a known weak / common password");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (var di2 = 0; di2 < dictionaryLower.length; di2++) {
|
|
355
|
+
if (lower.indexOf(dictionaryLower[di2]) !== -1) {
|
|
356
|
+
return _fail("forbidden-dictionary",
|
|
357
|
+
"plaintext contains a forbidden dictionary term");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (p.denyContextSubstrings && context) {
|
|
361
|
+
var deny = [];
|
|
362
|
+
if (typeof context.email === "string" && context.email.length > 0) {
|
|
363
|
+
deny.push(context.email.toLowerCase());
|
|
364
|
+
var at = context.email.indexOf("@");
|
|
365
|
+
if (at > 0) deny.push(context.email.slice(0, at).toLowerCase());
|
|
366
|
+
}
|
|
367
|
+
if (typeof context.username === "string" && context.username.length > 0) {
|
|
368
|
+
deny.push(context.username.toLowerCase());
|
|
369
|
+
}
|
|
370
|
+
if (Array.isArray(context.deny)) {
|
|
371
|
+
for (var di = 0; di < context.deny.length; di++) {
|
|
372
|
+
if (typeof context.deny[di] === "string" && context.deny[di].length >= 3) {
|
|
373
|
+
deny.push(context.deny[di].toLowerCase());
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (var dj = 0; dj < deny.length; dj++) {
|
|
378
|
+
if (deny[dj].length >= 3 && lower.indexOf(deny[dj]) !== -1) {
|
|
379
|
+
return _fail("contains-context",
|
|
380
|
+
"plaintext contains a forbidden context substring (account identifier or operator-supplied deny string)");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (complexity) {
|
|
385
|
+
if (complexity.minCategories > 0) {
|
|
386
|
+
var hits = 0;
|
|
387
|
+
for (var cc = 0; cc < complexity.categories.length; cc++) {
|
|
388
|
+
if (_hasCategory(plaintext, complexity.categories[cc])) hits++;
|
|
389
|
+
}
|
|
390
|
+
if (hits < complexity.minCategories) {
|
|
391
|
+
return _fail("complexity-categories",
|
|
392
|
+
"plaintext uses " + hits + " character categories; policy requires at least " +
|
|
393
|
+
complexity.minCategories + " of [" + complexity.categories.join(", ") + "]");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (complexity.minRunRepeat >= 2 && _hasRunOfLength(plaintext, complexity.minRunRepeat)) {
|
|
397
|
+
return _fail("complexity-run",
|
|
398
|
+
"plaintext contains " + complexity.minRunRepeat + "+ identical consecutive characters");
|
|
399
|
+
}
|
|
400
|
+
if (complexity.minSequenceLength >= 3 && _hasSequenceOfLength(plaintext, complexity.minSequenceLength)) {
|
|
401
|
+
return _fail("complexity-sequence",
|
|
402
|
+
"plaintext contains a " + complexity.minSequenceLength + "+-char ascending or descending sequence");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (p.breachCheck === "haveibeenpwned") {
|
|
406
|
+
// HIBP k-anonymity: send the first 5 hex chars of the SHA-1
|
|
407
|
+
// hash, scan the returned suffix list. The framework's only
|
|
408
|
+
// SHA-1 usage; HIBP requires it. (See lib/internal-sha1-hibp.js
|
|
409
|
+
// for the restriction rationale.)
|
|
410
|
+
var sha1Full = internalSha1Hibp.sha1Hex(plaintext).toUpperCase();
|
|
411
|
+
var prefix = sha1Full.slice(0, 5);
|
|
412
|
+
var suffix = sha1Full.slice(5);
|
|
413
|
+
var url = p.hibpEndpoint.replace(/\/+$/, "") + "/range/" + prefix;
|
|
414
|
+
var resp;
|
|
415
|
+
try {
|
|
416
|
+
resp = await httpClient.request({
|
|
417
|
+
method: "GET",
|
|
418
|
+
url: url,
|
|
419
|
+
headers: { "User-Agent": "blamejs-password-policy/1" },
|
|
420
|
+
idleTimeoutMs: p.hibpTimeoutMs,
|
|
421
|
+
errorClass: AuthError,
|
|
422
|
+
});
|
|
423
|
+
} catch (e) {
|
|
424
|
+
if (p.failClosed) {
|
|
425
|
+
return _fail("breach-check-failed",
|
|
426
|
+
"HIBP lookup failed and policy is fail-closed: " + ((e && e.message) || String(e)));
|
|
427
|
+
}
|
|
428
|
+
return _ok({ breachCheckSkipped: true,
|
|
429
|
+
breachCheckSkipReason: (e && e.message) || String(e) });
|
|
430
|
+
}
|
|
431
|
+
if (resp.statusCode !== 200 || !resp.body) {
|
|
432
|
+
if (p.failClosed) {
|
|
433
|
+
return _fail("breach-check-failed",
|
|
434
|
+
"HIBP returned status " + resp.statusCode + " with no body");
|
|
435
|
+
}
|
|
436
|
+
return _ok({ breachCheckSkipped: true,
|
|
437
|
+
breachCheckSkipReason: "hibp-status-" + resp.statusCode });
|
|
438
|
+
}
|
|
439
|
+
var bodyText = Buffer.isBuffer(resp.body) ? resp.body.toString("utf8") : String(resp.body);
|
|
440
|
+
var lines = bodyText.split(/\r?\n/);
|
|
441
|
+
for (var li = 0; li < lines.length; li++) {
|
|
442
|
+
var line = lines[li].trim();
|
|
443
|
+
if (line.length === 0) continue;
|
|
444
|
+
var colon = line.indexOf(":");
|
|
445
|
+
if (colon < 0) continue;
|
|
446
|
+
var hashSuffix = line.slice(0, colon).toUpperCase();
|
|
447
|
+
var count = parseInt(line.slice(colon + 1), 10);
|
|
448
|
+
if (timingSafeEqual(Buffer.from(hashSuffix, "utf8"), Buffer.from(suffix, "utf8")) &&
|
|
449
|
+
isFinite(count) && count >= p.breachThreshold) {
|
|
450
|
+
return _fail("breached",
|
|
451
|
+
"plaintext appears in HaveIBeenPwned with count " + count +
|
|
452
|
+
" (threshold " + p.breachThreshold + ")");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return _ok({ breachCheckCount: 0 });
|
|
456
|
+
}
|
|
457
|
+
return _ok();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function shouldRotate(passwordSetAt, now) {
|
|
461
|
+
if (p.mustRotateAfterMs === null) return false;
|
|
462
|
+
if (typeof passwordSetAt !== "number" || !isFinite(passwordSetAt)) {
|
|
463
|
+
throw new AuthError("auth-password/bad-input",
|
|
464
|
+
"shouldRotate: passwordSetAt must be a numeric ms-epoch timestamp");
|
|
465
|
+
}
|
|
466
|
+
var nowMs = typeof now === "number" ? now : Date.now();
|
|
467
|
+
return (nowMs - passwordSetAt) >= p.mustRotateAfterMs;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function reuseProhibited(plaintext, history) {
|
|
471
|
+
if (typeof plaintext !== "string" || plaintext.length === 0) return false;
|
|
472
|
+
if (p.historyMinDistance <= 0) return false;
|
|
473
|
+
if (!Array.isArray(history) || history.length === 0) return false;
|
|
474
|
+
// Check the most-recent N entries (history-min-distance bound).
|
|
475
|
+
var checkCount = Math.min(history.length, p.historyMinDistance);
|
|
476
|
+
for (var i = 0; i < checkCount; i++) {
|
|
477
|
+
if (await _argon2Verify(history[i], plaintext)) return true;
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
check: check,
|
|
484
|
+
shouldRotate: shouldRotate,
|
|
485
|
+
reuseProhibited: reuseProhibited,
|
|
486
|
+
// Operator introspection — handy when an admin tool wants to
|
|
487
|
+
// surface "your policy requires X" to end users.
|
|
488
|
+
describe: function () {
|
|
489
|
+
return {
|
|
490
|
+
minLength: p.minLength,
|
|
491
|
+
maxLength: p.maxLength,
|
|
492
|
+
breachCheck: p.breachCheck,
|
|
493
|
+
mustRotateAfterMs: p.mustRotateAfterMs,
|
|
494
|
+
historyMinDistance: p.historyMinDistance,
|
|
495
|
+
complexity: complexity ? Object.assign({}, complexity) : null,
|
|
496
|
+
dictionaryCount: dictionaryLower.length,
|
|
497
|
+
forbidCommonCount: forbidLower.length,
|
|
498
|
+
bundledCommonCount: bundledSet ? bundledSet.size : 0,
|
|
499
|
+
};
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
62
504
|
function _validatePlain(plain) {
|
|
63
505
|
if (typeof plain !== "string" || plain.length === 0) {
|
|
64
506
|
throw new AuthError("auth-password/invalid-plain",
|
|
@@ -132,8 +574,11 @@ function needsRehash(stored, opts) {
|
|
|
132
574
|
}
|
|
133
575
|
|
|
134
576
|
module.exports = {
|
|
135
|
-
hash:
|
|
136
|
-
verify:
|
|
137
|
-
needsRehash:
|
|
138
|
-
|
|
577
|
+
hash: hash,
|
|
578
|
+
verify: verify,
|
|
579
|
+
needsRehash: needsRehash,
|
|
580
|
+
policy: policy,
|
|
581
|
+
DEFAULT_PARAMS: DEFAULT_PARAMS,
|
|
582
|
+
DEFAULT_POLICY: DEFAULT_POLICY,
|
|
583
|
+
POLICY_PROFILES: POLICY_PROFILES,
|
|
139
584
|
};
|