@blamejs/core 0.17.23 → 0.18.0
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 +11 -5
- package/NOTICE +12 -13
- package/README.md +3 -3
- package/lib/audit.js +69 -1
- package/lib/auth/oauth.js +57 -0
- package/lib/cli.js +15 -5
- package/lib/daemon.js +192 -2
- package/lib/guard-tenant-id.js +12 -1
- package/lib/mtls-ca.js +173 -18
- package/lib/mtls-engine-default.js +339 -314
- package/lib/outbox.js +43 -13
- package/lib/redact.js +39 -6
- package/lib/safe-json.js +9 -1
- package/lib/self-update.js +215 -24
- package/lib/vendor/MANIFEST.json +27 -30
- package/lib/vendor/blamejs-pki.cjs +28419 -0
- package/lib/webhook-dispatcher.js +55 -24
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/lib/vendor/pki.cjs +0 -39718
package/lib/auth/oauth.js
CHANGED
|
@@ -272,6 +272,27 @@ function _generatePkce() {
|
|
|
272
272
|
return { verifier: verifier, challenge: challenge };
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* @primitive b.auth.oauth.generatePkce
|
|
277
|
+
* @signature b.auth.oauth.generatePkce()
|
|
278
|
+
* @since 0.18.0
|
|
279
|
+
* @status stable
|
|
280
|
+
* @related b.auth.oauth.parseCallback
|
|
281
|
+
*
|
|
282
|
+
* Generate a PKCE (RFC 7636) verifier/challenge pair for a hand-rolled
|
|
283
|
+
* authorization-code flow that does not go through `create()`. The
|
|
284
|
+
* `code_challenge_method` is always `S256`: the challenge is the base64url
|
|
285
|
+
* of SHA-256 over the verifier, and the verifier is 43 base64url characters
|
|
286
|
+
* (32 CSPRNG bytes).
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* var pkce = b.auth.oauth.generatePkce();
|
|
290
|
+
* // → { verifier: "…43 chars…", challenge: "…base64url(SHA-256(verifier))…" }
|
|
291
|
+
*/
|
|
292
|
+
function generatePkce() {
|
|
293
|
+
return _generatePkce();
|
|
294
|
+
}
|
|
295
|
+
|
|
275
296
|
function _validateUrl(url, allowHttp, label) {
|
|
276
297
|
if (typeof url !== "string" || url.length === 0) {
|
|
277
298
|
throw new OAuthError("auth-oauth/bad-url", label + ": URL is required");
|
|
@@ -550,6 +571,7 @@ function _toAttestationPrivateKey(value, label) {
|
|
|
550
571
|
catch (e) {
|
|
551
572
|
var code = (e && e.code) === "auth-jwt-external/sign-no-key"
|
|
552
573
|
? "auth-oauth/attestation-no-key" : "auth-oauth/attestation-bad-key";
|
|
574
|
+
/* c8 ignore next -- String(e) fallback: the jwt-external error always carries a message */
|
|
553
575
|
throw new OAuthError(code, (e && e.message) || String(e));
|
|
554
576
|
}
|
|
555
577
|
}
|
|
@@ -567,8 +589,10 @@ function _resolveAttestationAlg(explicitAlg, privateKey, label) {
|
|
|
567
589
|
try {
|
|
568
590
|
return jwtExternal._resolveSignAlg(explicitAlg, privateKey, label);
|
|
569
591
|
} catch (e) {
|
|
592
|
+
/* c8 ignore next -- || "" is unreachable: _resolveSignAlg always throws a coded AuthError */
|
|
570
593
|
var ec = (e && e.code) || "";
|
|
571
594
|
if (ec === "auth-jwt-external/sign-alg-key-mismatch") {
|
|
595
|
+
/* c8 ignore next -- String(e) fallback: the mapped error always carries a message */
|
|
572
596
|
throw new OAuthError("auth-oauth/attestation-alg-key-mismatch", (e && e.message) || String(e));
|
|
573
597
|
}
|
|
574
598
|
if (ec === "auth-jwt-external/sign-alg-refused" || ec === "auth-jwt-external/sign-alg-unsupported") {
|
|
@@ -576,8 +600,10 @@ function _resolveAttestationAlg(explicitAlg, privateKey, label) {
|
|
|
576
600
|
label + ": alg '" + explicitAlg + "' is not an accepted attestation algorithm");
|
|
577
601
|
}
|
|
578
602
|
if (ec === "auth-jwt-external/sign-key-unsupported") {
|
|
603
|
+
/* c8 ignore next -- String(e) fallback: the mapped error always carries a message */
|
|
579
604
|
throw new OAuthError("auth-oauth/attestation-key-unsupported", (e && e.message) || String(e));
|
|
580
605
|
}
|
|
606
|
+
/* c8 ignore next -- unreachable: _resolveSignAlg only emits the four codes handled above */
|
|
581
607
|
throw new OAuthError("auth-oauth/attestation-bad-key", (e && e.message) || String(e));
|
|
582
608
|
}
|
|
583
609
|
}
|
|
@@ -613,6 +639,7 @@ function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
|
|
|
613
639
|
header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"), { maxBytes: MAX_ATTESTATION_JWT_BYTES });
|
|
614
640
|
payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8"), { maxBytes: MAX_ATTESTATION_JWT_BYTES });
|
|
615
641
|
} catch (e) {
|
|
642
|
+
/* c8 ignore next 2 -- String(e) fallback: the decode error always carries a message */
|
|
616
643
|
throw new OAuthError("auth-oauth/attestation-malformed",
|
|
617
644
|
label + ": header/payload decode failed: " + ((e && e.message) || String(e)));
|
|
618
645
|
}
|
|
@@ -653,6 +680,7 @@ function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
|
|
|
653
680
|
var ok;
|
|
654
681
|
try {
|
|
655
682
|
ok = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
|
|
683
|
+
/* c8 ignore next 4 -- verify cannot raise: the alg/kty/crv cross-check above guarantees a compatible key/params pairing, so a bad signature returns false rather than throwing */
|
|
656
684
|
} catch (verifyErr) {
|
|
657
685
|
throw new OAuthError("auth-oauth/attestation-bad-signature",
|
|
658
686
|
label + ": signature verification raised: " + ((verifyErr && verifyErr.message) || String(verifyErr)));
|
|
@@ -667,6 +695,7 @@ function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
|
|
|
667
695
|
// never reach the attestation's cnf claim. Mirrors the dpop.buildProof
|
|
668
696
|
// public-only embed.
|
|
669
697
|
function _publicCnfJwk(jwk, label) {
|
|
698
|
+
/* c8 ignore next 4 -- unreachable: the sole caller validates instanceKeyJwk as a required object before this call */
|
|
670
699
|
if (!jwk || typeof jwk !== "object") {
|
|
671
700
|
throw new OAuthError("auth-oauth/attestation-bad-cnf",
|
|
672
701
|
label + ": instanceKeyJwk (public JWK for the cnf claim) is required");
|
|
@@ -1006,6 +1035,7 @@ async function verifyClientAttestation(attestationJwt, popJwt, vopts) {
|
|
|
1006
1035
|
// strings, returns false (never throws) on length mismatch, and refuses
|
|
1007
1036
|
// non-string/Buffer input — so it carries the timing + type discipline.
|
|
1008
1037
|
function _constantTimeStrEq(a, b) {
|
|
1038
|
+
/* c8 ignore next -- every caller passes a String()-wrapped first argument, so the typeof-a arm never short-circuits */
|
|
1009
1039
|
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
1010
1040
|
return cryptoTimingSafeEqual(a, b);
|
|
1011
1041
|
}
|
|
@@ -1040,6 +1070,7 @@ var RESERVED_AUTHZ_PARAMS = {
|
|
|
1040
1070
|
// closed; shared by authorizationUrl, pushAuthorizationRequest, and
|
|
1041
1071
|
// endSessionUrl so every URL/PAR builder guards the same way.
|
|
1042
1072
|
function _assertNoReservedExtraParams(extraParams, reserved, errCode, ctx) {
|
|
1073
|
+
/* c8 ignore next -- every caller guards `extraParams && typeof === "object"` first, so the !extraParams arm never short-circuits */
|
|
1043
1074
|
if (!extraParams || typeof extraParams !== "object") return;
|
|
1044
1075
|
var ek = Object.keys(extraParams);
|
|
1045
1076
|
for (var i = 0; i < ek.length; i++) {
|
|
@@ -1187,13 +1218,16 @@ function create(opts) {
|
|
|
1187
1218
|
Object.assign(req, httpClientOpts);
|
|
1188
1219
|
var res = await hc.request(req);
|
|
1189
1220
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1221
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
1190
1222
|
var bodyText = res.body ? res.body.toString("utf8") : "";
|
|
1191
1223
|
throw new OAuthError("auth-oauth/http-" + res.statusCode,
|
|
1192
1224
|
url + " returned " + res.statusCode + ": " + bodyText.slice(0, 500));
|
|
1193
1225
|
}
|
|
1226
|
+
/* c8 ignore next -- httpClient always yields a Buffer body (empty Buffer for no content), so this never returns null */
|
|
1194
1227
|
if (!res.body) return null;
|
|
1195
1228
|
try { return safeJson.parse(res.body.toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
1196
1229
|
catch (e) {
|
|
1230
|
+
/* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
|
|
1197
1231
|
throw new OAuthError("auth-oauth/bad-json",
|
|
1198
1232
|
url + " response not JSON: " + ((e && e.message) || String(e)));
|
|
1199
1233
|
}
|
|
@@ -1262,6 +1296,7 @@ function create(opts) {
|
|
|
1262
1296
|
async function _peekDiscovery() {
|
|
1263
1297
|
if (!isOidc || !issuer) return null;
|
|
1264
1298
|
try { return (await _discoveryCache.get("config")) || null; }
|
|
1299
|
+
/* c8 ignore next -- defensive: the in-memory discovery cache get() does not throw */
|
|
1265
1300
|
catch (_e) { return null; }
|
|
1266
1301
|
}
|
|
1267
1302
|
|
|
@@ -1312,6 +1347,7 @@ function create(opts) {
|
|
|
1312
1347
|
// base64url(SHA-256(verifier)) per RFC 7636.
|
|
1313
1348
|
var state = uopts.state || _generateRandomToken(STATE_NONCE_BYTES);
|
|
1314
1349
|
var nonce = uopts.nonce || (isOidc ? _generateRandomToken(STATE_NONCE_BYTES) : null);
|
|
1350
|
+
/* c8 ignore next -- pkce is always true (create() refuses pkce:false), so the : null alternate is dead */
|
|
1315
1351
|
var pkceVals = pkce ? _generatePkce() : null;
|
|
1316
1352
|
var params = new URLSearchParams();
|
|
1317
1353
|
params.set("response_type", "code");
|
|
@@ -1356,6 +1392,7 @@ function create(opts) {
|
|
|
1356
1392
|
url: endpoint + sep + params.toString(),
|
|
1357
1393
|
state: state,
|
|
1358
1394
|
nonce: nonce,
|
|
1395
|
+
/* c8 ignore next 2 -- pkceVals is always truthy (pkce is always on), so the : null alternates are dead */
|
|
1359
1396
|
verifier: pkceVals ? pkceVals.verifier : null,
|
|
1360
1397
|
challenge: pkceVals ? pkceVals.challenge : null,
|
|
1361
1398
|
authorizationDetails: requestedAuthzDetails,
|
|
@@ -1764,6 +1801,7 @@ function create(opts) {
|
|
|
1764
1801
|
if (allowInternal !== null) req.allowInternal = allowInternal;
|
|
1765
1802
|
Object.assign(req, httpClientOpts);
|
|
1766
1803
|
var res = await hc.request(req);
|
|
1804
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
1767
1805
|
var text = res.body ? res.body.toString("utf8") : "";
|
|
1768
1806
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1769
1807
|
throw new OAuthError("auth-oauth/token-error-" + res.statusCode,
|
|
@@ -1772,6 +1810,7 @@ function create(opts) {
|
|
|
1772
1810
|
var parsed;
|
|
1773
1811
|
try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
1774
1812
|
catch (e) {
|
|
1813
|
+
/* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
|
|
1775
1814
|
throw new OAuthError("auth-oauth/bad-token-json",
|
|
1776
1815
|
"token endpoint response not JSON: " + ((e && e.message) || String(e)));
|
|
1777
1816
|
}
|
|
@@ -1779,6 +1818,7 @@ function create(opts) {
|
|
|
1779
1818
|
}
|
|
1780
1819
|
|
|
1781
1820
|
async function _normalizeTokens(raw, vopts) {
|
|
1821
|
+
/* c8 ignore next -- every caller passes a vopts object, so the || {} default is unreachable */
|
|
1782
1822
|
vopts = vopts || {};
|
|
1783
1823
|
// RFC 6749 §3.3 — scope is space-separated, ONLY U+0020. `\s+` previously
|
|
1784
1824
|
// matched U+0085 NEL, U+00A0 NBSP, etc., so a hostile AS returning
|
|
@@ -1879,6 +1919,7 @@ function create(opts) {
|
|
|
1879
1919
|
action: "jwt.jwe.refused",
|
|
1880
1920
|
outcome: "denied",
|
|
1881
1921
|
metadata: { reason: "jwe-on-jws-verifier", primitive: "oauth.verifyIdToken" },
|
|
1922
|
+
/* c8 ignore next -- drop-silent observability sink: safeEmit does not throw */
|
|
1882
1923
|
}); } catch (_e) { /* drop-silent — observability sink */ }
|
|
1883
1924
|
throw new OAuthError("auth-oauth/jwe-refused",
|
|
1884
1925
|
"5-segment JWE id_token refused — verifyIdToken only handles JWS " +
|
|
@@ -1892,6 +1933,7 @@ function create(opts) {
|
|
|
1892
1933
|
header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES });
|
|
1893
1934
|
payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES });
|
|
1894
1935
|
} catch (e) {
|
|
1936
|
+
/* c8 ignore next 2 -- String(e) fallback: the decode error always carries a message */
|
|
1895
1937
|
throw new OAuthError("auth-oauth/malformed-jwt",
|
|
1896
1938
|
"ID token header/payload base64 decode failed: " + ((e && e.message) || String(e)));
|
|
1897
1939
|
}
|
|
@@ -1980,6 +2022,7 @@ function create(opts) {
|
|
|
1980
2022
|
var verified;
|
|
1981
2023
|
try {
|
|
1982
2024
|
verified = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
|
|
2025
|
+
/* c8 ignore next 5 -- verify cannot raise: the alg/kty/crv cross-check above guarantees a compatible key/params pairing, so a bad signature returns false rather than throwing */
|
|
1983
2026
|
} catch (verifyErr) {
|
|
1984
2027
|
throw new OAuthError("auth-oauth/bad-signature",
|
|
1985
2028
|
"ID token signature verification raised: " +
|
|
@@ -2055,6 +2098,7 @@ function create(opts) {
|
|
|
2055
2098
|
reason: "cross-realm-jwt-refused",
|
|
2056
2099
|
primitive: "oauth.verifyIdToken",
|
|
2057
2100
|
},
|
|
2101
|
+
/* c8 ignore next -- drop-silent observability sink: safeEmit does not throw */
|
|
2058
2102
|
}); } catch (_e) { /* drop-silent — observability sink */ }
|
|
2059
2103
|
throw new OAuthError("auth-oauth/iss-mismatch",
|
|
2060
2104
|
"ID token iss '" + payload.iss + "' does not match expected '" + issuer +
|
|
@@ -2335,6 +2379,7 @@ function create(opts) {
|
|
|
2335
2379
|
reason: "frontchannel-logout-cross-realm",
|
|
2336
2380
|
primitive: "oauth.parseFrontchannelLogoutRequest",
|
|
2337
2381
|
},
|
|
2382
|
+
/* c8 ignore next -- drop-silent observability sink: safeEmit does not throw */
|
|
2338
2383
|
}); } catch (_e) { /* drop-silent — observability sink */ }
|
|
2339
2384
|
throw new OAuthError("auth-oauth/frontchannel-logout-iss-mismatch",
|
|
2340
2385
|
"parseFrontchannelLogoutRequest: iss \"" + iss +
|
|
@@ -2423,6 +2468,7 @@ function create(opts) {
|
|
|
2423
2468
|
var claims = verified.claims;
|
|
2424
2469
|
|
|
2425
2470
|
// §2.6 — events claim presence + correct shape
|
|
2471
|
+
/* c8 ignore next 5 -- defense-in-depth: verifyIdToken's skipExpCheck self-guard already enforced the backchannel-logout event before returning, so this re-check never fires */
|
|
2426
2472
|
if (!claims.events || typeof claims.events !== "object" ||
|
|
2427
2473
|
!claims.events["http://schemas.openid.net/event/backchannel-logout"]) {
|
|
2428
2474
|
throw new OAuthError("auth-oauth/missing-logout-event",
|
|
@@ -2449,6 +2495,7 @@ function create(opts) {
|
|
|
2449
2495
|
? vopts.maxAgeSec
|
|
2450
2496
|
: DEFAULT_LOGOUT_TOKEN_MAX_AGE_SEC;
|
|
2451
2497
|
var nowSecLogout = Math.floor(Date.now() / C.TIME.seconds(1));
|
|
2498
|
+
/* c8 ignore next 4 -- defense-in-depth: verifyIdToken's skipExpCheck freshness gate already required a numeric iat before returning, so this re-check never fires */
|
|
2452
2499
|
if (typeof claims.iat !== "number") {
|
|
2453
2500
|
throw new OAuthError("auth-oauth/logout-token-no-iat",
|
|
2454
2501
|
"verifyBackchannelLogoutToken: payload.iat required (OIDC BCL §2.4)");
|
|
@@ -2513,6 +2560,7 @@ function create(opts) {
|
|
|
2513
2560
|
sub: claims.sub || null,
|
|
2514
2561
|
sid: claims.sid || null,
|
|
2515
2562
|
jti: claims.jti || null,
|
|
2563
|
+
/* c8 ignore next -- iat is always a positive number here (enforced above), so the || null arm is unreachable */
|
|
2516
2564
|
iat: claims.iat || null,
|
|
2517
2565
|
events: claims.events,
|
|
2518
2566
|
claims: claims,
|
|
@@ -2672,6 +2720,7 @@ function create(opts) {
|
|
|
2672
2720
|
if (allowInternal !== null) req.allowInternal = allowInternal;
|
|
2673
2721
|
Object.assign(req, httpClientOpts);
|
|
2674
2722
|
var res = await hc.request(req);
|
|
2723
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
2675
2724
|
var text = res.body ? res.body.toString("utf8") : "";
|
|
2676
2725
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
2677
2726
|
throw new OAuthError("auth-oauth/register-failed-" + res.statusCode,
|
|
@@ -2680,6 +2729,7 @@ function create(opts) {
|
|
|
2680
2729
|
var parsed;
|
|
2681
2730
|
try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
2682
2731
|
catch (e) {
|
|
2732
|
+
/* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
|
|
2683
2733
|
throw new OAuthError("auth-oauth/bad-register-response",
|
|
2684
2734
|
"registerClient: response not JSON: " + ((e && e.message) || String(e)));
|
|
2685
2735
|
}
|
|
@@ -2803,13 +2853,16 @@ function create(opts) {
|
|
|
2803
2853
|
"deleteClient: " + res.statusCode);
|
|
2804
2854
|
}
|
|
2805
2855
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
2856
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
2806
2857
|
var errText = res.body ? res.body.toString("utf8").slice(0, 500) : "";
|
|
2807
2858
|
throw new OAuthError("auth-oauth/dcr-" + method.toLowerCase() + "-failed-" + res.statusCode,
|
|
2808
2859
|
method.toLowerCase() + "Client: " + res.statusCode + ": " + errText);
|
|
2809
2860
|
}
|
|
2861
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
2810
2862
|
var text = res.body ? res.body.toString("utf8") : "";
|
|
2811
2863
|
try { return safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
2812
2864
|
catch (e) {
|
|
2865
|
+
/* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
|
|
2813
2866
|
throw new OAuthError("auth-oauth/dcr-bad-response",
|
|
2814
2867
|
method.toLowerCase() + "Client: response not JSON: " + ((e && e.message) || String(e)));
|
|
2815
2868
|
}
|
|
@@ -2937,6 +2990,7 @@ function create(opts) {
|
|
|
2937
2990
|
// reinstate buffer mode.
|
|
2938
2991
|
req.responseMode = "always-resolve";
|
|
2939
2992
|
var res = await hc.request(req);
|
|
2993
|
+
/* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
|
|
2940
2994
|
var text = res.body ? res.body.toString("utf8") : "";
|
|
2941
2995
|
var parsed;
|
|
2942
2996
|
try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
|
|
@@ -2959,6 +3013,7 @@ function create(opts) {
|
|
|
2959
3013
|
throw new OAuthError("auth-oauth/device-" + (err || "unknown"),
|
|
2960
3014
|
"pollDeviceCode: " + (parsed && parsed.error_description ? parsed.error_description : text.slice(0, 200))); // 200-char error-snippet cap, not bytes
|
|
2961
3015
|
}
|
|
3016
|
+
/* c8 ignore next 2 -- the || fallback is unreachable: reaching this timeout requires a truthy (small) maxWaitMs; a falsy one yields the 10-minute deadline that never expires within a test window */
|
|
2962
3017
|
throw new OAuthError("auth-oauth/device-poll-timeout",
|
|
2963
3018
|
"pollDeviceCode: exceeded maxWaitMs " + (popts.maxWaitMs || C.TIME.minutes(10)));
|
|
2964
3019
|
}
|
|
@@ -3180,6 +3235,8 @@ module.exports = {
|
|
|
3180
3235
|
buildClientAttestation: buildClientAttestation,
|
|
3181
3236
|
buildClientAttestationPop: buildClientAttestationPop,
|
|
3182
3237
|
verifyClientAttestation: verifyClientAttestation,
|
|
3238
|
+
// PKCE (RFC 7636) generator for hand-rolled authorization-code flows.
|
|
3239
|
+
generatePkce: generatePkce,
|
|
3183
3240
|
// Internal helpers exposed for tests
|
|
3184
3241
|
_generatePkce: _generatePkce,
|
|
3185
3242
|
_generateRandomToken: _generateRandomToken,
|
package/lib/cli.js
CHANGED
|
@@ -1425,15 +1425,21 @@ var MTLS_USAGE = [
|
|
|
1425
1425
|
" 'auto' loads whichever form exists.",
|
|
1426
1426
|
"",
|
|
1427
1427
|
"Subcommand flags:",
|
|
1428
|
+
" init: [--algorithm ECDSA-P384-SHA384]",
|
|
1428
1429
|
" issue: --subject <CN> [--days <N>]",
|
|
1429
1430
|
" issue-p12: --subject <CN> --password <pkcs12-passphrase> [--days <N>] [--out <path>]",
|
|
1430
1431
|
"",
|
|
1431
1432
|
"Cert issuance ('init', 'issue', 'issue-p12') uses the framework's",
|
|
1432
|
-
"bundled pure-JS engine (lib/mtls-engine-default.js
|
|
1433
|
-
"
|
|
1434
|
-
"
|
|
1435
|
-
"
|
|
1436
|
-
"
|
|
1433
|
+
"bundled pure-JS engine (lib/mtls-engine-default.js) on the vendored",
|
|
1434
|
+
"@blamejs/pki toolkit: post-quantum ML-DSA-87 (FIPS 204) certificate",
|
|
1435
|
+
"signatures by default, which node:tls verifies in a real mutual-auth",
|
|
1436
|
+
"handshake on OpenSSL 3.5. Pass 'init --algorithm ECDSA-P384-SHA384'",
|
|
1437
|
+
"for a classical CA a peer predating OpenSSL 3.5 can verify (the pin",
|
|
1438
|
+
"covers the CA and its leaves). PKCS#12 export keeps AES-256-CBC +",
|
|
1439
|
+
"PBKDF2-HMAC-SHA-512 @ 2,000,000-iter bag protection with a",
|
|
1440
|
+
"tier-dependent integrity MAC (PBMAC1/RFC 9579 for the PQC default,",
|
|
1441
|
+
"RFC 7292 HMAC MacData for the classical bridge). Operators with custom",
|
|
1442
|
+
"requirements pass a different engine via b.mtlsCa.create({ engine: ... }).",
|
|
1437
1443
|
].join("\n");
|
|
1438
1444
|
|
|
1439
1445
|
async function _runMtls(args, ctx) {
|
|
@@ -1481,6 +1487,10 @@ async function _runMtls(args, ctx) {
|
|
|
1481
1487
|
dataDir: dataDir,
|
|
1482
1488
|
vault: booted.b.vault,
|
|
1483
1489
|
caKeySealedMode: sealedMode,
|
|
1490
|
+
// --algorithm pins the CA + its leaves; omitted -> the ML-DSA-87 default.
|
|
1491
|
+
// Pass "ECDSA-P384-SHA384" for the classical bridge (a peer predating
|
|
1492
|
+
// OpenSSL 3.5). undefined when the flag is absent.
|
|
1493
|
+
algorithm: args.flags.algorithm,
|
|
1484
1494
|
// No engine passed — b.mtlsCa falls back to the bundled default
|
|
1485
1495
|
// (lib/mtls-engine-default.js).
|
|
1486
1496
|
});
|
package/lib/daemon.js
CHANGED
|
@@ -72,6 +72,13 @@ var DEFAULT_STOP_TIMEOUT_MS = C.TIME.seconds(30);
|
|
|
72
72
|
var DEFAULT_STOP_SIGNAL = "SIGTERM";
|
|
73
73
|
var DEFAULT_POLL_MS = 100;
|
|
74
74
|
var DEFAULT_LOG_FILE_MODE = 0o600;
|
|
75
|
+
// A detached child that exits within this window of spawn is treated as a boot
|
|
76
|
+
// death (spawn_failed audit); an abnormal exit after it is a normal run/crash,
|
|
77
|
+
// and a clean exit or an operator stop() is never a spawn failure.
|
|
78
|
+
var BOOT_DEATH_WINDOW_MS = C.TIME.seconds(5);
|
|
79
|
+
// setTimeout clamps a delay above this 32-bit ceiling to ~1ms, which would
|
|
80
|
+
// silently defeat the boot-window loop-hold — so the opt is refused above it.
|
|
81
|
+
var MAX_BOOT_DEATH_WINDOW_MS = 0x7FFFFFFF; // 2,147,483,647
|
|
75
82
|
// Poll cadence for the Windows cooperative-stop sentinel (a synchronous
|
|
76
83
|
// existsSync on this interval; see _installStopSentinelWatcher for why it is a
|
|
77
84
|
// poll and not a filesystem watch). Runs for a foreground daemon's whole
|
|
@@ -90,6 +97,71 @@ function _safeAuditEmit(action, outcome, metadata) {
|
|
|
90
97
|
var _isLivePid = pidProbe.isLivePid;
|
|
91
98
|
var _readPidFile = pidProbe.readPidFile;
|
|
92
99
|
|
|
100
|
+
// An in-flight stop() writes a `<pidFile>.stopping` marker holding the pid it is
|
|
101
|
+
// stopping. The detached boot-death exit handler consults it so a stop()-induced
|
|
102
|
+
// exit within the boot window is not misread as a spawn failure — a FILESYSTEM
|
|
103
|
+
// marker (not an in-process flag) so it works whether stop() runs in the same
|
|
104
|
+
// process that called start() OR a different one (e.g. a `daemon stop` CLI). The
|
|
105
|
+
// marker carries the target pid so a stale marker from a crashed stopper can
|
|
106
|
+
// only ever suppress that same pid, never a later daemon reusing the pidfile.
|
|
107
|
+
function _stoppingMarkerPath(pidFile) { return pidFile + ".stopping"; }
|
|
108
|
+
|
|
109
|
+
// Reap the boot-dead child's OWN stale pidfile without a check-then-unlink race.
|
|
110
|
+
// A naive `if (read(pidFile) === childPid) unlink(pidFile)` can delete the WRONG
|
|
111
|
+
// file: a fast operator restart that rewrites pidFile with a NEW child's pid
|
|
112
|
+
// between the read and the unlink leaves the new daemon unmanageable via stop().
|
|
113
|
+
//
|
|
114
|
+
// A NON-DESTRUCTIVE ownership pre-check runs first: only when the sidecar still
|
|
115
|
+
// records THIS child's pid do we ATOMICALLY claim it (rename it to a per-pid path)
|
|
116
|
+
// so the confirming check and the removal act on the same exclusive file. A fast
|
|
117
|
+
// restart's replacement pidfile (a different pid) is thus never even temporarily
|
|
118
|
+
// hidden — we leave it untouched. Should a restart land in the tiny window between
|
|
119
|
+
// the pre-check and the claim, the post-claim re-check catches it (the claimed
|
|
120
|
+
// file's pid won't match) and restores the file. `readPid` defaults to the
|
|
121
|
+
// hardened reader and is injectable for tests. Returns whether the reaped sidecar
|
|
122
|
+
// was this child's.
|
|
123
|
+
function _reapOwnStalePidfile(pidFile, childPid, readPid) {
|
|
124
|
+
readPid = readPid || _readPidFile;
|
|
125
|
+
// Pre-check: don't claim (and momentarily hide) a pidfile that isn't ours.
|
|
126
|
+
var preOwned = false;
|
|
127
|
+
try { preOwned = String(readPid(pidFile)) === String(childPid); } catch (_pe) { preOwned = false; }
|
|
128
|
+
if (!preOwned) return false;
|
|
129
|
+
var claim = pidFile + ".reap-" + childPid;
|
|
130
|
+
try {
|
|
131
|
+
atomicFile.renameWithRetry(pidFile, claim); // retries a transient Windows AV/indexer lock
|
|
132
|
+
} catch (_e) {
|
|
133
|
+
return false; // pidFile vanished between the pre-check and the claim (stop() / a restart) — nothing to reap
|
|
134
|
+
}
|
|
135
|
+
// We now exclusively hold `claim`. Verify ownership against it (not pidFile) so
|
|
136
|
+
// the check and the removal act on the same file; a restart that rewrites
|
|
137
|
+
// pidFile after our rename creates a fresh, separate pidfile we never touch.
|
|
138
|
+
var mine = false;
|
|
139
|
+
try { mine = String(readPid(claim)) === String(childPid); } catch (_e2) { mine = false; }
|
|
140
|
+
if (mine) {
|
|
141
|
+
try { nodeFs.unlinkSync(claim); } catch (_e3) { /* best-effort reap — the sidecar is already off pidFile */ }
|
|
142
|
+
} else {
|
|
143
|
+
// Not ours (a fast restart's newer pidfile, or an unreadable one) — put it
|
|
144
|
+
// back so stop() still finds the running daemon's sidecar, but ONLY if nothing
|
|
145
|
+
// newer has since taken pidFile's place. linkSync is atomic and fails with
|
|
146
|
+
// EEXIST when a still-newer daemon already wrote pidFile during our inspection,
|
|
147
|
+
// so the restore never clobbers the newest pidfile with our older claimed one
|
|
148
|
+
// (a plain rename would). A NON-EEXIST failure means hard links aren't
|
|
149
|
+
// available on this filesystem (ENOTSUP on FAT / some network mounts), where
|
|
150
|
+
// nothing newer is present — fall back to a plain rename so the pidfile isn't
|
|
151
|
+
// lost entirely (it may clobber, but losing the running daemon's pidfile is
|
|
152
|
+
// worse). Either way, drop our claim afterward.
|
|
153
|
+
try {
|
|
154
|
+
nodeFs.linkSync(claim, pidFile);
|
|
155
|
+
} catch (linkErr) {
|
|
156
|
+
if (linkErr.code !== "EEXIST") {
|
|
157
|
+
try { atomicFile.renameWithRetry(claim, pidFile); } catch (_re) { /* best-effort restore */ }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try { nodeFs.unlinkSync(claim); } catch (_e5) { /* consumed by the rename fallback, or already gone */ }
|
|
161
|
+
}
|
|
162
|
+
return mine;
|
|
163
|
+
}
|
|
164
|
+
|
|
93
165
|
function _validateStartOpts(opts) {
|
|
94
166
|
validateOpts.shape(opts, {
|
|
95
167
|
pidFile: { rule: "required-string", code: "daemon/bad-pid-file",
|
|
@@ -118,6 +190,15 @@ function _validateStartOpts(opts) {
|
|
|
118
190
|
"daemon.start: opts.args requires opts.command");
|
|
119
191
|
}
|
|
120
192
|
},
|
|
193
|
+
bootDeathWindowMs: function (value) {
|
|
194
|
+
if (value !== undefined && (typeof value !== "number" || !isFinite(value) ||
|
|
195
|
+
value < 0 || value > MAX_BOOT_DEATH_WINDOW_MS)) {
|
|
196
|
+
throw new DaemonError("daemon/bad-boot-window",
|
|
197
|
+
"daemon.start: opts.bootDeathWindowMs must be a finite number of " +
|
|
198
|
+
"milliseconds in [0, " + MAX_BOOT_DEATH_WINDOW_MS + "] when present " +
|
|
199
|
+
"(a larger delay clamps setTimeout to ~1ms and defeats the boot window)");
|
|
200
|
+
}
|
|
201
|
+
},
|
|
121
202
|
}, "daemon.start", DaemonError, "daemon/bad-opts");
|
|
122
203
|
}
|
|
123
204
|
|
|
@@ -166,6 +247,7 @@ function _maybeReapStale(pidFile) {
|
|
|
166
247
|
// Used both by detached-spawn (passed via stdio) and by foreground
|
|
167
248
|
// redirect of the current process' stdout/stderr.
|
|
168
249
|
function _openLogFd(logFile) {
|
|
250
|
+
/* c8 ignore next -- every caller gates on a truthy logFile string, so this guard never returns null */
|
|
169
251
|
if (typeof logFile !== "string" || logFile.length === 0) return null;
|
|
170
252
|
atomicFile.ensureDir(nodePath.dirname(logFile));
|
|
171
253
|
// O_NOFOLLOW append: refuse (ELOOP) a symlink planted at the daemon log
|
|
@@ -182,6 +264,7 @@ function _openLogFd(logFile) {
|
|
|
182
264
|
// pattern for foreground daemons that don't want to lose output when
|
|
183
265
|
// detached from a terminal.
|
|
184
266
|
function _redirectStdio(fd) {
|
|
267
|
+
/* c8 ignore next -- only ever called with the numeric fd from _openLogFd; the non-number guard is unreachable */
|
|
185
268
|
if (typeof fd !== "number") return;
|
|
186
269
|
function _writer(chunk, encOrCb, maybeCb) {
|
|
187
270
|
var enc = typeof encOrCb === "string" ? encOrCb : "utf8";
|
|
@@ -247,6 +330,7 @@ function _installStopSentinelWatcher(pidFile, orchestrator) {
|
|
|
247
330
|
timer = null;
|
|
248
331
|
}
|
|
249
332
|
function _maybeFire() {
|
|
333
|
+
/* c8 ignore next -- _stopPolling clears the interval on the first fire, so _maybeFire can't re-enter with fired=true */
|
|
250
334
|
if (fired) return;
|
|
251
335
|
// Synchronous existsSync on the main thread — deliberately not an async
|
|
252
336
|
// stat, so detection never queues behind a saturated libuv threadpool.
|
|
@@ -310,6 +394,7 @@ function _installStopSentinelWatcher(pidFile, orchestrator) {
|
|
|
310
394
|
* command: string, // executable for detached-fork mode
|
|
311
395
|
* args: string[], // argv for the detached child
|
|
312
396
|
* cwd: string, // cwd for the detached child
|
|
397
|
+
* bootDeathWindowMs: number, // detached: keep the parent loop alive this long after spawn to observe a boot death (an abnormal exit in the window is audited as a spawn failure + reaps the pidfile); default 5000, 0 opts out (fire-and-forget)
|
|
313
398
|
*
|
|
314
399
|
* @example
|
|
315
400
|
* var handle = b.daemon.start({
|
|
@@ -378,6 +463,12 @@ function start(opts) {
|
|
|
378
463
|
throw new DaemonError("daemon/spawn-failed",
|
|
379
464
|
"daemon.start: spawn failed: " + ((e && e.message) || String(e)));
|
|
380
465
|
}
|
|
466
|
+
// Boot-death window is measured from the moment the child was spawned. An
|
|
467
|
+
// abnormal exit within it is a boot failure; a later one is a normal
|
|
468
|
+
// run/crash. Operators tune it for a slow-booting child (default 5s).
|
|
469
|
+
var spawnedAt = Date.now();
|
|
470
|
+
var bootWindowMs = (typeof opts.bootDeathWindowMs === "number")
|
|
471
|
+
? opts.bootDeathWindowMs : BOOT_DEATH_WINDOW_MS;
|
|
381
472
|
// A bad command does NOT throw synchronously from spawn — child_process
|
|
382
473
|
// reports it ASYNC via a 'error' event, with child.pid left undefined. The
|
|
383
474
|
// sync try/catch above only covers spawn() itself, so without this the old
|
|
@@ -385,7 +476,18 @@ function start(opts) {
|
|
|
385
476
|
// one-shot 'error' handler that reaps the sidecar + audits the failure, then
|
|
386
477
|
// refuse to proceed for a child that never got a pid.
|
|
387
478
|
child.on("error", function (err) {
|
|
388
|
-
|
|
479
|
+
// Reap ONLY a pidfile this child actually wrote. A numeric pid means the
|
|
480
|
+
// sync path below wrote one, and the claim-then-verify reap removes only its
|
|
481
|
+
// own sidecar (never a fast restart's). A no-pid spawn failure (child.pid
|
|
482
|
+
// undefined) throws below BEFORE any pidfile write, so this late-firing
|
|
483
|
+
// callback must not touch pidFile at all — else a caller that catches that
|
|
484
|
+
// sync throw and retries with the same pidFile would have its replacement
|
|
485
|
+
// daemon's pidfile deleted. (A numeric-but-invalid pid never reaches here:
|
|
486
|
+
// child_process yields a positive int or undefined, and either way the
|
|
487
|
+
// claim-then-verify reap only removes a sidecar recording that exact pid.)
|
|
488
|
+
if (typeof child.pid === "number") {
|
|
489
|
+
_reapOwnStalePidfile(pidFile, child.pid);
|
|
490
|
+
}
|
|
389
491
|
_safeAuditEmit("daemon.spawn_failed", "failure", {
|
|
390
492
|
pidFile: pidFile,
|
|
391
493
|
command: opts.command,
|
|
@@ -408,9 +510,76 @@ function start(opts) {
|
|
|
408
510
|
// Write the child's PID via atomic temp+rename so a concurrent
|
|
409
511
|
// observer never sees a half-written pidFile.
|
|
410
512
|
atomicFile.ensureDir(nodePath.dirname(pidFile));
|
|
513
|
+
// Clear any STALE stop marker before claiming this pidfile: a stopper that was
|
|
514
|
+
// SIGKILLed mid-stop leaves `<pidFile>.stopping` behind, and if the OS later
|
|
515
|
+
// reuses that stopped pid for THIS fresh child, the stale marker would
|
|
516
|
+
// wrongly suppress a genuine boot-death audit. A fresh start means no stop is
|
|
517
|
+
// in flight, so the marker can only be stale.
|
|
518
|
+
try { nodeFs.unlinkSync(_stoppingMarkerPath(pidFile)); } catch (_sm) { /* best-effort — usually absent */ }
|
|
411
519
|
var pidStr = String(child.pid) + "\n";
|
|
412
520
|
atomicFile.writeSync(pidFile, pidStr, { fileMode: 0o600 });
|
|
413
|
-
//
|
|
521
|
+
// A detached child can spawn cleanly (valid pid above) yet DIE AT BOOT —
|
|
522
|
+
// exit before it ever serves. The synchronous success handle is already
|
|
523
|
+
// committed (detached mode returns immediately, so the sync return contract
|
|
524
|
+
// stands), so recover asynchronously: a one-shot 'exit' handler reaps the
|
|
525
|
+
// sidecar we just wrote — but ONLY when the pidFile still records THIS
|
|
526
|
+
// child's pid, so a fast operator restart that rewrote it is never
|
|
527
|
+
// clobbered — and audits the boot death so it leaves a trail instead of a
|
|
528
|
+
// silently-stranded pidfile that daemon.stop would misread as running.
|
|
529
|
+
// bootWatch (installed after this handler) holds the parent loop open through
|
|
530
|
+
// the boot window so a short-lived launcher can't exit before observing the
|
|
531
|
+
// death; the handler clears it the instant the child exits.
|
|
532
|
+
var bootWatch = null;
|
|
533
|
+
child.on("exit", function (code, signal) {
|
|
534
|
+
if (bootWatch) { clearTimeout(bootWatch); bootWatch = null; } // death observed — release the loop
|
|
535
|
+
// Was the sidecar still OURS at exit? Atomically claim-then-verify (see
|
|
536
|
+
// _reapOwnStalePidfile) so a fast operator restart that rewrote the pidfile
|
|
537
|
+
// can't make us delete the NEW daemon's sidecar, and so the boot-death
|
|
538
|
+
// signal survives a swallowed unlink failure. A stop() or a restart that
|
|
539
|
+
// rewrote/cleared it means someone else owns it now — not a boot death.
|
|
540
|
+
var wasOurs = _reapOwnStalePidfile(pidFile, child.pid);
|
|
541
|
+
// Only a BOOT DEATH is a spawn failure: an ABNORMAL exit (non-zero code or
|
|
542
|
+
// a terminating signal) SHORTLY after spawn, while the sidecar was still
|
|
543
|
+
// ours (nobody stop()'d it). A clean exit (code 0), a later run/crash, or
|
|
544
|
+
// an operator stop() is NOT a spawn failure — auditing those as one emits a
|
|
545
|
+
// contradictory failure alongside the daemon.stopped record.
|
|
546
|
+
var abnormal = (typeof code === "number" && code !== 0) || signal != null;
|
|
547
|
+
var withinBoot = (Date.now() - spawnedAt) <= bootWindowMs;
|
|
548
|
+
// stop() (this process OR another) sends SIGTERM but unlinks the pidfile
|
|
549
|
+
// only after observing the exit, so wasOurs is still true here. A
|
|
550
|
+
// `<pidFile>.stopping` marker holding THIS child's pid means an operator
|
|
551
|
+
// stop is in flight, so the exit is intentional — not a boot death that
|
|
552
|
+
// should emit spawn_failed right before daemon.stopped.
|
|
553
|
+
// Read the marker through the HARDENED pid-sidecar reader (1 KiB cap,
|
|
554
|
+
// refuse-symlink, positive-int parse, null on any failure) — the marker
|
|
555
|
+
// lives in the pidfile directory, so a raw readFileSync here would follow a
|
|
556
|
+
// planted symlink or buffer an unbounded file (CWE-59 / DoS), the exact
|
|
557
|
+
// threat _readPidFile hardens the pid read against. null (no/garbage marker)
|
|
558
|
+
// !== child.pid, so a missing marker correctly reads as "not being stopped".
|
|
559
|
+
var beingStopped = _readPidFile(_stoppingMarkerPath(pidFile)) === child.pid;
|
|
560
|
+
if (wasOurs && abnormal && withinBoot && !beingStopped) {
|
|
561
|
+
_safeAuditEmit("daemon.spawn_failed", "failure", {
|
|
562
|
+
pidFile: pidFile,
|
|
563
|
+
command: opts.command,
|
|
564
|
+
exitCode: code,
|
|
565
|
+
signal: signal || null,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
// Keep the parent event loop alive through the boot-death window so the exit
|
|
570
|
+
// handler above can actually observe a child that dies at boot. A short-lived
|
|
571
|
+
// launcher (e.g. `blamejs daemon start`) would otherwise reach child.unref()
|
|
572
|
+
// and exit before the child dies — stranding the pidfile for a later stop()
|
|
573
|
+
// to misread, the very failure the handler exists to prevent. The timer is
|
|
574
|
+
// ref'd (holds the loop), is cleared the instant the child exits, and
|
|
575
|
+
// otherwise fires a no-op once the window elapses (boot succeeded → release
|
|
576
|
+
// the loop, which child.unref() no longer holds). bootDeathWindowMs:0 opts
|
|
577
|
+
// out entirely: no monitor, immediate exit (historical fire-and-forget).
|
|
578
|
+
if (bootWindowMs > 0) {
|
|
579
|
+
bootWatch = setTimeout(function () { bootWatch = null; }, bootWindowMs);
|
|
580
|
+
}
|
|
581
|
+
// Detach so a HEALTHY long-running child never holds the parent open past the
|
|
582
|
+
// boot window (bootWatch is the only remaining ref, and it self-clears).
|
|
414
583
|
try { child.unref(); } catch (_u) { /* best-effort */ }
|
|
415
584
|
if (typeof logFd === "number") {
|
|
416
585
|
// Parent doesn't need its handle to the log; child inherited it.
|
|
@@ -446,6 +615,7 @@ function start(opts) {
|
|
|
446
615
|
logFdForeground = _openLogFd(logFile);
|
|
447
616
|
_redirectStdio(logFdForeground);
|
|
448
617
|
} catch (e) {
|
|
618
|
+
/* c8 ignore next -- pidLock.release() swallows its own fs errors, so this guard never catches */
|
|
449
619
|
try { lock.release(); } catch (_r) { /* best-effort */ }
|
|
450
620
|
throw new DaemonError("daemon/log-open-failed",
|
|
451
621
|
"daemon.start: failed to open logFile '" + logFile + "': " +
|
|
@@ -469,7 +639,9 @@ function start(opts) {
|
|
|
469
639
|
{
|
|
470
640
|
name: "pidLock-release",
|
|
471
641
|
run: function () {
|
|
642
|
+
/* c8 ignore next -- close() delegates to _stopPolling, which self-catches, so stopWatcher.close never throws */
|
|
472
643
|
if (stopWatcher) { try { stopWatcher.close(); } catch (_w) { /* best-effort */ } }
|
|
644
|
+
/* c8 ignore next -- pidLock.release() swallows its own fs errors, so this guard never catches */
|
|
473
645
|
try { lock.release(); } catch (_e) { /* best-effort */ }
|
|
474
646
|
if (logFdForeground !== null) {
|
|
475
647
|
try { nodeFs.closeSync(logFdForeground); } catch (_c) { /* best-effort */ }
|
|
@@ -555,6 +727,23 @@ async function stop(opts) {
|
|
|
555
727
|
return { stopped: false, pid: pid, reason: "stale" };
|
|
556
728
|
}
|
|
557
729
|
|
|
730
|
+
// Publish a `<pidFile>.stopping` marker (holding the pid we are stopping) so the
|
|
731
|
+
// boot-death exit handler — in THIS process or the still-alive starter of a
|
|
732
|
+
// cross-process stop — treats the SIGTERM-induced exit as intentional, not a
|
|
733
|
+
// boot death. The finally removes it on every exit (return OR a kill-failed
|
|
734
|
+
// throw) so a later start() at the same path is never suppressed.
|
|
735
|
+
var stopMarker = _stoppingMarkerPath(pidFile);
|
|
736
|
+
try { atomicFile.writeSync(stopMarker, String(pid), { fileMode: 0o600 }); } catch (_w) { /* best-effort hint */ }
|
|
737
|
+
try {
|
|
738
|
+
return await _stopLivePid(pidFile, pid, signal, timeoutMs, pollMs, opts);
|
|
739
|
+
} finally {
|
|
740
|
+
try { nodeFs.unlinkSync(stopMarker); } catch (_u) { /* best-effort */ }
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Signal a confirmed-live pid and wait for exit, escalating SIGTERM -> SIGKILL.
|
|
745
|
+
// Extracted from stop() so the .stopping marker wraps every exit via try/finally.
|
|
746
|
+
async function _stopLivePid(pidFile, pid, signal, timeoutMs, pollMs, opts) {
|
|
558
747
|
var t0 = Date.now();
|
|
559
748
|
|
|
560
749
|
// Windows has no cooperative signal: process.kill(pid, "SIGTERM") maps to
|
|
@@ -733,4 +922,5 @@ module.exports = {
|
|
|
733
922
|
DEFAULT_STOP_SIGNAL: DEFAULT_STOP_SIGNAL,
|
|
734
923
|
DEFAULT_STOP_TIMEOUT_MS: DEFAULT_STOP_TIMEOUT_MS,
|
|
735
924
|
_resetForTest: _resetForTest,
|
|
925
|
+
_reapOwnStalePidfile: _reapOwnStalePidfile,
|
|
736
926
|
};
|
package/lib/guard-tenant-id.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
var { defineClass } = require("./framework-error");
|
|
28
28
|
var gateContract = require("./gate-contract");
|
|
29
29
|
var codepointClass = require("./codepoint-class");
|
|
30
|
+
var pick = require("./pick");
|
|
30
31
|
|
|
31
32
|
var GuardTenantIdError = defineClass("GuardTenantIdError", { alwaysPermanent: true });
|
|
32
33
|
|
|
@@ -78,10 +79,20 @@ function validate(tenantId, opts) {
|
|
|
78
79
|
throw new GuardTenantIdError("tenant-id/oversize",
|
|
79
80
|
"guardTenantId.validate: tenantId exceeds maxBytes=" + profile.maxBytes);
|
|
80
81
|
}
|
|
81
|
-
if (RESERVED
|
|
82
|
+
if (Object.prototype.hasOwnProperty.call(RESERVED, tenantId)) {
|
|
82
83
|
throw new GuardTenantIdError("tenant-id/reserved",
|
|
83
84
|
"guardTenantId.validate: tenantId '" + tenantId + "' is framework-reserved");
|
|
84
85
|
}
|
|
86
|
+
// Refuse the prototype-pollution key names outright via the framework's single
|
|
87
|
+
// poisoned-key predicate (lib/pick.js). The own-property RESERVED check above
|
|
88
|
+
// (deliberately) will not match these as inherited keys, and a tenant id used to
|
|
89
|
+
// key a plain-object store must never be __proto__ / constructor / prototype (or
|
|
90
|
+
// an operator-registered dangerous name), which would pollute the prototype
|
|
91
|
+
// chain instead of isolating the tenant.
|
|
92
|
+
if (pick.isPoisonedKey(tenantId)) {
|
|
93
|
+
throw new GuardTenantIdError("tenant-id/reserved",
|
|
94
|
+
"guardTenantId.validate: tenantId '" + tenantId + "' is a prototype-pollution key name");
|
|
95
|
+
}
|
|
85
96
|
if (tenantId.charAt(0) === ".") {
|
|
86
97
|
throw new GuardTenantIdError("tenant-id/hidden",
|
|
87
98
|
"guardTenantId.validate: tenantId cannot start with '.'");
|