@blamejs/core 0.7.107 → 0.8.4
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 +41 -1
- package/NOTICE +17 -1
- package/README.md +4 -3
- package/index.js +15 -0
- package/lib/asyncapi-bindings.js +160 -0
- package/lib/asyncapi-traits.js +143 -0
- package/lib/asyncapi.js +531 -0
- package/lib/audit-sign.js +1 -1
- package/lib/audit.js +68 -2
- package/lib/auth/acr-vocabulary.js +265 -0
- package/lib/auth/auth-time-tracker.js +111 -0
- package/lib/auth/elevation-grant.js +306 -0
- package/lib/auth/jwt.js +13 -0
- package/lib/auth/lockout.js +16 -3
- package/lib/auth/oauth.js +15 -1
- package/lib/auth/password.js +22 -2
- package/lib/auth/sd-jwt-vc-issuer.js +2 -2
- package/lib/auth/sd-jwt-vc.js +7 -2
- package/lib/auth/step-up-policy.js +335 -0
- package/lib/auth/step-up.js +445 -0
- package/lib/break-glass.js +53 -14
- package/lib/cache-redis.js +1 -1
- package/lib/cache.js +6 -1
- package/lib/cli.js +3 -3
- package/lib/cluster.js +24 -1
- package/lib/compliance-ai-act-logging.js +190 -0
- package/lib/compliance-ai-act-prohibited.js +205 -0
- package/lib/compliance-ai-act-risk.js +189 -0
- package/lib/compliance-ai-act-transparency.js +200 -0
- package/lib/compliance-ai-act.js +558 -0
- package/lib/compliance.js +12 -2
- package/lib/config-drift.js +2 -2
- package/lib/crypto-field.js +21 -1
- package/lib/crypto.js +114 -1
- package/lib/db.js +35 -4
- package/lib/dev.js +30 -3
- package/lib/dual-control.js +19 -1
- package/lib/external-db.js +10 -0
- package/lib/file-upload.js +30 -3
- package/lib/flag-cache.js +136 -0
- package/lib/flag-evaluation-context.js +135 -0
- package/lib/flag-providers.js +279 -0
- package/lib/flag-targeting.js +210 -0
- package/lib/flag.js +284 -0
- package/lib/guard-all.js +33 -16
- package/lib/guard-csv.js +16 -2
- package/lib/guard-html.js +35 -0
- package/lib/guard-svg.js +20 -0
- package/lib/http-client.js +57 -11
- package/lib/inbox.js +391 -0
- package/lib/log-stream-syslog.js +8 -0
- package/lib/log-stream.js +1 -1
- package/lib/mail-arc-sign.js +372 -0
- package/lib/mail-auth.js +2 -0
- package/lib/mail.js +40 -0
- package/lib/middleware/ai-act-disclosure.js +166 -0
- package/lib/middleware/asyncapi-serve.js +136 -0
- package/lib/middleware/attach-user.js +25 -2
- package/lib/middleware/bearer-auth.js +71 -6
- package/lib/middleware/body-parser.js +13 -0
- package/lib/middleware/cors.js +10 -0
- package/lib/middleware/csrf-protect.js +34 -3
- package/lib/middleware/dpop.js +3 -3
- package/lib/middleware/flag-context.js +76 -0
- package/lib/middleware/host-allowlist.js +1 -1
- package/lib/middleware/index.js +15 -0
- package/lib/middleware/openapi-serve.js +143 -0
- package/lib/middleware/require-aal.js +2 -2
- package/lib/middleware/require-step-up.js +186 -0
- package/lib/middleware/trace-propagate.js +1 -1
- package/lib/mtls-ca.js +23 -29
- package/lib/mtls-engine-default.js +21 -1
- package/lib/network-tls.js +21 -6
- package/lib/object-store/sigv4-bucket-ops.js +41 -0
- package/lib/observability-otlp-exporter.js +35 -2
- package/lib/openapi-paths-builder.js +248 -0
- package/lib/openapi-schema-walk.js +192 -0
- package/lib/openapi-security.js +169 -0
- package/lib/openapi-yaml.js +154 -0
- package/lib/openapi.js +443 -0
- package/lib/outbox.js +3 -3
- package/lib/permissions.js +10 -1
- package/lib/pqc-agent.js +22 -1
- package/lib/pqc-software.js +195 -0
- package/lib/pubsub.js +8 -4
- package/lib/redact.js +26 -1
- package/lib/retention.js +26 -0
- package/lib/router.js +1 -0
- package/lib/scheduler.js +57 -1
- package/lib/session.js +3 -3
- package/lib/ssrf-guard.js +19 -4
- package/lib/static.js +12 -0
- package/lib/totp.js +16 -0
- package/lib/vault/index.js +3 -0
- package/lib/vault-aad.js +259 -0
- package/lib/vendor/MANIFEST.json +29 -0
- package/lib/vendor/noble-post-quantum.cjs +18 -0
- package/lib/ws-client.js +978 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC 9470 — OAuth 2.0 Step-Up Authentication Challenge.
|
|
4
|
+
*
|
|
5
|
+
* Step-up flows let a resource server demand a stronger or fresher
|
|
6
|
+
* authentication ceremony before serving a particular request. The
|
|
7
|
+
* challenge shape is fixed by RFC 9470:
|
|
8
|
+
*
|
|
9
|
+
* HTTP/1.1 401 Unauthorized
|
|
10
|
+
* WWW-Authenticate: Bearer error="insufficient_user_authentication",
|
|
11
|
+
* error_description="A higher level of authentication is required",
|
|
12
|
+
* acr_values="urn:mace:incommon:iap:silver",
|
|
13
|
+
* max_age="300"
|
|
14
|
+
*
|
|
15
|
+
* The corresponding error code, `insufficient_user_authentication`, is
|
|
16
|
+
* registered in the OAuth Extensions Error Registry; clients MUST
|
|
17
|
+
* recognise it and re-trigger the auth-flow with `acr_values` and/or
|
|
18
|
+
* `max_age` propagated to the IdP.
|
|
19
|
+
*
|
|
20
|
+
* Public surface (b.auth.stepUp.*):
|
|
21
|
+
*
|
|
22
|
+
* .evaluate({ claims, requirement, now? })
|
|
23
|
+
* → { ok: true } | { ok: false, error, requirement }
|
|
24
|
+
*
|
|
25
|
+
* .buildChallenge({ requirement, realm?, error?, errorDescription? })
|
|
26
|
+
* → "Bearer error=\"insufficient_user_authentication\", ..."
|
|
27
|
+
*
|
|
28
|
+
* .acr.register({ value, rank }) (delegates to acr-vocabulary)
|
|
29
|
+
* .acr.meets(presented, required)
|
|
30
|
+
*
|
|
31
|
+
* .grant.create({ subject, scope, acr, amr, evidence?, ttlSec? })
|
|
32
|
+
* → { token, expiresAt, jti }
|
|
33
|
+
* .grant.verify(token, { audience?, scope? })
|
|
34
|
+
* → claims object
|
|
35
|
+
*
|
|
36
|
+
* .parseAuthorizationDetails(value) (RFC 9396 helper)
|
|
37
|
+
*
|
|
38
|
+
* Requirement object shape:
|
|
39
|
+
* {
|
|
40
|
+
* acr: "urn:..." (optional; one acr to require)
|
|
41
|
+
* acrValues: [ "...", "..." ] (optional; ANY satisfies)
|
|
42
|
+
* maxAge: 300 (optional, seconds — RFC 9470)
|
|
43
|
+
* requiredAmr: [ "hwk", "pop" ] (optional; AMR must include all)
|
|
44
|
+
* phishingResistant: true (optional; AMR must include any
|
|
45
|
+
* phishing-resistant method)
|
|
46
|
+
* authorizationDetails: [ {...} ] (optional; RFC 9396 fine-grained)
|
|
47
|
+
* }
|
|
48
|
+
*
|
|
49
|
+
* Per the validation-tier policy: configuration entry-points (.buildChallenge,
|
|
50
|
+
* .grant.create, .acr.register) THROW on bad input — operator catches the
|
|
51
|
+
* typo at boot. The hot-path (.evaluate) never throws — it returns the
|
|
52
|
+
* structured failure so the middleware can emit a 401.
|
|
53
|
+
*
|
|
54
|
+
* Audit emissions on every state transition:
|
|
55
|
+
* - auth.stepUp.required (challenge emitted)
|
|
56
|
+
* - auth.stepUp.satisfied (request passed evaluation)
|
|
57
|
+
* - auth.stepUp.denied (request failed)
|
|
58
|
+
* - auth.stepUp.grant.issued (elevation grant minted)
|
|
59
|
+
* - auth.stepUp.grant.consumed (elevation grant used)
|
|
60
|
+
* - auth.stepUp.grant.revoked (elevation grant revoked)
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
var lazyRequire = require("../lazy-require");
|
|
64
|
+
var validateOpts = require("../validate-opts");
|
|
65
|
+
var safeJson = require("../safe-json");
|
|
66
|
+
var C = require("../constants");
|
|
67
|
+
var { AuthError } = require("../framework-error");
|
|
68
|
+
|
|
69
|
+
var acr = require("./acr-vocabulary");
|
|
70
|
+
var authTime = require("./auth-time-tracker");
|
|
71
|
+
var elevation = lazyRequire(function () { return require("./elevation-grant"); });
|
|
72
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
73
|
+
|
|
74
|
+
var INSUFFICIENT_USER_AUTHENTICATION = "insufficient_user_authentication";
|
|
75
|
+
var DEFAULT_REALM = "api";
|
|
76
|
+
|
|
77
|
+
function _readPresentedClaims(claims) {
|
|
78
|
+
return authTime.readClaims(claims);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Quote a value for inclusion in a WWW-Authenticate parameter per RFC
|
|
82
|
+
// 7235 §2.2 and RFC 9470 §3 (uses `quoted-string` for all values).
|
|
83
|
+
function _quote(value) {
|
|
84
|
+
if (typeof value !== "string") value = String(value);
|
|
85
|
+
// Reject CTLs and quote-injecting characters.
|
|
86
|
+
for (var i = 0; i < value.length; i += 1) {
|
|
87
|
+
var code = value.charCodeAt(i);
|
|
88
|
+
if (code < 32 || code === 127) { // allow:raw-byte-literal — ASCII control codepoints
|
|
89
|
+
throw new AuthError("auth-stepUp/bad-challenge",
|
|
90
|
+
"challenge value contains control character at index " + i);
|
|
91
|
+
}
|
|
92
|
+
if (value.charAt(i) === '"' || value.charAt(i) === "\\") {
|
|
93
|
+
throw new AuthError("auth-stepUp/bad-challenge",
|
|
94
|
+
"challenge value contains illegal character " +
|
|
95
|
+
JSON.stringify(value.charAt(i)) + " at index " + i);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return '"' + value + '"';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function _validateRequirement(requirement, label) {
|
|
102
|
+
if (!requirement || typeof requirement !== "object") {
|
|
103
|
+
throw new AuthError("auth-stepUp/bad-requirement",
|
|
104
|
+
label + ": requirement must be an object — got " +
|
|
105
|
+
JSON.stringify(requirement));
|
|
106
|
+
}
|
|
107
|
+
validateOpts(requirement, [
|
|
108
|
+
"acr", "acrValues", "maxAge", "requiredAmr", "phishingResistant",
|
|
109
|
+
"authorizationDetails",
|
|
110
|
+
], label);
|
|
111
|
+
if (requirement.acr != null) {
|
|
112
|
+
validateOpts.requireNonEmptyString(requirement.acr,
|
|
113
|
+
label + ": acr", AuthError, "auth-stepUp/bad-acr");
|
|
114
|
+
}
|
|
115
|
+
if (requirement.acrValues != null) {
|
|
116
|
+
if (!Array.isArray(requirement.acrValues) || requirement.acrValues.length === 0) {
|
|
117
|
+
throw new AuthError("auth-stepUp/bad-acr",
|
|
118
|
+
label + ": acrValues must be a non-empty string array");
|
|
119
|
+
}
|
|
120
|
+
for (var i = 0; i < requirement.acrValues.length; i += 1) {
|
|
121
|
+
validateOpts.requireNonEmptyString(requirement.acrValues[i],
|
|
122
|
+
label + ": acrValues[" + i + "]", AuthError, "auth-stepUp/bad-acr");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (requirement.maxAge != null) {
|
|
126
|
+
if (typeof requirement.maxAge !== "number" || !isFinite(requirement.maxAge) ||
|
|
127
|
+
requirement.maxAge < 0) {
|
|
128
|
+
throw new AuthError("auth-stepUp/bad-max-age",
|
|
129
|
+
label + ": maxAge must be a finite number >= 0 — got " +
|
|
130
|
+
JSON.stringify(requirement.maxAge));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (requirement.requiredAmr != null) {
|
|
134
|
+
if (!Array.isArray(requirement.requiredAmr)) {
|
|
135
|
+
throw new AuthError("auth-stepUp/bad-amr",
|
|
136
|
+
label + ": requiredAmr must be a string array");
|
|
137
|
+
}
|
|
138
|
+
for (var j = 0; j < requirement.requiredAmr.length; j += 1) {
|
|
139
|
+
validateOpts.requireNonEmptyString(requirement.requiredAmr[j],
|
|
140
|
+
label + ": requiredAmr[" + j + "]", AuthError, "auth-stepUp/bad-amr");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (requirement.phishingResistant != null &&
|
|
144
|
+
typeof requirement.phishingResistant !== "boolean") {
|
|
145
|
+
throw new AuthError("auth-stepUp/bad-requirement",
|
|
146
|
+
label + ": phishingResistant must be boolean — got " +
|
|
147
|
+
JSON.stringify(requirement.phishingResistant));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function evaluate(opts) {
|
|
152
|
+
opts = opts || {};
|
|
153
|
+
var claims = opts.claims;
|
|
154
|
+
var requirement = opts.requirement;
|
|
155
|
+
if (!requirement || typeof requirement !== "object") {
|
|
156
|
+
return { ok: false, error: "no_requirement", reason: "evaluate: requirement object missing" };
|
|
157
|
+
}
|
|
158
|
+
// Hot-path drop-silent: do not throw on typo — return structured
|
|
159
|
+
// failure. But surface unregistered-acr because that's an operator-
|
|
160
|
+
// side typo that should bubble up.
|
|
161
|
+
try { _validateRequirement(requirement, "auth.stepUp.evaluate"); }
|
|
162
|
+
catch (err) { return { ok: false, error: "bad_requirement", reason: err.message }; }
|
|
163
|
+
|
|
164
|
+
var presented = _readPresentedClaims(claims);
|
|
165
|
+
var now = (typeof opts.now === "number") ? opts.now : Math.floor(Date.now() / C.TIME.seconds(1));
|
|
166
|
+
|
|
167
|
+
// 1. ACR check (single)
|
|
168
|
+
if (typeof requirement.acr === "string") {
|
|
169
|
+
if (!acr.isRegistered(requirement.acr)) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false, error: "unknown_acr",
|
|
172
|
+
reason: "evaluate: required acr is not registered: " + requirement.acr,
|
|
173
|
+
requirement: requirement,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (!acr.meets(presented.acr, requirement.acr)) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
|
|
179
|
+
reason: "presented acr " + JSON.stringify(presented.acr) +
|
|
180
|
+
" does not meet required " + JSON.stringify(requirement.acr),
|
|
181
|
+
requirement: requirement, presented: presented,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// 2. ACR-values list (any one suffices)
|
|
186
|
+
if (Array.isArray(requirement.acrValues) && requirement.acrValues.length > 0) {
|
|
187
|
+
if (!acr.meetsAny(presented.acr, requirement.acrValues)) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
|
|
190
|
+
reason: "presented acr " + JSON.stringify(presented.acr) +
|
|
191
|
+
" does not meet any of " + JSON.stringify(requirement.acrValues),
|
|
192
|
+
requirement: requirement, presented: presented,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// 3. max_age freshness
|
|
197
|
+
if (typeof requirement.maxAge === "number") {
|
|
198
|
+
if (!authTime.freshEnough(claims, requirement.maxAge, now)) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
|
|
201
|
+
reason: "auth_time stale or missing — required max_age=" +
|
|
202
|
+
requirement.maxAge + "s, age=" + authTime.ageSec(claims, now),
|
|
203
|
+
requirement: requirement, presented: presented,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// 4. AMR — required methods
|
|
208
|
+
if (Array.isArray(requirement.requiredAmr) && requirement.requiredAmr.length > 0) {
|
|
209
|
+
if (!acr.amrSatisfiesRequiredList(presented.amr, requirement.requiredAmr)) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
|
|
212
|
+
reason: "presented amr " + JSON.stringify(presented.amr) +
|
|
213
|
+
" does not include all required " + JSON.stringify(requirement.requiredAmr),
|
|
214
|
+
requirement: requirement, presented: presented,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// 5. AMR — phishing resistance
|
|
219
|
+
if (requirement.phishingResistant === true) {
|
|
220
|
+
if (!acr.amrIncludesPhishingResistant(presented.amr)) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false, error: INSUFFICIENT_USER_AUTHENTICATION,
|
|
223
|
+
reason: "presented amr " + JSON.stringify(presented.amr) +
|
|
224
|
+
" does not include any phishing-resistant method",
|
|
225
|
+
requirement: requirement, presented: presented,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, presented: presented };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildChallenge(opts) {
|
|
233
|
+
opts = opts || {};
|
|
234
|
+
validateOpts(opts, [
|
|
235
|
+
"requirement", "realm", "error", "errorDescription", "scope",
|
|
236
|
+
], "auth.stepUp.buildChallenge");
|
|
237
|
+
_validateRequirement(opts.requirement, "auth.stepUp.buildChallenge");
|
|
238
|
+
var realm = (typeof opts.realm === "string" && opts.realm.length > 0) ? opts.realm : DEFAULT_REALM;
|
|
239
|
+
var errCode = (typeof opts.error === "string" && opts.error.length > 0)
|
|
240
|
+
? opts.error : INSUFFICIENT_USER_AUTHENTICATION;
|
|
241
|
+
var errDesc = (typeof opts.errorDescription === "string" && opts.errorDescription.length > 0)
|
|
242
|
+
? opts.errorDescription : "A higher level of authentication is required";
|
|
243
|
+
|
|
244
|
+
var parts = [];
|
|
245
|
+
parts.push('realm=' + _quote(realm));
|
|
246
|
+
parts.push('error=' + _quote(errCode));
|
|
247
|
+
parts.push('error_description=' + _quote(errDesc));
|
|
248
|
+
if (typeof opts.scope === "string" && opts.scope.length > 0) {
|
|
249
|
+
parts.push('scope=' + _quote(opts.scope));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
var req = opts.requirement;
|
|
253
|
+
// Per RFC 9470 §3: emit acr_values as space-separated string per RFC 6749.
|
|
254
|
+
if (typeof req.acr === "string" && req.acr.length > 0) {
|
|
255
|
+
parts.push('acr_values=' + _quote(req.acr));
|
|
256
|
+
} else if (Array.isArray(req.acrValues) && req.acrValues.length > 0) {
|
|
257
|
+
parts.push('acr_values=' + _quote(req.acrValues.join(" ")));
|
|
258
|
+
}
|
|
259
|
+
if (typeof req.maxAge === "number") {
|
|
260
|
+
parts.push('max_age=' + _quote(String(req.maxAge)));
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(req.requiredAmr) && req.requiredAmr.length > 0) {
|
|
263
|
+
parts.push('amr_values=' + _quote(req.requiredAmr.join(" ")));
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(req.authorizationDetails) && req.authorizationDetails.length > 0) {
|
|
266
|
+
parts.push('authorization_details=' + _quote(JSON.stringify(req.authorizationDetails)));
|
|
267
|
+
}
|
|
268
|
+
return "Bearer " + parts.join(", ");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// RFC 9396 helper — parse the JSON-array authorization_details parameter.
|
|
272
|
+
// Throws on malformed payload at config time (operator typo at boot).
|
|
273
|
+
// Hot-path callers wrap this in try/catch.
|
|
274
|
+
function parseAuthorizationDetails(value) {
|
|
275
|
+
if (typeof value !== "string") {
|
|
276
|
+
throw new AuthError("auth-stepUp/bad-rar",
|
|
277
|
+
"parseAuthorizationDetails: value must be a JSON string — got " +
|
|
278
|
+
typeof value);
|
|
279
|
+
}
|
|
280
|
+
var parsed;
|
|
281
|
+
try { parsed = safeJson.parse(value, { maxBytes: C.BYTES.kib(64) }); }
|
|
282
|
+
catch (e) {
|
|
283
|
+
throw new AuthError("auth-stepUp/bad-rar",
|
|
284
|
+
"parseAuthorizationDetails: invalid JSON — " + e.message);
|
|
285
|
+
}
|
|
286
|
+
if (!Array.isArray(parsed)) {
|
|
287
|
+
throw new AuthError("auth-stepUp/bad-rar",
|
|
288
|
+
"parseAuthorizationDetails: value must be a JSON array — got " +
|
|
289
|
+
typeof parsed);
|
|
290
|
+
}
|
|
291
|
+
for (var i = 0; i < parsed.length; i += 1) {
|
|
292
|
+
var entry = parsed[i];
|
|
293
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
294
|
+
throw new AuthError("auth-stepUp/bad-rar",
|
|
295
|
+
"parseAuthorizationDetails[" + i + "]: must be an object");
|
|
296
|
+
}
|
|
297
|
+
if (typeof entry.type !== "string" || entry.type.length === 0) {
|
|
298
|
+
throw new AuthError("auth-stepUp/bad-rar",
|
|
299
|
+
"parseAuthorizationDetails[" + i + "]: missing required 'type' field");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return parsed;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function emitAuditRequired(label, requirement, presented, req) {
|
|
306
|
+
try {
|
|
307
|
+
audit().safeEmit({
|
|
308
|
+
action: "auth.stepup.required",
|
|
309
|
+
outcome: "denied",
|
|
310
|
+
actor: { route: req && (req.url || req.pathname) || null,
|
|
311
|
+
userId: req && req.user && req.user.id || null },
|
|
312
|
+
metadata: {
|
|
313
|
+
label: label || "stepUp",
|
|
314
|
+
requirement: _summarizeRequirement(requirement),
|
|
315
|
+
presented: _summarizePresented(presented),
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
} catch (_e) { /* drop-silent */ }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function emitAuditSatisfied(label, requirement, presented, req) {
|
|
322
|
+
try {
|
|
323
|
+
audit().safeEmit({
|
|
324
|
+
action: "auth.stepup.satisfied",
|
|
325
|
+
outcome: "success",
|
|
326
|
+
actor: { route: req && (req.url || req.pathname) || null,
|
|
327
|
+
userId: req && req.user && req.user.id || null },
|
|
328
|
+
metadata: {
|
|
329
|
+
label: label || "stepUp",
|
|
330
|
+
requirement: _summarizeRequirement(requirement),
|
|
331
|
+
presented: _summarizePresented(presented),
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
} catch (_e) { /* drop-silent */ }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function _summarizeRequirement(req) {
|
|
338
|
+
if (!req || typeof req !== "object") return null;
|
|
339
|
+
return {
|
|
340
|
+
acr: req.acr || null,
|
|
341
|
+
acrValues: Array.isArray(req.acrValues) ? req.acrValues.slice() : null,
|
|
342
|
+
maxAge: (typeof req.maxAge === "number") ? req.maxAge : null,
|
|
343
|
+
requiredAmr: Array.isArray(req.requiredAmr) ? req.requiredAmr.slice() : null,
|
|
344
|
+
phishingResistant: req.phishingResistant === true ? true : false,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function _summarizePresented(presented) {
|
|
349
|
+
if (!presented || typeof presented !== "object") return null;
|
|
350
|
+
return {
|
|
351
|
+
acr: presented.acr || null,
|
|
352
|
+
amr: Array.isArray(presented.amr) ? presented.amr.slice() : null,
|
|
353
|
+
auth_time: presented.auth_time || null,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- Bearer-challenge parser (RFC 7235 §2.1, RFC 9470 §3) ----
|
|
358
|
+
//
|
|
359
|
+
// Operator-side helper to inspect what an upstream RS challenged with.
|
|
360
|
+
// Returns null when the header doesn't carry a Bearer challenge or
|
|
361
|
+
// doesn't carry the insufficient_user_authentication error.
|
|
362
|
+
|
|
363
|
+
function parseChallenge(headerValue) {
|
|
364
|
+
if (typeof headerValue !== "string") return null;
|
|
365
|
+
// Tolerate "Bearer " prefix in any case; reject anything else.
|
|
366
|
+
var idx = headerValue.toLowerCase().indexOf("bearer");
|
|
367
|
+
if (idx === -1) return null;
|
|
368
|
+
var rest = headerValue.slice(idx + "bearer".length).trim();
|
|
369
|
+
if (rest.length === 0) return null;
|
|
370
|
+
var out = { error: null, scope: null, acrValues: null, maxAge: null, raw: {} };
|
|
371
|
+
// Split on commas at top level, but respect quoted strings.
|
|
372
|
+
var tokens = _splitWwwAuth(rest);
|
|
373
|
+
for (var i = 0; i < tokens.length; i += 1) {
|
|
374
|
+
var token = tokens[i].trim();
|
|
375
|
+
var eq = token.indexOf("=");
|
|
376
|
+
if (eq === -1) continue;
|
|
377
|
+
var key = token.slice(0, eq).trim().toLowerCase();
|
|
378
|
+
var val = token.slice(eq + 1).trim();
|
|
379
|
+
if (val.length >= 2 && val.charAt(0) === '"' && val.charAt(val.length - 1) === '"') {
|
|
380
|
+
val = val.slice(1, val.length - 1);
|
|
381
|
+
}
|
|
382
|
+
out.raw[key] = val;
|
|
383
|
+
if (key === "error") out.error = val;
|
|
384
|
+
else if (key === "scope") out.scope = val;
|
|
385
|
+
else if (key === "acr_values") out.acrValues = val.split(/\s+/);
|
|
386
|
+
else if (key === "max_age") out.maxAge = parseInt(val, 10);
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function _splitWwwAuth(raw) {
|
|
392
|
+
var tokens = [];
|
|
393
|
+
var cursor = 0;
|
|
394
|
+
var inQuoted = false;
|
|
395
|
+
var current = "";
|
|
396
|
+
while (cursor < raw.length) {
|
|
397
|
+
var ch = raw.charAt(cursor);
|
|
398
|
+
if (inQuoted) {
|
|
399
|
+
current += ch;
|
|
400
|
+
if (ch === "\\" && cursor + 1 < raw.length) {
|
|
401
|
+
current += raw.charAt(cursor + 1);
|
|
402
|
+
cursor += 2;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (ch === '"') inQuoted = false;
|
|
406
|
+
cursor += 1;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (ch === '"') { inQuoted = true; current += ch; cursor += 1; continue; }
|
|
410
|
+
if (ch === ",") {
|
|
411
|
+
tokens.push(current);
|
|
412
|
+
current = "";
|
|
413
|
+
cursor += 1;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
current += ch;
|
|
417
|
+
cursor += 1;
|
|
418
|
+
}
|
|
419
|
+
if (current.length > 0) tokens.push(current);
|
|
420
|
+
return tokens;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
var policy = lazyRequire(function () { return require("./step-up-policy"); });
|
|
424
|
+
|
|
425
|
+
module.exports = {
|
|
426
|
+
evaluate: evaluate,
|
|
427
|
+
buildChallenge: buildChallenge,
|
|
428
|
+
parseChallenge: parseChallenge,
|
|
429
|
+
parseAuthorizationDetails: parseAuthorizationDetails,
|
|
430
|
+
acr: acr,
|
|
431
|
+
authTime: authTime,
|
|
432
|
+
get policy() { return policy(); },
|
|
433
|
+
grant: {
|
|
434
|
+
create: function (opts) { return elevation().create(opts); },
|
|
435
|
+
verify: function (token, opts) { return elevation().verify(token, opts); },
|
|
436
|
+
revoke: function (jti, opts) { return elevation().revoke(jti, opts); },
|
|
437
|
+
isRevoked: function (jti) { return elevation().isRevoked(jti); },
|
|
438
|
+
list: function () { return elevation().list(); },
|
|
439
|
+
setSigningKey: function (key) { return elevation().setSigningKey(key); },
|
|
440
|
+
_resetForTests: function () { return elevation()._resetForTests(); },
|
|
441
|
+
},
|
|
442
|
+
emitAuditRequired: emitAuditRequired,
|
|
443
|
+
emitAuditSatisfied: emitAuditSatisfied,
|
|
444
|
+
INSUFFICIENT_USER_AUTHENTICATION: INSUFFICIENT_USER_AUTHENTICATION,
|
|
445
|
+
};
|
package/lib/break-glass.js
CHANGED
|
@@ -693,6 +693,45 @@ async function grant(opts) {
|
|
|
693
693
|
"grant: no authenticated actor on request (req.user.id / req.apiKey.id required)", true);
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
+
// Scope-gate enforcement — when the policy declares requireScope,
|
|
697
|
+
// the actor must carry the named scope (or matching wildcard via
|
|
698
|
+
// b.permissions.match) before the framework will mint a grant.
|
|
699
|
+
// Without this, every TOTP-passing actor could glass-unseal PHI
|
|
700
|
+
// even when the operator explicitly declared `requireScope:
|
|
701
|
+
// "phi:admin"`.
|
|
702
|
+
if (policy.requireScope) {
|
|
703
|
+
var actorScopes = (opts.req && opts.req.user && Array.isArray(opts.req.user.scopes))
|
|
704
|
+
? opts.req.user.scopes
|
|
705
|
+
: ((opts.req && opts.req.apiKey && Array.isArray(opts.req.apiKey.scopes))
|
|
706
|
+
? opts.req.apiKey.scopes
|
|
707
|
+
: []);
|
|
708
|
+
var scopeOk = false;
|
|
709
|
+
for (var sci = 0; sci < actorScopes.length; sci += 1) {
|
|
710
|
+
if (actorScopes[sci] === policy.requireScope) { scopeOk = true; break; }
|
|
711
|
+
// Wildcard support: "phi:*" matches "phi:admin" and "phi:read".
|
|
712
|
+
if (typeof actorScopes[sci] === "string" &&
|
|
713
|
+
actorScopes[sci].length > 0 &&
|
|
714
|
+
actorScopes[sci].charAt(actorScopes[sci].length - 1) === "*") {
|
|
715
|
+
var prefix = actorScopes[sci].slice(0, -1);
|
|
716
|
+
if (typeof policy.requireScope === "string" &&
|
|
717
|
+
policy.requireScope.indexOf(prefix) === 0) {
|
|
718
|
+
scopeOk = true; break;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (!scopeOk) {
|
|
723
|
+
audit.safeEmit({
|
|
724
|
+
action: "breakglass.grant.requested",
|
|
725
|
+
outcome: "denied",
|
|
726
|
+
actor: actor,
|
|
727
|
+
reason: "missing-scope",
|
|
728
|
+
metadata: { table: table, requireScope: policy.requireScope },
|
|
729
|
+
});
|
|
730
|
+
throw new BreakGlassError("breakglass/missing-scope",
|
|
731
|
+
"grant: actor does not carry required scope '" + policy.requireScope + "'", true);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
696
735
|
// Factor verification + lockout
|
|
697
736
|
var factorType = opts.factor && opts.factor.type;
|
|
698
737
|
if (!factorType || policy.factors.indexOf(factorType) === -1) {
|
|
@@ -897,6 +936,20 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
897
936
|
grantRow.maxRowsPerGrant + " allowed rows", true);
|
|
898
937
|
}
|
|
899
938
|
|
|
939
|
+
// SELECT-before-increment — fetch the target row FIRST. If the row
|
|
940
|
+
// doesn't exist (operator typo, race with row-deletion, etc.), the
|
|
941
|
+
// grant should not be consumed. Without this ordering, a single
|
|
942
|
+
// typo against `maxRowsPerGrant: 1` (the default) exhausts the
|
|
943
|
+
// grant and forces the operator to re-do the step-up ceremony.
|
|
944
|
+
var rows = await clusterStorage.executeAll(
|
|
945
|
+
"SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
|
|
946
|
+
[String(rowId)]
|
|
947
|
+
);
|
|
948
|
+
if (!rows || rows.length === 0) {
|
|
949
|
+
throw new BreakGlassError("breakglass/row-not-found",
|
|
950
|
+
"unsealRow: " + table + "[" + rowId + "] not found", true);
|
|
951
|
+
}
|
|
952
|
+
|
|
900
953
|
// Increment rowsConsumed (atomic UPDATE with WHERE rowsConsumed < cap
|
|
901
954
|
// so concurrent unseals can't both pass the runtime check above).
|
|
902
955
|
var updateRes = await clusterStorage.execute(
|
|
@@ -925,20 +978,6 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
925
978
|
"unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
|
|
926
979
|
}
|
|
927
980
|
void updateRes;
|
|
928
|
-
|
|
929
|
-
// Fetch + unseal the target row. Model A goes straight through
|
|
930
|
-
// cryptoField; Model B reads the row, lets cryptoField unseal the
|
|
931
|
-
// non-glass-locked columns, and then decryptCell handles the
|
|
932
|
-
// glass-locked columns separately (their ciphertext was written
|
|
933
|
-
// by encryptCell at app-write time, not by cryptoField.sealRow).
|
|
934
|
-
var rows = await clusterStorage.executeAll(
|
|
935
|
-
"SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
|
|
936
|
-
[String(rowId)]
|
|
937
|
-
);
|
|
938
|
-
if (!rows || rows.length === 0) {
|
|
939
|
-
throw new BreakGlassError("breakglass/row-not-found",
|
|
940
|
-
"unsealRow: " + table + "[" + rowId + "] not found", true);
|
|
941
|
-
}
|
|
942
981
|
var policy = await policyGet(table);
|
|
943
982
|
var unsealedRow;
|
|
944
983
|
if (policy && policy.cryptographic) {
|
package/lib/cache-redis.js
CHANGED
|
@@ -96,7 +96,7 @@ function create(cfg) {
|
|
|
96
96
|
|
|
97
97
|
async function set(key, value, expiresAt, meta) {
|
|
98
98
|
await _ensureConnected();
|
|
99
|
-
var json =
|
|
99
|
+
var json = safeJson.stringify(value);
|
|
100
100
|
|
|
101
101
|
// Drop any prior tag membership for this key (tags may have changed
|
|
102
102
|
// across sets). The reverse-tag set tells us which tag SETs need
|
package/lib/cache.js
CHANGED
|
@@ -491,7 +491,12 @@ function _clusterBackend(cfg) {
|
|
|
491
491
|
}
|
|
492
492
|
|
|
493
493
|
async function set(key, value, expiresAt, meta) {
|
|
494
|
-
|
|
494
|
+
// safeJson.stringify refuses Buffer / circular / Date round-trip
|
|
495
|
+
// ambiguity that vanilla JSON.stringify silently flattens. The
|
|
496
|
+
// failure mode without this is "cache returns a structurally-
|
|
497
|
+
// changed value, app code treats it as the original" — a subtle
|
|
498
|
+
// freshness bug that's hard to debug.
|
|
499
|
+
var json = safeJson.stringify(value);
|
|
495
500
|
var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
|
|
496
501
|
var now = clock();
|
|
497
502
|
var ck = _composedKey(key);
|
package/lib/cli.js
CHANGED
|
@@ -1370,9 +1370,9 @@ async function _runMtls(args, ctx) {
|
|
|
1370
1370
|
if (vaultMode !== "wrapped" && vaultMode !== "plaintext") {
|
|
1371
1371
|
return report.error("--vault-mode must be 'wrapped' or 'plaintext'", 2);
|
|
1372
1372
|
}
|
|
1373
|
-
var sealedMode = args.flags["sealed-mode"] || "
|
|
1374
|
-
if (["
|
|
1375
|
-
return report.error("--sealed-mode must be '
|
|
1373
|
+
var sealedMode = args.flags["sealed-mode"] || "required";
|
|
1374
|
+
if (["required", "disabled"].indexOf(sealedMode) === -1) {
|
|
1375
|
+
return report.error("--sealed-mode must be 'required' or 'disabled'", 2);
|
|
1376
1376
|
}
|
|
1377
1377
|
|
|
1378
1378
|
var booted;
|
package/lib/cluster.js
CHANGED
|
@@ -69,7 +69,17 @@ var vault = lazyRequire(function () { return require("./vault"); });
|
|
|
69
69
|
|
|
70
70
|
var DEFAULT_LEASE_TTL = C.TIME.seconds(30);
|
|
71
71
|
var DEFAULT_HEARTBEAT = C.TIME.seconds(10);
|
|
72
|
-
|
|
72
|
+
// MIN_LEASE_TTL bumped from 5s → 10s. With 5s leases + 1s heartbeats,
|
|
73
|
+
// a network glitch + GC pause can leave the old leader believing it
|
|
74
|
+
// still holds the lease (4s remaining on its clock) while a new
|
|
75
|
+
// leader has already acquired. Old-leader writes during that window
|
|
76
|
+
// only land on framework state with a fencingToken WHERE clause
|
|
77
|
+
// (audit-tip CHECK catches it); operator-supplied writes through
|
|
78
|
+
// b.externalDb.transaction outside the audit chain DON'T carry the
|
|
79
|
+
// clause and can be accepted by both leaders. 10s leaves more room
|
|
80
|
+
// for the framework's audit-tip fencing to catch the split-brain
|
|
81
|
+
// before consequential writes reach durable state.
|
|
82
|
+
var MIN_LEASE_TTL = C.TIME.seconds(10);
|
|
73
83
|
var MIN_HEARTBEAT = C.TIME.seconds(1);
|
|
74
84
|
|
|
75
85
|
var initialized = false;
|
|
@@ -476,7 +486,20 @@ async function _tryAcquire() {
|
|
|
476
486
|
|
|
477
487
|
async function _heartbeat() {
|
|
478
488
|
if (!initialized) return;
|
|
489
|
+
// ±20% per-tick jitter on followers — without it, N followers
|
|
490
|
+
// polling on a deterministic cadence all fire _tryAcquire at the
|
|
491
|
+
// same wall-clock instant on lease expiry, producing thundering-
|
|
492
|
+
// herd INSERT/UPDATE pressure on the leader-election row at
|
|
493
|
+
// exactly the worst time. Leader-renewal path doesn't jitter
|
|
494
|
+
// (a missed renewal hands the lease to a follower; the timing
|
|
495
|
+
// budget is in `leaseTtl - heartbeatMs`, not in the jitter
|
|
496
|
+
// window).
|
|
479
497
|
if (!lease) {
|
|
498
|
+
var jitterMs = Math.floor(Math.random() * (heartbeatMs * 0.4)); // allow:math-random-noncrypto — heartbeat jitter, not security-bearing
|
|
499
|
+
if (jitterMs > 0) {
|
|
500
|
+
await safeAsync.sleep(jitterMs);
|
|
501
|
+
}
|
|
502
|
+
if (!initialized) return;
|
|
480
503
|
// Not currently leader — try to acquire (lease may have expired
|
|
481
504
|
// on the previous holder).
|
|
482
505
|
await _tryAcquire();
|