@blamejs/core 0.7.64 → 0.7.74
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 +20 -0
- package/index.js +2 -0
- package/lib/auth/aal.js +149 -0
- package/lib/auth/dpop.js +512 -0
- package/lib/auth/jwt.js +67 -0
- package/lib/auth/oauth.js +13 -6
- package/lib/cookies.js +2 -1
- package/lib/mail-auth.js +356 -2
- package/lib/mail-unsubscribe.js +160 -0
- package/lib/mail.js +135 -9
- package/lib/middleware/dpop.js +173 -0
- package/lib/middleware/gpc.js +120 -0
- package/lib/middleware/index.js +8 -0
- package/lib/middleware/require-aal.js +107 -0
- package/lib/middleware/security-headers.js +29 -1
- package/lib/network-dns.js +131 -12
- package/lib/network-smtp-policy.js +118 -3
- package/lib/vendor/MANIFEST.json +21 -5
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,26 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.74** (2026-05-06) — Email receive-side parity: DMARC aggregate (RUA) report parser + ARC trust evaluation + Authentication-Results header builder + TLS-RPT receive-side report parser. Closes the "framework can send mail compliantly but can't receive compliantly" gap. **`b.mail.dmarc.parseAggregateReport(xmlBytes, { contentType? })`** parses RFC 7489 §7.2 aggregate XML reports through the framework's existing `lib/parsers/safe-xml.js` (the existing security-focused XML parser handles XXE / DOCTYPE / entity-expansion defenses by default). Auto-detects gzip via magic bytes (`0x1f 0x8b`) or `Content-Type: application/gzip`. Returns `{ reportMetadata, policyPublished, records, totals }` with per-record source-IP / count / policy-evaluated dispositions / identifiers / DKIM + SPF auth results, plus aggregated `messages` / `aligned` / `notAligned` totals operators want for dashboards. Caps report size at 8 MiB and records-per-report at 10 000. **`b.mail.arc.evaluate(rfc822, { trustedSealers })`** wraps the existing `arc.verify` cryptographic chain check with the operator-side trust decision: given a passing chain, did any hop in the chain belong to a sealer the operator trusts? Returns `{ chainStatus, trusted, trustedHop, trustedDomain }` walking hops most-recent-first so the deepest trusted sealer wins. **`b.mail.authResults.emit({ authservId, results, fold? })`** builds the RFC 8601 Authentication-Results header value — operators consume per-method results from `b.mail.spf.verify` / `b.mail.dmarc.evaluate` / `b.mail.arc.verify`, hand them to `.emit`, and the framework formats the conformant header string with method-specific properties (`smtp.mailfrom`, `header.d`, `header.from`, `policy.iprev`, `policy.ip`, `policy.tls`). Refuses unknown methods / results at config-mistake time. **`b.network.smtp.tlsRpt.parseReport(body, { contentType? })`** is the receive-side counterpart to `tlsRpt.recordShape` / `tlsRpt.submit` — accepts a Buffer or string, auto-detects gzip, parses JSON, validates the RFC 8460 §4.4 required-fields shape (`organization-name`, `date-range`, `report-id`, `policies`), aggregates `total-successful-session-count` / `total-failure-session-count` across policies. Caps report size at 8 MiB and policies-per-report at 1024.
|
|
12
|
+
|
|
13
|
+
- **0.7.73** (2026-05-06) — `b.auth.aal` + `b.middleware.requireAal({ minimum })` — NIST SP 800-63-4 Authentication Assurance Level bands. **`b.auth.aal.fromMethods({ password, totp, webauthn, ... })`** combines a set of operator-asserted authenticator methods into the resulting band: `AAL1` (single factor — memorized secret OR single-factor cryptographic), `AAL2` (multi-factor — memorized secret + OTP/SMS/hardware/mTLS), `AAL3` (phishing-resistant multi-factor — WebAuthn / passkey / hardware-+-PIN). Recognized methods: `password`, `pin`, `totp`, `sms`, `webauthn`, `passkey`, `hardware`, `mtls`. **`b.middleware.requireAal({ minimum, getAal?, audit?, realm? })`** gates routes by the request's AAL band — reads `req.user.aal` by default (or operator-supplied `getAal(req)`), compares against the minimum, returns 401 with `WWW-Authenticate: AAL-StepUp realm="...", required="AAL2"` on insufficient assurance. The bespoke scheme name signals to the operator's frontend that a step-up flow should be triggered (re-prompt for TOTP / passkey) without reusing the generic `Bearer` challenge namespace. Audit emits `auth.aal.granted` / `auth.aal.denied` (drop-silent on observability sink failure). The framework leaves AMR/ACR claim emission to the operator's IdP; the new `b.auth.aal.AMR` constants object provides consistent OIDC-conformant strings for operators emitting access tokens with AAL info. Also exposes `b.auth.aal.meets(actual, required)` for ad-hoc band comparisons outside the middleware. **CI vendor-manifest gate fix**: vendor files now have an explicit `.gitattributes` `lib/vendor/** -text binary` declaration so git never rewrites line endings between Windows and Linux checkouts. The `lib/vendor/noble-ciphers.cjs` file was renormalized to LF on disk and re-hashed in `lib/vendor/MANIFEST.json`. Closes the v0.7.65–0.7.72 npm-publish.yml smoke-test failures (`vendor manifest: @noble/ciphers :: server hash matches`).
|
|
14
|
+
|
|
15
|
+
- **0.7.72** (2026-05-06) — `b.mail` gains EAI / SMTPUTF8 / Punycode-IDN support (RFC 6531 / 6532 / 6533 / 3492). **Internationalized email addresses** (`müller@münchen.example`) now validate through `b.mail.send()` — `_isValidEmail` detects non-ASCII content, converts the IDN domain to Punycode via Node's `url.domainToASCII`, and re-tests the assembled `local@ascii-domain` against the framework's pragmatic email regex. Local parts may legitimately contain Unicode under RFC 6531 §3.3 (the regex check substitutes a placeholder local for the format gate; the original local-part is still refused if it contains CRLF/NUL header-injection bytes). **`b.mail.toAscii(domain)`** and **`b.mail.toUnicode(domain)`** are the operator-facing wrappers around `node:url`'s IDN helpers — one obvious place to reach for Punycode encoding when handling addresses outside `send()`. **SMTP transport** now captures EHLO extension lines (250-X continuations) and detects `SMTPUTF8`. Messages whose from / to / cc / bcc / subject contain non-ASCII octets compute `requiresSmtpUtf8 = true`; when the peer advertises `SMTPUTF8` the transport appends ` SMTPUTF8` to `MAIL FROM:<...>` per RFC 6531 §3.4. When the peer does NOT advertise `SMTPUTF8` AND the message requires it, the transport refuses with `mail/smtp-failed: eai-required-not-supported` rather than emit a mangled wire (some peers would silently corrupt headers downstream). Pure-ASCII messages continue without the keyword (some legacy mailboxes reject `SMTPUTF8` outright on transactions that don't need it).
|
|
16
|
+
|
|
17
|
+
- **0.7.71** (2026-05-06) — `b.auth.oauth` adopts the OAuth 2.1 (draft-ietf-oauth-v2-1) baseline. **`pkce: false` is now refused outright** — `create()` throws `auth-oauth/pkce-required` rather than warning-and-continuing. PKCE is required for every client (public AND confidential) per OAuth 2.1; the prior pre-1.0 leniency that emitted a warning and proceeded is closed. Operators integrating with genuinely-broken legacy IdPs that don't accept `code_challenge` must strip the parameters at their own ingress; the framework primitive does not ship that escape hatch. The framework's authorization-code flow already implements the rest of the OAuth 2.1 baseline (`response_type=code` only — no implicit / password / client-credentials grants exposed; mandatory `state`; HTTPS-required `redirect_uri` outside localhost dev opt-in). Wiki docstring updated to reflect the lock-in.
|
|
18
|
+
|
|
19
|
+
- **0.7.70** (2026-05-06) — `b.auth.dpop` + `b.middleware.dpop` — RFC 9449 Demonstrating Proof of Possession. Bearer tokens are bound to a per-client keypair, so an attacker who exfils the access token still can't replay it without stealing the client's private key. **`b.auth.dpop.buildProof({ htm, htu, privateKey, accessToken?, nonce?, jti?, iat? })`** produces a compact-JWS proof with the public key embedded in the header (`typ: "dpop+jwt"`, `jwk`); the proof signs over the request method + canonicalized URI + a fresh `jti`, optionally binding to `ath = sha256(access_token)` and a server-issued nonce. **`b.auth.dpop.verify(proof, { htm, htu, algorithms?, iatWindowSec?, accessToken?, expectedThumbprint?, nonce?, replayStore? })`** validates the proof: typ check, alg-allowlist (HS*/none refused outright), signature verified against the embedded jwk, htm/htu match, iat window, ath match if accessToken supplied, nonce match if supplied, jti replay defense via `b.nonceStore`, jwk-thumbprint match if `expectedThumbprint` supplied. Returns `{ header, payload, jkt }` where `jkt` is the RFC 7638 thumbprint operators compare against the `cnf.jkt` claim of the bound access token. **`b.middleware.dpop({ replayStore, algorithms, getAccessToken, getNonce, getHtu, audit })`** wraps verify into the request lifecycle — reads `req.headers.dpop`, reconstructs htu from `X-Forwarded-{Proto,Host}` + `req.url`, attaches `req.dpop` on success, returns 401 with `WWW-Authenticate: DPoP error="invalid_dpop_proof"` on failure (or `error="use_dpop_nonce"` per RFC 9449 §8 when nonce missing/mismatched). Default algorithm allowlist: ES256/384/512, PS256/384/512, RS256/384/512, EdDSA, ML-DSA-87 (forward-PQC). SLH-DSA-SHAKE-256f is intentionally omitted because Node lacks SLH-DSA JWK round-trip and the alg's ~50 KB signatures + ~80x sign-time penalty make it a poor fit for per-request proofs regardless. JWK header refuses any private-key components (`d`/`p`/`q`/`dp`/`dq`/`qi`/`k`/`priv`) — a proof must NEVER carry the private half. `crit` header rejected outright. The `replayStore` opt accepts the same atomic `checkAndInsert(jti, expireAtMs)` shape as `b.auth.jwt.verify`'s `replayStore` so the same `b.nonceStore.create()` instance can serve both primitives.
|
|
20
|
+
|
|
21
|
+
- **0.7.69** (2026-05-06) — `b.auth.jwt.verify({ replayStore })` — RFC 7519 §4.1.7 jti replay defense. Captured bearer tokens replayed against the verifier (TLS-terminated proxy capture, log scraping, browser-history exposure, leaked Authorization headers in shared dev tools) are refused with `auth-jwt/replay` when an operator wires the new `replayStore` opt. The store contract is the same atomic `checkAndInsert(jti, expireAtMs)` that `b.nonceStore` exposes — first call records the jti, any later call with the same jti returns false and the verifier throws. The token MUST carry a non-empty `jti` claim; without one the verifier throws `auth-jwt/replay-no-jti` rather than silently letting every jti-less token through. Bad-shape stores throw `auth-jwt/bad-replay-store` at config-mistake time; backend-level errors propagate as `auth-jwt/replay-store-failed`. The TTL is bounded by the token's `exp` claim, capped at 24h so an in-memory backend can't be made to grow unbounded by tokens with absurd exp claims. Use `b.nonceStore.create({ backend: "memory" })` for single-node, `{ backend: "cluster" }` for the framework's external-DB cluster posture, or supply a Redis/Memcached/etc. backend by passing any object with a compatible `checkAndInsert` method. Replay defense remains opt-in — the verifier's default behavior is unchanged.
|
|
22
|
+
|
|
23
|
+
- **0.7.68** (2026-05-06) — `b.network.dns.resolveSecure(host, type)` + DNS name-compression parser bug fix. The pre-0.7.68 `_decodeDnsAnswer` had a name-walk bug: after a name-compression pointer (RFC 1035 §3.1 — high two bits `11`), the parser unconditionally executed `if (buf[off] === 0) off++` which consumed the high byte of the next field. This silently broke every DoH response that used name compression in the answer section (which is most of them) — `b.network.dns.lookup` returned "no addresses" against responses where compressed names were used, and the framework fell through to the system resolver fallback path. Fixed by tracking whether the loop exited via a compression pointer and skipping the trailing-zero consume in that case. **`b.network.dns.resolveSecure(host, type)`** is the new DNSSEC-aware resolution API. Returns `{ rrs, ad }` where `ad` is the AD bit (RFC 4035) set by the upstream DoH resolver after chain validation, and `rrs` is the answer-record list. Only available over DoH (`useDnsOverHttps`) — DoT and the system resolver don't surface the AD bit through Node's API today. Operators wiring DANE / TLSA validation (RFC 7672 §1.3) refuse the chain when `ad === false`. The `network-smtp-policy.js` docstring updates to point operators at this primitive. `_readAdBit(buf)` helper extracts the AD bit (byte 3, mask 0x20) from any DNS reply.
|
|
24
|
+
|
|
25
|
+
- **0.7.67** (2026-05-06) — `b.middleware.gpc` (Sec-GPC honoring) + `Reporting-Endpoints` opt on `b.middleware.securityHeaders`. **`b.middleware.gpc({ audit, consent, mode, statusHeader })`** reads the `Sec-GPC: 1` request header (W3C Privacy Sandbox / IETF draft-doty-gpc-header) and sets `req.gpcOptOut = true` for downstream consumers. Optional `consent` integration calls `consent.recordOptOut({ req, purposes, source: "sec-gpc", mode })` so the operator's data-flow primitives can refuse `sale` / `share` / `targeted-ads` / `cross-context-behavioral-advertising` / `profiling` purposes for the session. Echoes a `Sec-GPC-Status: honored` response header so the UA + audit logs see the acknowledgement. **Compliance context** — Sec-GPC is **legally required** by California (CCPA/CPRA) since Jan 2024 and by 12+ US states (Colorado, Connecticut, Texas, Oregon, Delaware, Montana, Iowa, Nebraska, New Hampshire, New Jersey, Maryland, Minnesota) by various dates through Jan 2026. CPPA fines up to $7,500 per intentional violation. **`b.middleware.securityHeaders({ reportingEndpoints: { default: "https://...", csp: "https://..." } })`** — when operator passes a map of endpoint-name → URL, emits `Reporting-Endpoints: name="url", ...` (W3C Reporting API). When the `default` endpoint is set AND the operator hasn't overridden the framework's default CSP, auto-appends `report-to default` to the CSP so violations route to the named endpoint.
|
|
26
|
+
|
|
27
|
+
- **0.7.66** (2026-05-06) — `b.mail.unsubscribe` — RFC 8058 / RFC 2369 List-Unsubscribe support. Two pieces ship together: **`b.mail.unsubscribe.buildHeaders({ url, mailto, oneClick })`** produces the `List-Unsubscribe` and (when `oneClick: true`) `List-Unsubscribe-Post: List-Unsubscribe=One-Click` header values. **`b.mail.unsubscribe.handler({ onUnsubscribe })`** is the request-lifecycle middleware that validates the RFC 8058 §3.1 one-click POST body (`List-Unsubscribe=One-Click` exact byte sequence) and dispatches to the operator's `onUnsubscribe` callback; on success returns 200 OK with empty body. Refuses non-POST (405) / wrong body (400) / oversized body (413). Compliance context: Gmail + Yahoo bulk-sender requirements (Feb 2024) mandate one-click List-Unsubscribe for senders >= 5k/day; Microsoft 365 followed in 2025. **`b.mail.send({ unsubscribe: { ... } })`** is the convenience opt — the existing `send` translates the structured unsubscribe value into the right header pair before transport. Operators using their own header-construction continue to work unchanged.
|
|
28
|
+
|
|
29
|
+
- **0.7.65** (2026-05-06) — Vendor manifest tamper defense + JWT keyResolver kid contract. **`lib/vendor/MANIFEST.json` gains SHA-256 hashes per file.** Each vendored package's `files` map now has a corresponding `hashes` map (`sha256:...` for files, `sha256-tree:...` for directory trees). Closes the supply-chain class where a compromised `scripts/vendor-update.sh` could swap a vendored dependency silently — the on-disk content must match the committed hash. Verification gate ships in `test/layer-0-primitives/vendor-manifest.test.js` so smoke catches drift locally before commit. **`scripts/refresh-vendor-manifest.js`** is the operator-facing tool — run it after `scripts/vendor-update.sh` bumps a vendored package to update the manifest. **`b.auth.jwt` `keyResolver` contract** — the docstring above the resolver call site now points operators at `b.guardJwt.kidSafe(header.kid)` for path-traversal sanitization. The `kidSafe` helper has shipped since v0.7.50; this slice surfaces the contract directly in the JWT verifier so operators wiring custom resolvers (cache lookup, file load, JWKS index) can't miss it.
|
|
30
|
+
|
|
11
31
|
- **0.7.64** (2026-05-06) — HTTP/2 + WebSocket DoS hardening. **HTTP/2 server caps** — `lib/router.js` `http2.createSecureServer` now ships framework-default hardening: `maxConcurrentStreams: 100` (CVE-2023-44487 Rapid Reset cap; Node default was 4294967295), `maxSessionMemory: 10` (MB), `maxHeaderListPairs: 100` (CVE-2024-27983 / CVE-2024-28182 CONTINUATION-flood cap), `maxSettings: 32`, `peerMaxConcurrentStreams: 100`, `unknownProtocolTimeout: 10s` (Slowloris-h2 variant). Operator-supplied `tlsOptions` override any of these. **Slowloris timeouts** — `server.headersTimeout = 60s`, `server.requestTimeout = 5min`, `server.keepAliveTimeout = 5s` set explicitly post-listen. The framework was previously relying on Node-version-shifting defaults; pinning brings older Node releases up to the modern bar. **WebSocket origin default** — *breaking change* (pre-1.0, no compat shim per CLAUDE.md): when `origins` is omitted from `b.websocket.create({ ... })`, the new default is **same-origin enforcement** (Origin header host must match Host header). Pre-0.7.64 the default was "accept all", which is the canonical Cross-Site WebSocket Hijacking (CSWSH) class. Operators needing cross-origin opt in via `origins: "*"` (with audited reason) or `origins: [...allowlist]`. Non-browser clients (no Origin header) continue to bypass — origin enforcement is a browser-class defense.
|
|
12
32
|
|
|
13
33
|
- **0.7.63** (2026-05-06) — gitleaks regex allowlist extended to cover JWT fixtures split across multiple string literals. The v0.7.62 regex only matched the full three-segment JWT compact-serialization shape; source files split long JWT fixtures across literals for line-length, so gitleaks saw individual `eyJ...`-prefixed base64url segments and still flagged them. Added a second allowlist regex matching any `eyJ`-prefixed segment of substantive length (`{20,}`). Same rationale: real signing keys never appear as `eyJ...` base64url tokens — they're PEM / DER / PKCS#8.
|
package/index.js
CHANGED
|
@@ -137,6 +137,8 @@ var auth = {
|
|
|
137
137
|
{ verifyExternal: require("./lib/auth/jwt-external").verifyExternal }),
|
|
138
138
|
oauth: require("./lib/auth/oauth"),
|
|
139
139
|
lockout: require("./lib/auth/lockout"),
|
|
140
|
+
dpop: require("./lib/auth/dpop"),
|
|
141
|
+
aal: require("./lib/auth/aal"),
|
|
140
142
|
};
|
|
141
143
|
var template = require("./lib/template");
|
|
142
144
|
var render = require("./lib/render");
|
package/lib/auth/aal.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* NIST SP 800-63-4 Authentication Assurance Levels.
|
|
4
|
+
*
|
|
5
|
+
* Three bands (AAL1, AAL2, AAL3) describe the rigor of the
|
|
6
|
+
* authentication ceremony that gated this session. Operators wiring
|
|
7
|
+
* step-up flows compare the AAL band of an incoming request against
|
|
8
|
+
* the minimum required for a given route — break-glass / financial /
|
|
9
|
+
* PHI / admin paths gate at AAL2 or AAL3, low-risk read paths at
|
|
10
|
+
* AAL1.
|
|
11
|
+
*
|
|
12
|
+
* const aal = b.auth.aal.fromMethods({
|
|
13
|
+
* password: true, // memorized secret (single factor)
|
|
14
|
+
* webauthn: true, // multi-factor cryptographic authenticator
|
|
15
|
+
* }); // → "AAL3"
|
|
16
|
+
*
|
|
17
|
+
* b.middleware.requireAal({ minimum: "AAL2" })
|
|
18
|
+
*
|
|
19
|
+
* SP 800-63-4 (final, 2026) replaces SP 800-63-3 (2017). The band
|
|
20
|
+
* ordering is unchanged; the framework helpers reflect the 2026
|
|
21
|
+
* vocabulary (memorized secret / single-factor / multi-factor /
|
|
22
|
+
* phishing-resistant) rather than the older "Something you know /
|
|
23
|
+
* are / have" trichotomy.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
var validateOpts = require("../validate-opts");
|
|
27
|
+
var { AuthError } = require("../framework-error");
|
|
28
|
+
|
|
29
|
+
// ---- band constants ----
|
|
30
|
+
//
|
|
31
|
+
// Strings (not enums) so operator audit logs / observability sinks
|
|
32
|
+
// see the canonical "AAL1" / "AAL2" / "AAL3" labels directly.
|
|
33
|
+
|
|
34
|
+
var AAL1 = "AAL1";
|
|
35
|
+
var AAL2 = "AAL2";
|
|
36
|
+
var AAL3 = "AAL3";
|
|
37
|
+
|
|
38
|
+
var BANDS_ORDER = [AAL1, AAL2, AAL3];
|
|
39
|
+
|
|
40
|
+
function _bandRank(band) {
|
|
41
|
+
var idx = BANDS_ORDER.indexOf(band);
|
|
42
|
+
if (idx === -1) return -1;
|
|
43
|
+
return idx;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- method classification ----
|
|
47
|
+
//
|
|
48
|
+
// Per SP 800-63B / 63-4 §4.2.1 — each authenticator class carries an
|
|
49
|
+
// implicit factor count. The compose helper below combines a set of
|
|
50
|
+
// satisfied methods into the resulting AAL band.
|
|
51
|
+
//
|
|
52
|
+
// METHOD CLASSES:
|
|
53
|
+
// - password / pin → memorized-secret single factor
|
|
54
|
+
// - totp → out-of-band single factor
|
|
55
|
+
// - sms → restricted single factor (SP 800-63-4 §5.1.3.3
|
|
56
|
+
// marks SMS as RESTRICTED — fine for AAL1 only)
|
|
57
|
+
// - webauthn → cryptographic multi-factor (verifier-attached
|
|
58
|
+
// UV=true → phishing-resistant per SP 800-63B §5.2.5)
|
|
59
|
+
// - passkey → synonym for webauthn-with-UV (operator
|
|
60
|
+
// contract: a "passkey" implies UV=true)
|
|
61
|
+
// - hardware → hardware cryptographic single factor
|
|
62
|
+
// (smart card, FIDO U2F-only)
|
|
63
|
+
// - mtls → cryptographic single factor; combine with
|
|
64
|
+
// memorized secret for AAL2
|
|
65
|
+
//
|
|
66
|
+
// The compose function takes a methods-object `{ password: true,
|
|
67
|
+
// webauthn: true, ... }` and returns the resulting band. Operators
|
|
68
|
+
// supply the methods object based on what THEIR auth flow verified.
|
|
69
|
+
|
|
70
|
+
var KNOWN_METHODS = [
|
|
71
|
+
"password", "pin", "totp", "sms", "webauthn", "passkey",
|
|
72
|
+
"hardware", "mtls",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function fromMethods(methods) {
|
|
76
|
+
if (!methods || typeof methods !== "object") {
|
|
77
|
+
throw new AuthError("auth-aal/bad-methods",
|
|
78
|
+
"fromMethods: methods must be an object like { password: true, webauthn: true }");
|
|
79
|
+
}
|
|
80
|
+
var has = function (m) { return methods[m] === true; };
|
|
81
|
+
|
|
82
|
+
// Phishing-resistant multi-factor → AAL3.
|
|
83
|
+
// - WebAuthn / passkey with UV=true alone satisfies AAL3 per
|
|
84
|
+
// SP 800-63-4 §5.1.7 (cryptographic authenticator + verifier impl
|
|
85
|
+
// binding + user verification = MF-CRYPT).
|
|
86
|
+
// - Hardware authenticator + memorized secret also satisfies AAL3
|
|
87
|
+
// (SF-CRYPT + memorized = MF-equivalent under §4.4.1).
|
|
88
|
+
if (has("webauthn") || has("passkey")) return AAL3;
|
|
89
|
+
if (has("hardware") && (has("password") || has("pin"))) return AAL3;
|
|
90
|
+
|
|
91
|
+
// Multi-factor → AAL2.
|
|
92
|
+
// - password + totp / sms / hardware single-factor / mtls.
|
|
93
|
+
// - SP 800-63-4 §5.1.3.3 — SMS is RESTRICTED but still satisfies
|
|
94
|
+
// AAL2 with the operator's documented risk acceptance.
|
|
95
|
+
if (has("password") || has("pin")) {
|
|
96
|
+
if (has("totp") || has("sms") || has("hardware") || has("mtls")) return AAL2;
|
|
97
|
+
return AAL1; // memorized secret alone
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Single-factor cryptographic — mtls / hardware on its own → AAL1
|
|
101
|
+
// unless paired with a memorized secret (handled above).
|
|
102
|
+
if (has("hardware") || has("mtls")) return AAL1;
|
|
103
|
+
|
|
104
|
+
// No recognized method asserted.
|
|
105
|
+
throw new AuthError("auth-aal/no-methods",
|
|
106
|
+
"fromMethods: methods object did not assert any known authenticator " +
|
|
107
|
+
"(known: " + KNOWN_METHODS.join(", ") + ")");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isValidBand(band) {
|
|
111
|
+
return band === AAL1 || band === AAL2 || band === AAL3;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function meets(actualBand, requiredBand) {
|
|
115
|
+
if (!isValidBand(actualBand)) return false;
|
|
116
|
+
if (!isValidBand(requiredBand)) return false;
|
|
117
|
+
return _bandRank(actualBand) >= _bandRank(requiredBand);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- helper for operator-side AAL ↔ AMR JWT claim ----
|
|
121
|
+
//
|
|
122
|
+
// SP 800-63-4 doesn't define a JWT claim shape; operators emitting
|
|
123
|
+
// access tokens with AAL info typically use `acr` / `amr` (RFC 9068
|
|
124
|
+
// §3 / OpenID Connect Core §2). The framework leaves that wiring to
|
|
125
|
+
// the operator — but the constants make the AMR strings consistent.
|
|
126
|
+
var AMR = Object.freeze({
|
|
127
|
+
PASSWORD: "pwd",
|
|
128
|
+
PIN: "pin",
|
|
129
|
+
TOTP: "otp",
|
|
130
|
+
SMS: "sms",
|
|
131
|
+
WEBAUTHN: "fido-u2f",
|
|
132
|
+
PASSKEY: "passkey",
|
|
133
|
+
HARDWARE: "hwk",
|
|
134
|
+
MTLS: "mtls",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
AAL1: AAL1,
|
|
139
|
+
AAL2: AAL2,
|
|
140
|
+
AAL3: AAL3,
|
|
141
|
+
BANDS: Object.freeze([AAL1, AAL2, AAL3]),
|
|
142
|
+
KNOWN_METHODS: Object.freeze(KNOWN_METHODS),
|
|
143
|
+
AMR: AMR,
|
|
144
|
+
fromMethods: fromMethods,
|
|
145
|
+
isValidBand: isValidBand,
|
|
146
|
+
meets: meets,
|
|
147
|
+
// Operator-facing optional-band validator — used by middleware below.
|
|
148
|
+
_validateOpts: validateOpts,
|
|
149
|
+
};
|