@blamejs/core 0.14.20 → 0.14.22
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 +4 -0
- package/index.js +5 -1
- package/lib/auth/jar.js +190 -28
- package/lib/auth/jwt-external.js +213 -0
- package/lib/auth/oauth.js +115 -101
- package/lib/auth/oid4vci.js +124 -5
- package/lib/auth/oid4vp.js +14 -4
- package/lib/break-glass.js +1 -2
- package/lib/config.js +28 -31
- package/lib/dora.js +8 -5
- package/lib/dsr.js +2 -2
- package/lib/flag-evaluation-context.js +7 -0
- package/lib/guard-html-wcag-aria.js +4 -2
- package/lib/guard-html-wcag-forms.js +4 -2
- package/lib/guard-html-wcag-tables.js +4 -2
- package/lib/guard-html-wcag-tagwalk.js +20 -0
- package/lib/guard-html-wcag.js +1 -1
- package/lib/honeytoken.js +27 -20
- package/lib/http-client.js +3 -4
- package/lib/lro.js +3 -4
- package/lib/mail-deploy.js +1 -1
- package/lib/mail-send-deliver.js +13 -4
- package/lib/middleware/api-encrypt.js +140 -13
- package/lib/middleware/asyncapi-serve.js +3 -0
- package/lib/middleware/csp-report.js +13 -9
- package/lib/middleware/deny-response.js +2 -10
- package/lib/middleware/health.js +1 -4
- package/lib/middleware/openapi-serve.js +3 -0
- package/lib/middleware/scim-server.js +297 -19
- package/lib/middleware/security-txt.js +1 -2
- package/lib/middleware/trace-log-correlation.js +4 -8
- package/lib/network-smtp-policy.js +4 -4
- package/lib/object-store/sigv4-bucket-ops.js +11 -2
- package/lib/observability-tracer.js +1 -1
- package/lib/problem-details.js +56 -11
- package/lib/pubsub-cluster.js +16 -3
- package/lib/queue-sqs.js +20 -2
- package/lib/redis-client.js +32 -4
- package/lib/safe-redirect.js +16 -2
- package/lib/validate-opts.js +34 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/auth/oauth.js
CHANGED
|
@@ -122,6 +122,11 @@ var lazyRequire = require("../lazy-require");
|
|
|
122
122
|
// convention §3; no circular load — jwt-external requires nothing from
|
|
123
123
|
// oauth.
|
|
124
124
|
var jwtExternal = require("./jwt-external");
|
|
125
|
+
// RFC 9101 request-object builder — composed by pushAuthorizationRequest
|
|
126
|
+
// when the operator opts into sending a signed request object. Top-of-file
|
|
127
|
+
// per convention §3; no circular load — jar requires jwt-external +
|
|
128
|
+
// validate-opts only, nothing from oauth.
|
|
129
|
+
var jar = require("./jar");
|
|
125
130
|
var audit = lazyRequire(function () { return require("../audit"); });
|
|
126
131
|
|
|
127
132
|
// Cap on responses parsed from upstream OAuth providers. Token /
|
|
@@ -517,97 +522,62 @@ var MAX_ATTESTATION_JWT_BYTES = C.BYTES.kib(16);
|
|
|
517
522
|
var DEFAULT_POP_MAX_AGE_SEC = C.TIME.minutes(5) / C.TIME.seconds(1);
|
|
518
523
|
|
|
519
524
|
// Sign/verify params keyed by alg — superset of _verifyParamsForAlg that
|
|
520
|
-
// also covers EdDSA (used only on the attestation path; the
|
|
521
|
-
// verifier keeps its own narrower table untouched).
|
|
525
|
+
// also covers EdDSA (used only on the attestation verify path; the
|
|
526
|
+
// ID-token verifier keeps its own narrower table untouched).
|
|
522
527
|
function _attestationCryptoParams(alg) {
|
|
523
528
|
if (alg === "EdDSA") return { hash: null };
|
|
524
529
|
return _verifyParamsForAlg(alg);
|
|
525
530
|
}
|
|
526
531
|
|
|
532
|
+
// _toAttestationPrivateKey / _resolveAttestationAlg / _signAttestationJws —
|
|
533
|
+
// thin wrappers over the classical-JWS signer that the jwt-external module
|
|
534
|
+
// owns (b.auth.jws.sign internals). The attestation path keeps its own
|
|
535
|
+
// `auth-oauth/attestation-*` error codes so operators routing alerts on
|
|
536
|
+
// that class see no change; the signer BODIES (alg-from-key derivation,
|
|
537
|
+
// compact-JWS assembly) live in exactly one place — the classical-JOSE
|
|
538
|
+
// domain owner — rather than duplicated here. RFC 7518 §3.1 alg↔key
|
|
539
|
+
// binding and the self-invalid-alg defenses are enforced by the composed
|
|
540
|
+
// primitive.
|
|
541
|
+
|
|
527
542
|
function _toAttestationPrivateKey(value, label) {
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (typeof value === "string" || Buffer.isBuffer(value)) {
|
|
534
|
-
return nodeCrypto.createPrivateKey({ key: value, format: "pem" });
|
|
535
|
-
}
|
|
536
|
-
if (typeof value === "object" && value.kty) {
|
|
537
|
-
return nodeCrypto.createPrivateKey({ key: value, format: "jwk" });
|
|
538
|
-
}
|
|
539
|
-
} catch (e) {
|
|
540
|
-
throw new OAuthError("auth-oauth/attestation-bad-key",
|
|
541
|
-
label + ": private key parse failed: " + ((e && e.message) || String(e)));
|
|
543
|
+
try { return jwtExternal._toPrivateKey(value, label); }
|
|
544
|
+
catch (e) {
|
|
545
|
+
var code = (e && e.code) === "auth-jwt-external/sign-no-key"
|
|
546
|
+
? "auth-oauth/attestation-no-key" : "auth-oauth/attestation-bad-key";
|
|
547
|
+
throw new OAuthError(code, (e && e.message) || String(e));
|
|
542
548
|
}
|
|
543
|
-
throw new OAuthError("auth-oauth/attestation-bad-key",
|
|
544
|
-
label + ": privateKey must be a PEM string/Buffer, JWK object, or KeyObject");
|
|
545
549
|
}
|
|
546
550
|
|
|
547
|
-
// EC curve → the one ES* alg whose hash matches it (RFC 7518 §3.4).
|
|
548
|
-
var _EC_CURVE_ALG = { prime256v1: "ES256", secp384r1: "ES384", secp521r1: "ES512" };
|
|
549
|
-
|
|
550
551
|
// Resolve the JWS alg for an attestation / PoP signature. When the caller
|
|
551
|
-
// gives no `algorithm`,
|
|
552
|
-
// non-EC attester key (RSA, Ed25519) yields a
|
|
553
|
-
// alg ⇄ signature key — instead of a fixed
|
|
554
|
-
// real key, which `verifyClientAttestation`'s
|
|
555
|
-
// then reject. An explicit alg incompatible with
|
|
556
|
-
// signing
|
|
552
|
+
// gives no `algorithm`, the composed signer infers the default that matches
|
|
553
|
+
// the key type so a non-EC attester key (RSA, Ed25519) yields a
|
|
554
|
+
// self-consistent JWS — header alg ⇄ signature key — instead of a fixed
|
|
555
|
+
// `ES256` header signed with the real key, which `verifyClientAttestation`'s
|
|
556
|
+
// alg/kty cross-check would then reject. An explicit alg incompatible with
|
|
557
|
+
// the key is refused BEFORE signing. The draft additionally floors the
|
|
558
|
+
// accepted set to ATTESTATION_ALGS (no HMAC / none); the composed resolver
|
|
559
|
+
// already refuses those, surfaced here as the attestation-specific code.
|
|
557
560
|
function _resolveAttestationAlg(explicitAlg, privateKey, label) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
var
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
compatible = ["PS256", "PS384", "PS512"]; // an RSA-PSS key cannot produce an RS* signature
|
|
574
|
-
} else if (kty === "ed25519" || kty === "ed448") {
|
|
575
|
-
defaultAlg = "EdDSA";
|
|
576
|
-
compatible = ["EdDSA"];
|
|
577
|
-
} else {
|
|
578
|
-
throw new OAuthError("auth-oauth/attestation-key-unsupported",
|
|
579
|
-
label + ": key type '" + String(kty) + "' is not a supported attestation key (EC / RSA / Ed25519 / Ed448)");
|
|
580
|
-
}
|
|
581
|
-
if (explicitAlg === undefined || explicitAlg === null) return defaultAlg;
|
|
582
|
-
if (ATTESTATION_ALGS.indexOf(explicitAlg) === -1) {
|
|
583
|
-
throw new OAuthError("auth-oauth/attestation-alg-not-accepted",
|
|
584
|
-
label + ": alg '" + explicitAlg + "' is not an accepted attestation algorithm");
|
|
585
|
-
}
|
|
586
|
-
if (compatible.indexOf(explicitAlg) === -1) {
|
|
587
|
-
throw new OAuthError("auth-oauth/attestation-alg-key-mismatch",
|
|
588
|
-
label + ": alg '" + explicitAlg + "' is incompatible with the " + kty +
|
|
589
|
-
" key (compatible: " + compatible.join(", ") + ")");
|
|
561
|
+
try {
|
|
562
|
+
return jwtExternal._resolveSignAlg(explicitAlg, privateKey, label);
|
|
563
|
+
} catch (e) {
|
|
564
|
+
var ec = (e && e.code) || "";
|
|
565
|
+
if (ec === "auth-jwt-external/sign-alg-key-mismatch") {
|
|
566
|
+
throw new OAuthError("auth-oauth/attestation-alg-key-mismatch", (e && e.message) || String(e));
|
|
567
|
+
}
|
|
568
|
+
if (ec === "auth-jwt-external/sign-alg-refused" || ec === "auth-jwt-external/sign-alg-unsupported") {
|
|
569
|
+
throw new OAuthError("auth-oauth/attestation-alg-not-accepted",
|
|
570
|
+
label + ": alg '" + explicitAlg + "' is not an accepted attestation algorithm");
|
|
571
|
+
}
|
|
572
|
+
if (ec === "auth-jwt-external/sign-key-unsupported") {
|
|
573
|
+
throw new OAuthError("auth-oauth/attestation-key-unsupported", (e && e.message) || String(e));
|
|
574
|
+
}
|
|
575
|
+
throw new OAuthError("auth-oauth/attestation-bad-key", (e && e.message) || String(e));
|
|
590
576
|
}
|
|
591
|
-
return explicitAlg;
|
|
592
577
|
}
|
|
593
578
|
|
|
594
579
|
function _signAttestationJws(header, payload, privateKey, alg) {
|
|
595
|
-
|
|
596
|
-
var headerB64 = _b64urlEncode(Buffer.from(JSON.stringify(header), "utf8"));
|
|
597
|
-
var payloadB64 = _b64urlEncode(Buffer.from(JSON.stringify(payload), "utf8"));
|
|
598
|
-
var signingInput = headerB64 + "." + payloadB64;
|
|
599
|
-
var sig;
|
|
600
|
-
var input = Buffer.from(signingInput, "ascii");
|
|
601
|
-
if (params.hash === null) {
|
|
602
|
-
sig = nodeCrypto.sign(null, input, privateKey); // EdDSA — no prehash
|
|
603
|
-
} else {
|
|
604
|
-
var keyParam = { key: privateKey };
|
|
605
|
-
if (params.padding !== undefined) keyParam.padding = params.padding;
|
|
606
|
-
if (params.saltLength !== undefined) keyParam.saltLength = params.saltLength;
|
|
607
|
-
if (params.dsaEncoding !== undefined) keyParam.dsaEncoding = params.dsaEncoding;
|
|
608
|
-
sig = nodeCrypto.sign(params.hash, input, keyParam);
|
|
609
|
-
}
|
|
610
|
-
return signingInput + "." + _b64urlEncode(sig);
|
|
580
|
+
return jwtExternal._signCompactJws(header, payload, privateKey, alg);
|
|
611
581
|
}
|
|
612
582
|
|
|
613
583
|
// Verify a compact JWS against an already-imported public KeyObject. The
|
|
@@ -751,16 +721,9 @@ function buildClientAttestation(aopts) {
|
|
|
751
721
|
};
|
|
752
722
|
if (typeof aopts.nbf === "number") payload.nbf = aopts.nbf;
|
|
753
723
|
// Operator extra claims merged WITHOUT overriding the spec-required
|
|
754
|
-
// fields (
|
|
755
|
-
// names rejected).
|
|
724
|
+
// fields (proto-pollution sentinels skipped, the spec keys reserved).
|
|
756
725
|
if (aopts.extraClaims && typeof aopts.extraClaims === "object" && !Array.isArray(aopts.extraClaims)) {
|
|
757
|
-
|
|
758
|
-
for (var i = 0; i < ck.length; i += 1) {
|
|
759
|
-
var k = ck[i];
|
|
760
|
-
if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
|
|
761
|
-
if (Object.prototype.hasOwnProperty.call(payload, k)) continue; // never override spec fields
|
|
762
|
-
payload[k] = aopts.extraClaims[k];
|
|
763
|
-
}
|
|
726
|
+
validateOpts.assignOwnEnumerable(payload, aopts.extraClaims, Object.keys(payload));
|
|
764
727
|
}
|
|
765
728
|
return _signAttestationJws(
|
|
766
729
|
{ typ: "oauth-client-attestation+jwt", alg: alg }, payload, key, alg);
|
|
@@ -1996,6 +1959,13 @@ function create(opts) {
|
|
|
1996
1959
|
// browser-side redirect to /authorize. Defends against parameter
|
|
1997
1960
|
// tampering by an MITM at the user-agent + against URL-length
|
|
1998
1961
|
// overflow on long authorization requests.
|
|
1962
|
+
//
|
|
1963
|
+
// RFC 9101 signed request object: pass `signedRequestObject: { key,
|
|
1964
|
+
// alg?, kid?, audience?, expiresInMs? }` to push a JAR request object
|
|
1965
|
+
// instead of plain form params. The authorization parameters then
|
|
1966
|
+
// travel as signed claims (RFC 9126 §3 — form body carries only
|
|
1967
|
+
// `request` + client auth), so the PAR endpoint can verify they
|
|
1968
|
+
// arrived exactly as the client signed them. Absent → plain-form PAR.
|
|
1999
1969
|
async function pushAuthorizationRequest(uopts) {
|
|
2000
1970
|
uopts = uopts || {};
|
|
2001
1971
|
var endpoint;
|
|
@@ -2006,6 +1976,20 @@ function create(opts) {
|
|
|
2006
1976
|
"pushed_authorization_request_endpoint (set opts.pushedAuthorizationRequestEndpoint " +
|
|
2007
1977
|
"on create() if the IdP doesn't publish it)");
|
|
2008
1978
|
}
|
|
1979
|
+
// RFC 9101 signed-request-object opt: when the operator supplies
|
|
1980
|
+
// `signedRequestObject` (a config object carrying the client's signing
|
|
1981
|
+
// key), the authorization parameters travel as claims of a JAR request
|
|
1982
|
+
// object rather than as bare form params. Validated config-time; absent
|
|
1983
|
+
// → the existing plain-form path sends the same key/value set
|
|
1984
|
+
// (form-encoded params are unordered per the media type).
|
|
1985
|
+
var sro = uopts.signedRequestObject || null;
|
|
1986
|
+
if (sro) {
|
|
1987
|
+
validateOpts.optionalPlainObject(sro, "pushAuthorizationRequest: signedRequestObject",
|
|
1988
|
+
OAuthError, "auth-oauth/par-bad-request-object-opt",
|
|
1989
|
+
"must be an object { key, alg?, kid?, audience?, expiresInMs? }");
|
|
1990
|
+
validateOpts(sro, ["key", "alg", "kid", "audience", "expiresInMs"],
|
|
1991
|
+
"pushAuthorizationRequest.signedRequestObject");
|
|
1992
|
+
}
|
|
2009
1993
|
// Same PKCE-downgrade gate as authorizationUrl (RFC 9700 §4.13):
|
|
2010
1994
|
// PAR pushes the identical S256 challenge, so an OP advertising
|
|
2011
1995
|
// code_challenge_methods_supported without S256 is refused here too.
|
|
@@ -2015,31 +1999,60 @@ function create(opts) {
|
|
|
2015
1999
|
var state = uopts.state || _generateRandomToken(STATE_NONCE_BYTES);
|
|
2016
2000
|
var nonce = uopts.nonce || (isOidc ? _generateRandomToken(STATE_NONCE_BYTES) : null);
|
|
2017
2001
|
var pkceVals = _generatePkce();
|
|
2018
|
-
|
|
2019
|
-
body
|
|
2020
|
-
body.
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
if (
|
|
2031
|
-
if (
|
|
2002
|
+
// The authorization-request parameters. On the plain path these are set
|
|
2003
|
+
// on the form body directly; on the JAR path they become request-object
|
|
2004
|
+
// claims and the form body carries only `request` + client auth.
|
|
2005
|
+
var authzParams = {
|
|
2006
|
+
response_type: "code",
|
|
2007
|
+
client_id: clientId,
|
|
2008
|
+
redirect_uri: redirectUri,
|
|
2009
|
+
scope: scope.join(" "),
|
|
2010
|
+
state: state,
|
|
2011
|
+
code_challenge: pkceVals.challenge,
|
|
2012
|
+
code_challenge_method: "S256",
|
|
2013
|
+
};
|
|
2014
|
+
if (nonce) authzParams.nonce = nonce;
|
|
2015
|
+
if (responseMode) authzParams.response_mode = responseMode;
|
|
2016
|
+
if (uopts.prompt) authzParams.prompt = uopts.prompt;
|
|
2017
|
+
if (uopts.loginHint) authzParams.login_hint = uopts.loginHint;
|
|
2018
|
+
if (uopts.maxAge != null) authzParams.max_age = String(uopts.maxAge);
|
|
2032
2019
|
// RFC 9396 — push the fine-grained authorization request through PAR
|
|
2033
2020
|
// identically to the redirect path (validated, then JSON-serialized).
|
|
2034
2021
|
var requestedAuthzDetails = null;
|
|
2035
2022
|
if (uopts.authorizationDetails !== undefined) {
|
|
2036
2023
|
requestedAuthzDetails = _validateAuthorizationDetailsArray(
|
|
2037
2024
|
uopts.authorizationDetails, "pushAuthorizationRequest");
|
|
2038
|
-
|
|
2025
|
+
authzParams.authorization_details = JSON.stringify(requestedAuthzDetails);
|
|
2039
2026
|
}
|
|
2040
2027
|
if (uopts.extraParams && typeof uopts.extraParams === "object") {
|
|
2041
2028
|
var ek = Object.keys(uopts.extraParams);
|
|
2042
|
-
for (var i = 0; i < ek.length; i++)
|
|
2029
|
+
for (var i = 0; i < ek.length; i++) authzParams[ek[i]] = String(uopts.extraParams[ek[i]]);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
var body = new URLSearchParams();
|
|
2033
|
+
if (sro) {
|
|
2034
|
+
// RFC 9126 §3 — when a signed request object is pushed, the
|
|
2035
|
+
// authorization parameters MUST appear ONLY as claims of the JWT;
|
|
2036
|
+
// the form body carries `request` plus the parameters a client
|
|
2037
|
+
// authentication method requires (client_id, and client_secret for
|
|
2038
|
+
// the secret-based methods) and nothing else. The JAR `aud` is the
|
|
2039
|
+
// AS issuer identifier (RFC 9101 §5) — the operator may override but
|
|
2040
|
+
// it defaults to the configured `issuer`.
|
|
2041
|
+
var requestJwt = jar.build(authzParams, {
|
|
2042
|
+
clientId: clientId,
|
|
2043
|
+
audience: sro.audience || issuer,
|
|
2044
|
+
key: sro.key,
|
|
2045
|
+
alg: sro.alg,
|
|
2046
|
+
kid: sro.kid,
|
|
2047
|
+
expiresInMs: sro.expiresInMs,
|
|
2048
|
+
});
|
|
2049
|
+
body.set("request", requestJwt);
|
|
2050
|
+
body.set("client_id", clientId); // RFC 9126 §3 — client identification
|
|
2051
|
+
if (clientSecret) body.set("client_secret", clientSecret);
|
|
2052
|
+
} else {
|
|
2053
|
+
var ak = Object.keys(authzParams);
|
|
2054
|
+
for (var ap = 0; ap < ak.length; ap++) body.set(ak[ap], authzParams[ak[ap]]);
|
|
2055
|
+
if (clientSecret) body.set("client_secret", clientSecret);
|
|
2043
2056
|
}
|
|
2044
2057
|
var rv = await _postForm(endpoint, body);
|
|
2045
2058
|
if (!rv || typeof rv.request_uri !== "string" || rv.request_uri.length === 0) {
|
|
@@ -2062,6 +2075,7 @@ function create(opts) {
|
|
|
2062
2075
|
requestUri: rv.request_uri,
|
|
2063
2076
|
expiresIn: typeof rv.expires_in === "number" ? rv.expires_in : null,
|
|
2064
2077
|
authorizationDetails: requestedAuthzDetails,
|
|
2078
|
+
requestObjectSent: !!sro,
|
|
2065
2079
|
};
|
|
2066
2080
|
}
|
|
2067
2081
|
|
package/lib/auth/oid4vci.js
CHANGED
|
@@ -79,15 +79,68 @@ function _b64uDecodeStr(s) {
|
|
|
79
79
|
return Buffer.from(s, "base64url").toString("utf8");
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
// Linear trailing-`=` strip (charCodeAt + slice) — a regex-based
|
|
83
|
+
// padding strip is polynomial-ReDoS-shaped per CodeQL
|
|
84
|
+
// js/polynomial-redos; mirrors lib/argon2-builtin.js. The comparison
|
|
85
|
+
// below is standard base64 (RFC 7515 §4.1.6), so b.crypto.toBase64Url
|
|
86
|
+
// would produce the wrong alphabet.
|
|
87
|
+
function _stripBase64Pad(s) {
|
|
88
|
+
var end = s.length;
|
|
89
|
+
while (end > 0 && s.charCodeAt(end - 1) === 61) end--; // 61 = "="
|
|
90
|
+
return s.slice(0, end);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// RFC 7515 §4.1.6 — x5c is an array of base64 (NOT base64url) DER
|
|
94
|
+
// certificate strings, leaf first. Parse + shape-validate the chain into
|
|
95
|
+
// node:crypto X509Certificate objects; refuse a malformed array (empty,
|
|
96
|
+
// non-string entries, non-base64, or a leaf that won't parse) with a
|
|
97
|
+
// typed AuthError matching the module error style.
|
|
98
|
+
function _parseX5cChain(x5c) {
|
|
99
|
+
if (!Array.isArray(x5c) || x5c.length === 0) {
|
|
100
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
101
|
+
"credential issuance: proof JWT `x5c` must be a non-empty array of base64 DER certificate strings (RFC 7515 §4.1.6)");
|
|
102
|
+
}
|
|
103
|
+
var derBuffers = [];
|
|
104
|
+
var certs = [];
|
|
105
|
+
for (var i = 0; i < x5c.length; i++) {
|
|
106
|
+
var entry = x5c[i];
|
|
107
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
108
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
109
|
+
"credential issuance: proof JWT `x5c[" + i + "]` must be a non-empty base64 string");
|
|
110
|
+
}
|
|
111
|
+
// Standard base64 (not base64url) per RFC 7515 §4.1.6. Reject
|
|
112
|
+
// entries carrying base64url-only chars or that don't round-trip.
|
|
113
|
+
if (/[^A-Za-z0-9+/=]/.test(entry)) {
|
|
114
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
115
|
+
"credential issuance: proof JWT `x5c[" + i + "]` is not valid base64 (RFC 7515 §4.1.6 mandates standard base64, not base64url)");
|
|
116
|
+
}
|
|
117
|
+
var der = Buffer.from(entry, "base64");
|
|
118
|
+
if (der.length === 0 || _stripBase64Pad(der.toString("base64")) !== _stripBase64Pad(entry)) {
|
|
119
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
120
|
+
"credential issuance: proof JWT `x5c[" + i + "]` is not valid base64 (RFC 7515 §4.1.6)");
|
|
121
|
+
}
|
|
122
|
+
var cert;
|
|
123
|
+
try { cert = new nodeCrypto.X509Certificate(der); }
|
|
124
|
+
catch (e) {
|
|
125
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
126
|
+
"credential issuance: proof JWT `x5c[" + i + "]` is not a parseable DER certificate: " + ((e && e.message) || String(e)));
|
|
127
|
+
}
|
|
128
|
+
derBuffers.push(der);
|
|
129
|
+
certs.push(cert);
|
|
130
|
+
}
|
|
131
|
+
return { derBuffers: derBuffers, certs: certs };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId, supportedAlgs, proofMaxAgeMs, resolveKid, validateX5c) {
|
|
83
135
|
// OID4VCI §7.2.1.1: the proof JWT MUST:
|
|
84
136
|
// - typ = "openid4vci-proof+jwt"
|
|
85
137
|
// - alg in supported list (issuer publishes these)
|
|
86
138
|
// - aud = credential issuer URL (this issuer's `credential_issuer`)
|
|
87
139
|
// - iat = recent
|
|
88
140
|
// - nonce = c_nonce previously issued to the wallet
|
|
89
|
-
// - jwk (inline)
|
|
90
|
-
// pointing at the holder key to bind cnf to
|
|
141
|
+
// - jwk (inline), kid (resolved via resolveKid), OR x5c (leaf-cert
|
|
142
|
+
// SPKI) in the header pointing at the holder key to bind cnf to
|
|
143
|
+
// (RFC 7515 §4.1.3 / §4.1.4 / §4.1.6; OID4VCI §8.2.1.1)
|
|
91
144
|
if (typeof proofJwt !== "string" || proofJwt.length === 0 || proofJwt.length > MAX_PROOF_BYTES) {
|
|
92
145
|
throw new AuthError("auth-oid4vci/bad-proof",
|
|
93
146
|
"credential issuance: proof JWT is empty or exceeds " + MAX_PROOF_BYTES + " bytes");
|
|
@@ -135,6 +188,18 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
135
188
|
throw new AuthError("auth-oid4vci/wrong-proof-aud",
|
|
136
189
|
"credential issuance: proof JWT aud \"" + payload.aud + "\" mismatch (expected \"" + expectedAud + "\")");
|
|
137
190
|
}
|
|
191
|
+
// c_nonce expectation has three states the caller distinguishes:
|
|
192
|
+
// null → no nonce check expected (caller deliberately skips it).
|
|
193
|
+
// string → the c_nonce the wallet must echo (compared below).
|
|
194
|
+
// undefined → a nonce WAS expected but the store missed/expired it
|
|
195
|
+
// (cNonceStore.get returns undefined on miss/expiry, and
|
|
196
|
+
// the c_nonce TTL is shorter than the access token's).
|
|
197
|
+
// Refuse with a typed code — comparing against undefined
|
|
198
|
+
// would otherwise throw a raw TypeError from timingSafeEqual.
|
|
199
|
+
if (expectedCNonce === undefined) {
|
|
200
|
+
throw new AuthError("auth-oid4vci/c-nonce-expired",
|
|
201
|
+
"credential issuance: c_nonce expected but missing/expired — wallet must request a fresh c_nonce (the /token response's c_nonce TTL elapsed before /credential was called)");
|
|
202
|
+
}
|
|
138
203
|
if (expectedCNonce !== null) {
|
|
139
204
|
// Constant-time c_nonce compare — secret-shaped value vs
|
|
140
205
|
// attacker-controlled wallet payload.
|
|
@@ -172,7 +237,7 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
172
237
|
"credential issuance: proof JWT iss does not match the access-token client_id");
|
|
173
238
|
}
|
|
174
239
|
|
|
175
|
-
// Resolve the holder key the proof is signed with.
|
|
240
|
+
// Resolve the holder key the proof is signed with. Three paths:
|
|
176
241
|
// - inline `jwk` (RFC 7515 §4.1.3) — the wallet ships the public
|
|
177
242
|
// key in the header; bind `cnf` to it directly.
|
|
178
243
|
// - `kid` (RFC 7515 §4.1.4) without inline `jwk` — the wallet
|
|
@@ -181,6 +246,15 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
181
246
|
// supplies `resolveKid(kid, header)` to map the kid → public key.
|
|
182
247
|
// With no resolver configured the issuer keeps the clear refusal
|
|
183
248
|
// (back-compat): a kid-only proof can't be verified without one.
|
|
249
|
+
// - `x5c` (RFC 7515 §4.1.6) without inline `jwk`/`kid` — the wallet
|
|
250
|
+
// ships a base64 DER certificate chain; the LEAF cert's SPKI is
|
|
251
|
+
// the holder key (OID4VCI §8.2.1.1). Like inline `jwk`, the chain
|
|
252
|
+
// is self-asserted, so leaf-SPKI extraction at the same trust
|
|
253
|
+
// level is the correct parity — the proof signature check binds
|
|
254
|
+
// the key. Chain trust beyond that is operator policy: an optional
|
|
255
|
+
// `validateX5c(chainDerBuffers, header)` callback may throw to
|
|
256
|
+
// refuse (PKI anchoring, EKU checks, revocation, attestation-CA
|
|
257
|
+
// allowlist) before the SPKI is trusted.
|
|
184
258
|
var holderKeyJwk = header.jwk || null;
|
|
185
259
|
var keyObj;
|
|
186
260
|
if (!holderKeyJwk && header.kid) {
|
|
@@ -228,6 +302,42 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
228
302
|
throw new AuthError("auth-oid4vci/bad-resolved-key",
|
|
229
303
|
"credential issuance: resolveKid-returned key is not importable as a public key: " + ((e && e.message) || String(e)));
|
|
230
304
|
}
|
|
305
|
+
} else if (!holderKeyJwk && header.x5c) {
|
|
306
|
+
// RFC 7515 §4.1.6 / OID4VCI §8.2.1.1 — the wallet ships a base64 DER
|
|
307
|
+
// certificate chain; the LEAF (first) cert's SPKI is the holder key.
|
|
308
|
+
var chain = _parseX5cChain(header.x5c);
|
|
309
|
+
// Operator chain-trust policy runs BEFORE the SPKI is trusted. A
|
|
310
|
+
// throw refuses the proof (wrapped in a stable AuthError code so the
|
|
311
|
+
// /credential handler returns a typed refusal rather than an
|
|
312
|
+
// unhandled rejection; the callback is operator code, so its own
|
|
313
|
+
// message is allowed through for operator-side debugging).
|
|
314
|
+
if (typeof validateX5c === "function") {
|
|
315
|
+
try {
|
|
316
|
+
await validateX5c(chain.derBuffers.slice(), header);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
if (e instanceof AuthError) throw e;
|
|
319
|
+
throw new AuthError("auth-oid4vci/x5c-rejected",
|
|
320
|
+
"credential issuance: validateX5c rejected the proof JWT certificate chain: " + ((e && e.message) || String(e)));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Extract the leaf SPKI as a JWK to use as the holder key, exactly
|
|
324
|
+
// parallel to the inline-jwk path. publicKey is a node:crypto
|
|
325
|
+
// KeyObject; export to JWK for the cnf binding sdJwtIssuer.issue
|
|
326
|
+
// expects.
|
|
327
|
+
try { holderKeyJwk = chain.certs[0].publicKey.export({ format: "jwk" }); }
|
|
328
|
+
catch (e) {
|
|
329
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
330
|
+
"credential issuance: proof JWT `x5c` leaf certificate public key does not export to JWK: " + ((e && e.message) || String(e)));
|
|
331
|
+
}
|
|
332
|
+
// CVE-2026-22817 — same alg/kty cross-check the inline path applies.
|
|
333
|
+
// A leaf cert holding an RSA key against a proof declaring an HMAC
|
|
334
|
+
// alg would otherwise be verified as an HMAC secret.
|
|
335
|
+
jwtExternal._assertAlgKtyMatch(header.alg, holderKeyJwk);
|
|
336
|
+
try { keyObj = nodeCrypto.createPublicKey({ key: holderKeyJwk, format: "jwk" }); }
|
|
337
|
+
catch (e) {
|
|
338
|
+
throw new AuthError("auth-oid4vci/bad-x5c",
|
|
339
|
+
"credential issuance: proof JWT `x5c` leaf public key is not importable: " + ((e && e.message) || String(e)));
|
|
340
|
+
}
|
|
231
341
|
} else {
|
|
232
342
|
if (!holderKeyJwk) {
|
|
233
343
|
throw new AuthError("auth-oid4vci/no-jwk-in-header",
|
|
@@ -292,6 +402,7 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
|
|
|
292
402
|
* supportedCredentials: { [id]: { format, vct, claims, ... } },
|
|
293
403
|
* proofAlgorithms: string[], // default ["ES256", "ES384", "EdDSA"]
|
|
294
404
|
* resolveKid?: function(kid, header), // resolve a kid-only proof's holder key (JWK | KeyObject); without it, kid-only proofs are refused
|
|
405
|
+
* validateX5c?: function(chainDerBuffers, header), // x5c (RFC 7515 §4.1.6) chain-trust policy; throw to refuse. Absent → leaf-cert SPKI binds at the same self-asserted trust as inline `jwk`
|
|
295
406
|
* preAuthCodeTtlMs?: number, // default 5m
|
|
296
407
|
* accessTokenTtlMs?: number, // default 15m
|
|
297
408
|
* cNonceTtlMs?: number, // default 5m
|
|
@@ -358,6 +469,14 @@ function create(opts) {
|
|
|
358
469
|
var resolveKid = validateOpts.optionalFunction(opts.resolveKid,
|
|
359
470
|
"issuer.create: resolveKid", AuthError, "auth-oid4vci/bad-resolve-kid");
|
|
360
471
|
|
|
472
|
+
// Optional x5c chain-trust policy for x5c proofs (RFC 7515 §4.1.6 /
|
|
473
|
+
// OID4VCI §8.2.1.1). Config-time throw if supplied but not a function.
|
|
474
|
+
// Absent → the leaf-cert SPKI binds at the same self-asserted trust
|
|
475
|
+
// level as an inline `jwk` (the proof signature binds the key); chain
|
|
476
|
+
// anchoring beyond that is the operator's to enforce via this callback.
|
|
477
|
+
var validateX5c = validateOpts.optionalFunction(opts.validateX5c,
|
|
478
|
+
"issuer.create: validateX5c", AuthError, "auth-oid4vci/bad-validate-x5c");
|
|
479
|
+
|
|
361
480
|
var preAuthTtl = opts.preAuthCodeTtlMs || DEFAULT_PRE_AUTH_TTL_MS;
|
|
362
481
|
var accessTokenTtl = opts.accessTokenTtlMs || DEFAULT_ACCESS_TOKEN_TTL;
|
|
363
482
|
var cNonceTtl = opts.cNonceTtlMs || DEFAULT_C_NONCE_TTL_MS;
|
|
@@ -612,7 +731,7 @@ function create(opts) {
|
|
|
612
731
|
}
|
|
613
732
|
|
|
614
733
|
var expectedCNonce = await cNonceStore.get(iopts.accessToken);
|
|
615
|
-
var verified = await _verifyProofJwt(iopts.proof, opts.credentialIssuerUrl, expectedCNonce, null, proofAlgs, proofMaxAgeMs, resolveKid);
|
|
734
|
+
var verified = await _verifyProofJwt(iopts.proof, opts.credentialIssuerUrl, expectedCNonce, null, proofAlgs, proofMaxAgeMs, resolveKid, validateX5c);
|
|
616
735
|
|
|
617
736
|
if (!iopts.claims || typeof iopts.claims !== "object") {
|
|
618
737
|
throw new AuthError("auth-oid4vci/no-claims",
|
package/lib/auth/oid4vp.js
CHANGED
|
@@ -65,10 +65,11 @@ var _emitMetric = emit.metric;
|
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* Validate a DCQL query against the spec shape. Refuses unknown
|
|
68
|
-
* top-level keys, missing credential id, missing claim paths,
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
68
|
+
* top-level keys, missing credential id, missing claim paths,
|
|
69
|
+
* numeric claim-path segments that are not non-negative integer
|
|
70
|
+
* array indices (OpenID4VP 1.0 §7.1.1), or malformed credential_sets
|
|
71
|
+
* options. Throws AuthError on first failure (config-time validation
|
|
72
|
+
* — the verifier author is the one who needs to see the error).
|
|
72
73
|
*/
|
|
73
74
|
function _validateDcql(dcql) {
|
|
74
75
|
if (!dcql || typeof dcql !== "object" || Array.isArray(dcql)) {
|
|
@@ -113,6 +114,15 @@ function _validateDcql(dcql) {
|
|
|
113
114
|
throw new AuthError("auth-oid4vp/bad-claim-segment",
|
|
114
115
|
"DCQL: claim path segments must be string|number|null");
|
|
115
116
|
}
|
|
117
|
+
// OpenID4VP 1.0 §7.1.1 — a numeric segment is an array index;
|
|
118
|
+
// it MUST be a non-negative integer. Reject -1 / 1.5 / NaN /
|
|
119
|
+
// Infinity here (config-time / entry-point tier) so the
|
|
120
|
+
// verifier author sees the typo at build, not a silent
|
|
121
|
+
// non-match at verify time.
|
|
122
|
+
if (typeof segment === "number" && (!Number.isInteger(segment) || segment < 0)) {
|
|
123
|
+
throw new AuthError("auth-oid4vp/bad-claim-segment",
|
|
124
|
+
"DCQL: numeric claim path segment must be a non-negative integer (OpenID4VP 1.0 §7.1.1)");
|
|
125
|
+
}
|
|
116
126
|
});
|
|
117
127
|
if (claim.values !== undefined && !Array.isArray(claim.values)) {
|
|
118
128
|
throw new AuthError("auth-oid4vp/bad-claim-values",
|
package/lib/break-glass.js
CHANGED
|
@@ -425,7 +425,6 @@ async function migrate(table, opts) {
|
|
|
425
425
|
* has run.
|
|
426
426
|
*
|
|
427
427
|
* @opts
|
|
428
|
-
* now: number, // testing-only override of Date.now (fixtures)
|
|
429
428
|
* trustProxy: boolean, // honor X-Forwarded-For when populating grant.ip (default false)
|
|
430
429
|
*
|
|
431
430
|
* @example
|
|
@@ -434,7 +433,7 @@ async function migrate(table, opts) {
|
|
|
434
433
|
*/
|
|
435
434
|
function init(opts) {
|
|
436
435
|
opts = opts || {};
|
|
437
|
-
validateOpts(opts, ["
|
|
436
|
+
validateOpts(opts, ["trustProxy"], "breakGlass.init");
|
|
438
437
|
initialized = true;
|
|
439
438
|
policyCache.clear();
|
|
440
439
|
_factorLockout = null;
|
package/lib/config.js
CHANGED
|
@@ -258,7 +258,8 @@ function create(opts) {
|
|
|
258
258
|
* Rows whose transform throws or returns a non-string
|
|
259
259
|
* are skipped with a `config.reload.failed` audit so a
|
|
260
260
|
* single bad row never crashes the poller),
|
|
261
|
-
* audit: boolean (default true;
|
|
261
|
+
* audit: boolean (default true; set false to silence the
|
|
262
|
+
* per-poll config.reload.* audit emissions),
|
|
262
263
|
*
|
|
263
264
|
* @example
|
|
264
265
|
* var s = b.safeSchema;
|
|
@@ -322,6 +323,12 @@ function loadDbBacked(opts) {
|
|
|
322
323
|
var transformValue = validateOpts.optionalFunction(
|
|
323
324
|
opts.transformValue, "loadDbBacked: opts.transformValue",
|
|
324
325
|
ConfigError, "config/bad-transform-value") || null;
|
|
326
|
+
var auditOn = opts.audit !== false;
|
|
327
|
+
function _emitReloadAudit(record) {
|
|
328
|
+
if (!auditOn) return;
|
|
329
|
+
try { audit().safeEmit(record); }
|
|
330
|
+
catch (_e) { /* audit best-effort */ }
|
|
331
|
+
}
|
|
325
332
|
var cfg = create({ schema: opts.schema, env: opts.env, redactKeys: opts.redactKeys });
|
|
326
333
|
var stopped = false;
|
|
327
334
|
// Concurrency guard. _tick() runs `await opts.fetchRows()` + per-row
|
|
@@ -348,12 +355,10 @@ function loadDbBacked(opts) {
|
|
|
348
355
|
var rows;
|
|
349
356
|
try { rows = await opts.fetchRows(); }
|
|
350
357
|
catch (e) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
});
|
|
356
|
-
} catch (_e) { /* audit best-effort */ }
|
|
358
|
+
_emitReloadAudit({
|
|
359
|
+
action: "config.reload.failed", outcome: "failure",
|
|
360
|
+
metadata: { phase: "fetch", reason: e && e.message },
|
|
361
|
+
});
|
|
357
362
|
return;
|
|
358
363
|
}
|
|
359
364
|
if (!Array.isArray(rows)) return;
|
|
@@ -366,21 +371,17 @@ function loadDbBacked(opts) {
|
|
|
366
371
|
try {
|
|
367
372
|
value = await transformValue(row);
|
|
368
373
|
} catch (e) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
});
|
|
374
|
-
} catch (_e) { /* audit best-effort */ }
|
|
374
|
+
_emitReloadAudit({
|
|
375
|
+
action: "config.reload.failed", outcome: "failure",
|
|
376
|
+
metadata: { phase: "transform", key: row.key, reason: e && e.message },
|
|
377
|
+
});
|
|
375
378
|
continue;
|
|
376
379
|
}
|
|
377
380
|
if (typeof value !== "string") {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
});
|
|
383
|
-
} catch (_e) { /* audit best-effort */ }
|
|
381
|
+
_emitReloadAudit({
|
|
382
|
+
action: "config.reload.failed", outcome: "failure",
|
|
383
|
+
metadata: { phase: "transform", key: row.key, reason: "transformValue did not return a string" },
|
|
384
|
+
});
|
|
384
385
|
continue;
|
|
385
386
|
}
|
|
386
387
|
}
|
|
@@ -389,12 +390,10 @@ function loadDbBacked(opts) {
|
|
|
389
390
|
// Drop-stale: a tick that started after me has already finished and
|
|
390
391
|
// applied its newer fetch — my overlay would clobber fresher data.
|
|
391
392
|
if (mySeq <= ticksAppliedMax) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
});
|
|
397
|
-
} catch (_e) { /* audit best-effort */ }
|
|
393
|
+
_emitReloadAudit({
|
|
394
|
+
action: "config.reload.skipped", outcome: "success",
|
|
395
|
+
metadata: { phase: "stale-tick", mySeq: mySeq, appliedMax: ticksAppliedMax },
|
|
396
|
+
});
|
|
398
397
|
return;
|
|
399
398
|
}
|
|
400
399
|
// Advance the watermark ONLY after a successful reload. A newer
|
|
@@ -407,12 +406,10 @@ function loadDbBacked(opts) {
|
|
|
407
406
|
ticksAppliedMax = mySeq;
|
|
408
407
|
}
|
|
409
408
|
catch (e) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
});
|
|
415
|
-
} catch (_e) { /* audit best-effort */ }
|
|
409
|
+
_emitReloadAudit({
|
|
410
|
+
action: "config.reload.failed", outcome: "failure",
|
|
411
|
+
metadata: { phase: "validate", reason: e && e.message },
|
|
412
|
+
});
|
|
416
413
|
}
|
|
417
414
|
}
|
|
418
415
|
// Fire one immediate hydration before the interval kicks in so
|