@blamejs/core 0.10.14 → 0.11.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 +4 -0
- package/README.md +1 -0
- package/index.js +18 -0
- package/lib/auth/oauth.js +187 -0
- package/lib/auth/saml.js +1366 -13
- package/lib/cms-codec.js +141 -0
- package/lib/compliance.js +73 -0
- package/lib/csp.js +271 -0
- package/lib/dbsc.js +299 -0
- package/lib/fedcm.js +264 -0
- package/lib/hal.js +125 -0
- package/lib/importmap-integrity.js +90 -0
- package/lib/jsonapi.js +230 -0
- package/lib/lro.js +200 -0
- package/lib/mail-crypto-pgp.js +312 -2
- package/lib/mail-crypto-smime.js +530 -69
- package/lib/mail-deploy.js +632 -5
- package/lib/metrics.js +62 -12
- package/lib/middleware/security-headers.js +2 -1
- package/lib/standard-webhooks.js +183 -0
- package/lib/web-push-vapid.js +322 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/auth/saml.js
CHANGED
|
@@ -48,8 +48,11 @@
|
|
|
48
48
|
|
|
49
49
|
var lazyRequire = require("../lazy-require");
|
|
50
50
|
var validateOpts = require("../validate-opts");
|
|
51
|
+
var zlib = require("node:zlib");
|
|
51
52
|
var nodeCrypto = require("node:crypto");
|
|
52
|
-
var
|
|
53
|
+
var pqcSoftware = require("../pqc-software");
|
|
54
|
+
var bCrypto = require("../crypto");
|
|
55
|
+
var { generateToken, timingSafeEqual } = bCrypto;
|
|
53
56
|
var { AuthError } = require("../framework-error");
|
|
54
57
|
|
|
55
58
|
var xmlC14n = lazyRequire(function () { return require("../xml-c14n"); });
|
|
@@ -390,7 +393,6 @@ function create(opts) {
|
|
|
390
393
|
"<saml:Issuer>" + c14n.escapeText(opts.entityId) + "</saml:Issuer>" +
|
|
391
394
|
nameIdPolicy +
|
|
392
395
|
"</samlp:AuthnRequest>";
|
|
393
|
-
var zlib = require("node:zlib");
|
|
394
396
|
var deflated = zlib.deflateRawSync(Buffer.from(xml, "utf8"));
|
|
395
397
|
var samlRequest = encodeURIComponent(deflated.toString("base64"));
|
|
396
398
|
var url = opts.idpSsoUrl + (opts.idpSsoUrl.indexOf("?") === -1 ? "?" : "&") +
|
|
@@ -475,6 +477,57 @@ function create(opts) {
|
|
|
475
477
|
"verifyResponse: SAML Status is not Success: " + statusValue);
|
|
476
478
|
}
|
|
477
479
|
|
|
480
|
+
// EncryptedAssertion (SAML 2.0 §2.5) — operator supplies their
|
|
481
|
+
// SP decryption key via vopts.spPrivateKeyPem. Decrypt the
|
|
482
|
+
// EncryptedAssertion → re-parse the cleartext → splice the
|
|
483
|
+
// resulting <Assertion> back into the document tree before
|
|
484
|
+
// signature validation. The signature check then runs against
|
|
485
|
+
// the cleartext element exactly as if the IdP had emitted it
|
|
486
|
+
// unencrypted (the IdP signs the cleartext, then encrypts).
|
|
487
|
+
var encAssertionChildren = _findAllChildren(root, "EncryptedAssertion", SAML_NS.assertion);
|
|
488
|
+
if (encAssertionChildren.length > 1) {
|
|
489
|
+
throw new AuthError("auth-saml/duplicate-encrypted-assertion",
|
|
490
|
+
"verifyResponse: Response has multiple <EncryptedAssertion> children — XSW shape refused");
|
|
491
|
+
}
|
|
492
|
+
var encAssertion = encAssertionChildren[0] || null;
|
|
493
|
+
if (encAssertion) {
|
|
494
|
+
if (typeof vopts.spPrivateKeyPem !== "string" || vopts.spPrivateKeyPem.length === 0) {
|
|
495
|
+
throw new AuthError("auth-saml/encrypted-no-sp-key",
|
|
496
|
+
"verifyResponse: Response carries EncryptedAssertion but " +
|
|
497
|
+
"vopts.spPrivateKeyPem was not supplied");
|
|
498
|
+
}
|
|
499
|
+
var decryptedAssertionXml = _decryptEncryptedAssertion(encAssertion, vopts.spPrivateKeyPem);
|
|
500
|
+
// Re-parse the cleartext + splice into root.children, replacing
|
|
501
|
+
// the EncryptedAssertion node. The cleartext XML may carry its
|
|
502
|
+
// own namespace declarations; we use the c14n parser to handle
|
|
503
|
+
// that uniformly.
|
|
504
|
+
var clearRoot;
|
|
505
|
+
try { clearRoot = c14n.parse(decryptedAssertionXml); }
|
|
506
|
+
catch (e) {
|
|
507
|
+
throw new AuthError("auth-saml/encrypted-bad-cleartext",
|
|
508
|
+
"verifyResponse: decrypted EncryptedAssertion is not parseable XML: " +
|
|
509
|
+
((e && e.message) || String(e)));
|
|
510
|
+
}
|
|
511
|
+
var clearRootLocal = clearRoot.name.split(":").pop();
|
|
512
|
+
if (clearRootLocal !== "Assertion") {
|
|
513
|
+
throw new AuthError("auth-saml/encrypted-not-assertion",
|
|
514
|
+
"verifyResponse: decrypted EncryptedAssertion content is " + clearRootLocal +
|
|
515
|
+
", expected Assertion");
|
|
516
|
+
}
|
|
517
|
+
var encIdx = root.children.indexOf(encAssertion);
|
|
518
|
+
if (encIdx !== -1) {
|
|
519
|
+
root.children.splice(encIdx, 1, clearRoot);
|
|
520
|
+
} else {
|
|
521
|
+
root.children.push(clearRoot);
|
|
522
|
+
}
|
|
523
|
+
// Re-serialize the document with the cleartext Assertion inlined
|
|
524
|
+
// so the downstream XMLDSig verifier (_verifyXmldsig) operates
|
|
525
|
+
// on a coherent envelope. The signature reference still points
|
|
526
|
+
// at the Assertion ID; since the cleartext Assertion's ID is
|
|
527
|
+
// the IdP-signed one, the digest check matches.
|
|
528
|
+
xml = Buffer.from(c14n.canonicalize(root)).toString("utf8");
|
|
529
|
+
}
|
|
530
|
+
|
|
478
531
|
// Validate signature: prefer Assertion-level (most secure — the
|
|
479
532
|
// assertion is the security-critical element). Fall back to
|
|
480
533
|
// Response-level when the IdP signs the envelope only.
|
|
@@ -540,9 +593,101 @@ function create(opts) {
|
|
|
540
593
|
var nowSec = Math.floor((vopts.now || Date.now()) / 1000); // allow:raw-byte-literal — ms→s
|
|
541
594
|
var confirmations = _findAllChildren(subject, "SubjectConfirmation", SAML_NS.assertion);
|
|
542
595
|
var bearerOk = false;
|
|
596
|
+
var hokOk = false;
|
|
597
|
+
var hokFingerprint = null;
|
|
598
|
+
// Holder-of-Key SubjectConfirmation per SAML 2.0 Profile §3.1
|
|
599
|
+
// (urn:oasis:names:tc:SAML:2.0:cm:holder-of-key). The IdP binds
|
|
600
|
+
// the assertion to the subject's key by embedding a KeyInfo
|
|
601
|
+
// element inside SubjectConfirmationData; the SP MUST verify
|
|
602
|
+
// that the requesting party demonstrated possession of that key.
|
|
603
|
+
// Operators pass `vopts.holderOfKey: { presentedCertPem }` (the
|
|
604
|
+
// mTLS client cert pinned by b.network.tls.peerCert, or any
|
|
605
|
+
// operator-curated possession proof); we verify the embedded
|
|
606
|
+
// KeyInfo's SubjectPublicKeyInfo matches.
|
|
607
|
+
if (vopts.holderOfKey && typeof vopts.holderOfKey === "object" &&
|
|
608
|
+
typeof vopts.holderOfKey.presentedCertPem === "string") {
|
|
609
|
+
try {
|
|
610
|
+
var presentedKey = nodeCrypto.createPublicKey({
|
|
611
|
+
key: nodeCrypto.createPublicKey({
|
|
612
|
+
key: vopts.holderOfKey.presentedCertPem, format: "pem",
|
|
613
|
+
}).export({ type: "spki", format: "der" }),
|
|
614
|
+
format: "der", type: "spki",
|
|
615
|
+
});
|
|
616
|
+
hokFingerprint = nodeCrypto.createHash("sha3-512")
|
|
617
|
+
.update(presentedKey.export({ type: "spki", format: "der" })).digest("hex");
|
|
618
|
+
} catch (eHk) {
|
|
619
|
+
throw new AuthError("auth-saml/bad-hok-cert",
|
|
620
|
+
"verifyResponse: holderOfKey.presentedCertPem could not be parsed: " +
|
|
621
|
+
((eHk && eHk.message) || String(eHk)));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
543
624
|
for (var i = 0; i < confirmations.length; i++) {
|
|
544
625
|
var sc = confirmations[i];
|
|
545
|
-
|
|
626
|
+
var method = _attr(sc, "Method");
|
|
627
|
+
if (method === "urn:oasis:names:tc:SAML:2.0:cm:holder-of-key") {
|
|
628
|
+
// SP MUST refuse HoK without operator-supplied presented key
|
|
629
|
+
// (RFC SAML-V2-Profile §3.1 — receiver of an HoK confirmation
|
|
630
|
+
// can't honor it without proving possession).
|
|
631
|
+
if (!hokFingerprint) {
|
|
632
|
+
throw new AuthError("auth-saml/hok-no-presented-key",
|
|
633
|
+
"Assertion uses holder-of-key SubjectConfirmation but " +
|
|
634
|
+
"vopts.holderOfKey.presentedCertPem was not supplied");
|
|
635
|
+
}
|
|
636
|
+
var scdHok = _findChild(sc, "SubjectConfirmationData", SAML_NS.assertion);
|
|
637
|
+
if (!scdHok) continue;
|
|
638
|
+
var keyInfo = _findChild(scdHok, "KeyInfo");
|
|
639
|
+
if (!keyInfo) {
|
|
640
|
+
throw new AuthError("auth-saml/hok-no-keyinfo",
|
|
641
|
+
"holder-of-key SubjectConfirmationData missing KeyInfo");
|
|
642
|
+
}
|
|
643
|
+
// Resolve KeyInfo → SubjectPublicKeyInfo. SAML 2.0 §2.4.1.3.1
|
|
644
|
+
// permits X509Data/X509Certificate or KeyValue/RSAKeyValue
|
|
645
|
+
// shapes; we accept X509Certificate (most common) + compute
|
|
646
|
+
// its SPKI fingerprint to compare against the presented key.
|
|
647
|
+
var x509Data = _findChild(keyInfo, "X509Data");
|
|
648
|
+
var x509CertEl = x509Data ? _findChild(x509Data, "X509Certificate") : null;
|
|
649
|
+
if (!x509CertEl) {
|
|
650
|
+
throw new AuthError("auth-saml/hok-unsupported-keyinfo",
|
|
651
|
+
"holder-of-key KeyInfo: only X509Data/X509Certificate is supported");
|
|
652
|
+
}
|
|
653
|
+
var certB64 = _textContent(x509CertEl).replace(/\s+/g, "");
|
|
654
|
+
if (!certB64) {
|
|
655
|
+
throw new AuthError("auth-saml/hok-no-cert",
|
|
656
|
+
"holder-of-key KeyInfo/X509Certificate is empty");
|
|
657
|
+
}
|
|
658
|
+
var assertionCertPem =
|
|
659
|
+
"-----BEGIN CERTIFICATE-----\n" + certB64.replace(/(.{64})/g, "$1\n") +
|
|
660
|
+
"\n-----END CERTIFICATE-----\n";
|
|
661
|
+
var assertionKey;
|
|
662
|
+
try {
|
|
663
|
+
assertionKey = nodeCrypto.createPublicKey({ key: assertionCertPem, format: "pem" });
|
|
664
|
+
} catch (eAk) {
|
|
665
|
+
throw new AuthError("auth-saml/hok-bad-cert",
|
|
666
|
+
"holder-of-key X509Certificate could not be parsed: " +
|
|
667
|
+
((eAk && eAk.message) || String(eAk)));
|
|
668
|
+
}
|
|
669
|
+
var assertionFingerprint = nodeCrypto.createHash("sha3-512")
|
|
670
|
+
.update(assertionKey.export({ type: "spki", format: "der" })).digest("hex");
|
|
671
|
+
if (!timingSafeEqual(Buffer.from(assertionFingerprint, "hex"),
|
|
672
|
+
Buffer.from(hokFingerprint, "hex"))) {
|
|
673
|
+
throw new AuthError("auth-saml/hok-key-mismatch",
|
|
674
|
+
"holder-of-key: assertion's bound key fingerprint does not match " +
|
|
675
|
+
"the presented mTLS / possession-proof cert (possession-proof failed)");
|
|
676
|
+
}
|
|
677
|
+
// HoK still requires the same time-window / Recipient checks
|
|
678
|
+
// as Bearer (Profile §3.1 incorporates §3 by reference).
|
|
679
|
+
var nbHok = _attr(scdHok, "NotBefore");
|
|
680
|
+
var noaHok = _attr(scdHok, "NotOnOrAfter");
|
|
681
|
+
if (nbHok && isFinite(Date.parse(nbHok) / 1000) && // allow:raw-byte-literal — ms→s
|
|
682
|
+
Date.parse(nbHok) / 1000 > nowSec + clockSkewSec) continue; // allow:raw-byte-literal — ms→s
|
|
683
|
+
if (noaHok && isFinite(Date.parse(noaHok) / 1000) && // allow:raw-byte-literal — ms→s
|
|
684
|
+
Date.parse(noaHok) / 1000 < nowSec - clockSkewSec) continue; // allow:raw-byte-literal — ms→s
|
|
685
|
+
var recipHok = _attr(scdHok, "Recipient");
|
|
686
|
+
if (recipHok && recipHok !== opts.assertionConsumerServiceUrl) continue;
|
|
687
|
+
hokOk = true;
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
if (method !== "urn:oasis:names:tc:SAML:2.0:cm:bearer") continue;
|
|
546
691
|
var scd = _findChild(sc, "SubjectConfirmationData", SAML_NS.assertion);
|
|
547
692
|
if (!scd) continue;
|
|
548
693
|
var notOnOrAfter = _attr(scd, "NotOnOrAfter");
|
|
@@ -577,9 +722,10 @@ function create(opts) {
|
|
|
577
722
|
bearerOk = true;
|
|
578
723
|
break;
|
|
579
724
|
}
|
|
580
|
-
if (!bearerOk) {
|
|
581
|
-
throw new AuthError("auth-saml/no-valid-
|
|
582
|
-
"verifyResponse: no Bearer
|
|
725
|
+
if (!bearerOk && !hokOk) {
|
|
726
|
+
throw new AuthError("auth-saml/no-valid-confirmation",
|
|
727
|
+
"verifyResponse: no Bearer or holder-of-key SubjectConfirmation " +
|
|
728
|
+
"passed time/recipient/possession checks");
|
|
583
729
|
}
|
|
584
730
|
|
|
585
731
|
// Conditions
|
|
@@ -647,7 +793,7 @@ function create(opts) {
|
|
|
647
793
|
|
|
648
794
|
/**
|
|
649
795
|
* @primitive b.auth.saml.sp.metadata
|
|
650
|
-
* @signature b.auth.saml.sp.metadata()
|
|
796
|
+
* @signature b.auth.saml.sp.metadata(metaOpts?)
|
|
651
797
|
* @since 0.8.62
|
|
652
798
|
*
|
|
653
799
|
* Emit the SP's `EntityDescriptor` XML for IdP-side configuration.
|
|
@@ -659,14 +805,30 @@ function create(opts) {
|
|
|
659
805
|
* res.end(sp.metadata());
|
|
660
806
|
* });
|
|
661
807
|
*/
|
|
662
|
-
function metadata() {
|
|
808
|
+
function metadata(metaOpts) {
|
|
663
809
|
// RFC 3741 attr/text escaping for operator-supplied URLs / IDs —
|
|
664
810
|
// same audit-finding shape as buildAuthnRequest above.
|
|
811
|
+
metaOpts = metaOpts || {};
|
|
665
812
|
var c14n = xmlC14n();
|
|
813
|
+
// v0.10.16 — operator can supply SingleLogoutService URL +
|
|
814
|
+
// additional ACS bindings (HTTP-Redirect / HTTP-Artifact). The
|
|
815
|
+
// metadata XML now reflects what the SP actually supports.
|
|
816
|
+
var sloUrl = metaOpts.singleLogoutServiceUrl || opts.singleLogoutServiceUrl;
|
|
817
|
+
var sloXml = "";
|
|
818
|
+
if (sloUrl) {
|
|
819
|
+
sloXml =
|
|
820
|
+
"<md:SingleLogoutService " +
|
|
821
|
+
"Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" " +
|
|
822
|
+
"Location=\"" + c14n.escapeAttrValue(sloUrl) + "\"/>" +
|
|
823
|
+
"<md:SingleLogoutService " +
|
|
824
|
+
"Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" " +
|
|
825
|
+
"Location=\"" + c14n.escapeAttrValue(sloUrl) + "\"/>";
|
|
826
|
+
}
|
|
666
827
|
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
|
|
667
828
|
"<md:EntityDescriptor xmlns:md=\"" + SAML_NS.metadata + "\" entityID=\"" + c14n.escapeAttrValue(opts.entityId) + "\">" +
|
|
668
829
|
"<md:SPSSODescriptor protocolSupportEnumeration=\"" + SAML_NS.protocol + "\" " +
|
|
669
830
|
"AuthnRequestsSigned=\"false\" WantAssertionsSigned=\"true\">" +
|
|
831
|
+
sloXml +
|
|
670
832
|
"<md:AssertionConsumerService " +
|
|
671
833
|
"Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" " +
|
|
672
834
|
"Location=\"" + c14n.escapeAttrValue(opts.assertionConsumerServiceUrl) + "\" index=\"0\"/>" +
|
|
@@ -674,13 +836,1204 @@ function create(opts) {
|
|
|
674
836
|
"</md:EntityDescriptor>";
|
|
675
837
|
}
|
|
676
838
|
|
|
839
|
+
// ---- v0.10.16 — Single Logout (RFC SAML Bindings §3.4 HTTP-Redirect) ----
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* @primitive b.auth.saml.sp.buildLogoutRequest
|
|
843
|
+
* @signature b.auth.saml.sp.buildLogoutRequest(opts)
|
|
844
|
+
* @since 0.10.16
|
|
845
|
+
* @status stable
|
|
846
|
+
*
|
|
847
|
+
* Build a SAML 2.0 LogoutRequest XML + the URL-safe deflate-base64
|
|
848
|
+
* encoding for the HTTP-Redirect binding. When `signingKey` /
|
|
849
|
+
* `signingAlg` are supplied, computes the binding-§3.4.4.1
|
|
850
|
+
* canonical query-string signature so the IdP can verify the
|
|
851
|
+
* request originated from a trusted SP. The signature is computed
|
|
852
|
+
* over `SAMLRequest=<v>&[RelayState=<v>&]SigAlg=<v>` in that exact
|
|
853
|
+
* order (no re-sorting per the spec).
|
|
854
|
+
*
|
|
855
|
+
* @opts
|
|
856
|
+
* nameId: string, // user's NameID from the original AuthnResponse
|
|
857
|
+
* nameIdFormat: string, // optional NameID Format URI
|
|
858
|
+
* sessionIndex: string, // SessionIndex from the original Assertion AuthnStatement
|
|
859
|
+
* relayState: string, // optional opaque blob round-tripped to LogoutResponse
|
|
860
|
+
* signingKey: Uint8Array | string | KeyObject, // PQC private key (b.pqcSoftware.ml_dsa_*.keygen()) for ML-DSA;
|
|
861
|
+
* // PEM string or node:crypto KeyObject for RSA / ECDSA / Ed25519
|
|
862
|
+
* signingAlg: "rsa-sha256" | "rsa-sha384" | "rsa-sha512" |
|
|
863
|
+
* "ecdsa-sha256" | "ecdsa-sha384" | "ecdsa-sha512" |
|
|
864
|
+
* "ed25519" | "ml-dsa-65" | "ml-dsa-87", // default omitted → unsigned
|
|
865
|
+
*
|
|
866
|
+
* @example
|
|
867
|
+
* var lr = sp.buildLogoutRequest({
|
|
868
|
+
* nameId: "alice@idp", sessionIndex: "_session-9876",
|
|
869
|
+
* signingKey: kp.secretKey, signingAlg: "ml-dsa-65",
|
|
870
|
+
* });
|
|
871
|
+
* res.statusCode = 302;
|
|
872
|
+
* res.setHeader("Location", lr.redirectUrl);
|
|
873
|
+
*/
|
|
874
|
+
function buildLogoutRequest(bopts) {
|
|
875
|
+
bopts = validateOpts.requireObject(bopts, "auth.saml.sp.buildLogoutRequest", AuthError, "auth-saml/bad-opts");
|
|
876
|
+
validateOpts(bopts, ["nameId", "nameIdFormat", "sessionIndex", "relayState",
|
|
877
|
+
"signingKey", "signingAlg", "idpSloUrl"],
|
|
878
|
+
"auth.saml.sp.buildLogoutRequest");
|
|
879
|
+
validateOpts.requireNonEmptyString(bopts.nameId, "nameId", AuthError, "auth-saml/no-nameid");
|
|
880
|
+
var idpSloUrl = bopts.idpSloUrl || opts.idpSloUrl || opts.idpSsoUrl;
|
|
881
|
+
if (typeof idpSloUrl !== "string" || idpSloUrl.length === 0) {
|
|
882
|
+
throw new AuthError("auth-saml/no-idp-slo",
|
|
883
|
+
"buildLogoutRequest: opts.idpSloUrl (or sp.create's opts.idpSloUrl) required");
|
|
884
|
+
}
|
|
885
|
+
var id = "_" + generateToken(20); // allow:raw-byte-literal — 20-byte SAML ID token
|
|
886
|
+
var issueInstant = new Date().toISOString();
|
|
887
|
+
var c14n = xmlC14n();
|
|
888
|
+
var nameIdFormatAttr = bopts.nameIdFormat
|
|
889
|
+
? " Format=\"" + c14n.escapeAttrValue(bopts.nameIdFormat) + "\""
|
|
890
|
+
: "";
|
|
891
|
+
var sessionIndexXml = bopts.sessionIndex
|
|
892
|
+
? "<samlp:SessionIndex>" + c14n.escapeText(bopts.sessionIndex) + "</samlp:SessionIndex>"
|
|
893
|
+
: "";
|
|
894
|
+
var xml =
|
|
895
|
+
"<samlp:LogoutRequest xmlns:samlp=\"" + SAML_NS.protocol + "\" " +
|
|
896
|
+
"xmlns:saml=\"" + SAML_NS.assertion + "\" " +
|
|
897
|
+
"ID=\"" + id + "\" " +
|
|
898
|
+
"Version=\"2.0\" " +
|
|
899
|
+
"IssueInstant=\"" + issueInstant + "\" " +
|
|
900
|
+
"Destination=\"" + c14n.escapeAttrValue(idpSloUrl) + "\">" +
|
|
901
|
+
"<saml:Issuer>" + c14n.escapeText(opts.entityId) + "</saml:Issuer>" +
|
|
902
|
+
"<saml:NameID" + nameIdFormatAttr + ">" + c14n.escapeText(bopts.nameId) + "</saml:NameID>" +
|
|
903
|
+
sessionIndexXml +
|
|
904
|
+
"</samlp:LogoutRequest>";
|
|
905
|
+
var deflated = zlib.deflateRawSync(Buffer.from(xml, "utf8"));
|
|
906
|
+
var samlRequest = deflated.toString("base64");
|
|
907
|
+
var query = "SAMLRequest=" + encodeURIComponent(samlRequest);
|
|
908
|
+
if (bopts.relayState) {
|
|
909
|
+
query += "&RelayState=" + encodeURIComponent(bopts.relayState);
|
|
910
|
+
}
|
|
911
|
+
// Signature path — per SAML Bindings §3.4.4.1 the signature is
|
|
912
|
+
// computed over the URL-encoded query string with the SigAlg
|
|
913
|
+
// parameter appended (no Signature parameter, no re-sorting).
|
|
914
|
+
if (bopts.signingKey || bopts.signingAlg) {
|
|
915
|
+
var sigAlgUrn = _sigAlgUrn(bopts.signingAlg);
|
|
916
|
+
if (!sigAlgUrn) {
|
|
917
|
+
throw new AuthError("auth-saml/bad-signing-alg",
|
|
918
|
+
"buildLogoutRequest: signingAlg must be one of " +
|
|
919
|
+
"'rsa-sha256' / 'rsa-sha384' / 'rsa-sha512' / " +
|
|
920
|
+
"'ecdsa-sha256' / 'ecdsa-sha384' / 'ecdsa-sha512' / " +
|
|
921
|
+
"'ed25519' (W3C XMLDSig Core 1.1 + RFC 9231) or " +
|
|
922
|
+
"'ml-dsa-65' / 'ml-dsa-87' (framework-experimental — " +
|
|
923
|
+
"urn:blamejs:experimental:saml-sig-alg:*)");
|
|
924
|
+
}
|
|
925
|
+
var isPqc = bopts.signingAlg === "ml-dsa-65" || bopts.signingAlg === "ml-dsa-87";
|
|
926
|
+
if (isPqc && !(bopts.signingKey instanceof Uint8Array)) {
|
|
927
|
+
throw new AuthError("auth-saml/bad-signing-key",
|
|
928
|
+
"buildLogoutRequest: signingKey for " + bopts.signingAlg + " must be a Uint8Array");
|
|
929
|
+
}
|
|
930
|
+
if (!isPqc && bopts.signingAlg !== "ed25519" &&
|
|
931
|
+
typeof bopts.signingKey !== "string" &&
|
|
932
|
+
!(bopts.signingKey && typeof bopts.signingKey === "object" &&
|
|
933
|
+
bopts.signingKey.type === "private")) {
|
|
934
|
+
throw new AuthError("auth-saml/bad-signing-key",
|
|
935
|
+
"buildLogoutRequest: signingKey for classical " + bopts.signingAlg +
|
|
936
|
+
" must be a PEM string or node:crypto KeyObject");
|
|
937
|
+
}
|
|
938
|
+
query += "&SigAlg=" + encodeURIComponent(sigAlgUrn.urn);
|
|
939
|
+
var sigBytes = sigAlgUrn.sign(Buffer.from(query, "utf8"), bopts.signingKey);
|
|
940
|
+
query += "&Signature=" + encodeURIComponent(Buffer.from(sigBytes).toString("base64"));
|
|
941
|
+
}
|
|
942
|
+
var url = idpSloUrl + (idpSloUrl.indexOf("?") === -1 ? "?" : "&") + query;
|
|
943
|
+
_emitAudit("logoutrequest_built", "success", {
|
|
944
|
+
id: id, idp: opts.idpEntityId, signed: !!bopts.signingKey,
|
|
945
|
+
});
|
|
946
|
+
return { id: id, redirectUrl: url, raw: xml };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* @primitive b.auth.saml.sp.parseLogoutRequest
|
|
951
|
+
* @signature b.auth.saml.sp.parseLogoutRequest(samlRequestB64, vopts?)
|
|
952
|
+
* @since 0.10.16
|
|
953
|
+
* @status stable
|
|
954
|
+
*
|
|
955
|
+
* Parse an inbound LogoutRequest (IdP-initiated SLO). Returns
|
|
956
|
+
* `{ id, nameId, nameIdFormat, sessionIndex, issuer, issueInstant }`.
|
|
957
|
+
* When `vopts.idpVerifyKey` is supplied with `vopts.queryString`,
|
|
958
|
+
* verifies the HTTP-Redirect-binding signature against the IdP key.
|
|
959
|
+
*
|
|
960
|
+
* @opts
|
|
961
|
+
* queryString: string, // raw query string (everything after `?` in the redirect URL)
|
|
962
|
+
* idpVerifyKey: Uint8Array, // IdP's PQC public key
|
|
963
|
+
* idpVerifyAlg: "ml-dsa-65" | "ml-dsa-87" | "ed25519",
|
|
964
|
+
*
|
|
965
|
+
* @example
|
|
966
|
+
* var req = sp.parseLogoutRequest(req.query.SAMLRequest, {
|
|
967
|
+
* queryString: req.url.split("?")[1],
|
|
968
|
+
* idpVerifyKey: idpKp.publicKey,
|
|
969
|
+
* idpVerifyAlg: "ml-dsa-65",
|
|
970
|
+
* });
|
|
971
|
+
*/
|
|
972
|
+
function parseLogoutRequest(samlRequestB64, vopts) {
|
|
973
|
+
vopts = vopts || {};
|
|
974
|
+
if (typeof samlRequestB64 !== "string" || samlRequestB64.length === 0) {
|
|
975
|
+
throw new AuthError("auth-saml/no-saml-request",
|
|
976
|
+
"parseLogoutRequest: samlRequestB64 must be a non-empty string");
|
|
977
|
+
}
|
|
978
|
+
var xml;
|
|
979
|
+
try {
|
|
980
|
+
var deflated = Buffer.from(samlRequestB64, "base64");
|
|
981
|
+
xml = zlib.inflateRawSync(deflated, { maxOutputLength: 1024 * 1024 }).toString("utf8"); // allow:raw-byte-literal — 1 MiB max SAMLRequest decompressed
|
|
982
|
+
} catch (e) {
|
|
983
|
+
throw new AuthError("auth-saml/bad-saml-request",
|
|
984
|
+
"parseLogoutRequest: inflate failed: " + ((e && e.message) || String(e)));
|
|
985
|
+
}
|
|
986
|
+
// Verify the redirect-binding signature when an IdP key is supplied.
|
|
987
|
+
if (vopts.idpVerifyKey) {
|
|
988
|
+
if (typeof vopts.queryString !== "string") {
|
|
989
|
+
throw new AuthError("auth-saml/no-query-string",
|
|
990
|
+
"parseLogoutRequest: idpVerifyKey requires queryString (raw URL query)");
|
|
991
|
+
}
|
|
992
|
+
var sigAlgUrn = _sigAlgUrn(vopts.idpVerifyAlg);
|
|
993
|
+
if (!sigAlgUrn) {
|
|
994
|
+
throw new AuthError("auth-saml/bad-verify-alg",
|
|
995
|
+
"parseLogoutRequest: idpVerifyAlg must be 'ml-dsa-65' / 'ml-dsa-87' / 'ed25519'");
|
|
996
|
+
}
|
|
997
|
+
var parts = vopts.queryString.split("&");
|
|
998
|
+
var sigValue = null;
|
|
999
|
+
var signedPortion = [];
|
|
1000
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
1001
|
+
if (parts[i].indexOf("Signature=") === 0) {
|
|
1002
|
+
sigValue = decodeURIComponent(parts[i].slice("Signature=".length));
|
|
1003
|
+
} else {
|
|
1004
|
+
signedPortion.push(parts[i]);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if (!sigValue) {
|
|
1008
|
+
throw new AuthError("auth-saml/no-signature",
|
|
1009
|
+
"parseLogoutRequest: queryString lacks Signature parameter");
|
|
1010
|
+
}
|
|
1011
|
+
var sigBytes = Buffer.from(sigValue, "base64");
|
|
1012
|
+
var msgBytes = Buffer.from(signedPortion.join("&"), "utf8");
|
|
1013
|
+
var ok;
|
|
1014
|
+
try { ok = sigAlgUrn.verify(new Uint8Array(sigBytes), new Uint8Array(msgBytes), vopts.idpVerifyKey); }
|
|
1015
|
+
catch (eV) {
|
|
1016
|
+
throw new AuthError("auth-saml/verify-threw",
|
|
1017
|
+
"parseLogoutRequest: signature verify threw: " + ((eV && eV.message) || String(eV)));
|
|
1018
|
+
}
|
|
1019
|
+
if (!ok) {
|
|
1020
|
+
throw new AuthError("auth-saml/bad-signature",
|
|
1021
|
+
"parseLogoutRequest: HTTP-Redirect signature does not verify against idpVerifyKey");
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
// Parse the inflated XML.
|
|
1025
|
+
var c14n = xmlC14n();
|
|
1026
|
+
var root = c14n.parse(xml);
|
|
1027
|
+
var rootLocal = root.name.indexOf(":") !== -1 ? root.name.split(":").pop() : root.name;
|
|
1028
|
+
if (rootLocal !== "LogoutRequest") {
|
|
1029
|
+
throw new AuthError("auth-saml/not-logout-request",
|
|
1030
|
+
"parseLogoutRequest: root element is " + rootLocal + ", expected LogoutRequest");
|
|
1031
|
+
}
|
|
1032
|
+
var nameIdEl = _findChild(root, "NameID", SAML_NS.assertion);
|
|
1033
|
+
if (!nameIdEl) {
|
|
1034
|
+
throw new AuthError("auth-saml/no-nameid",
|
|
1035
|
+
"parseLogoutRequest: missing NameID");
|
|
1036
|
+
}
|
|
1037
|
+
var issuerEl = _findChild(root, "Issuer", SAML_NS.assertion);
|
|
1038
|
+
var sessionIndexEl = _findChild(root, "SessionIndex", SAML_NS.protocol);
|
|
1039
|
+
return {
|
|
1040
|
+
id: _attr(root, "ID"),
|
|
1041
|
+
issueInstant: _attr(root, "IssueInstant"),
|
|
1042
|
+
destination: _attr(root, "Destination"),
|
|
1043
|
+
nameId: _textContent(nameIdEl),
|
|
1044
|
+
nameIdFormat: _attr(nameIdEl, "Format"),
|
|
1045
|
+
sessionIndex: sessionIndexEl ? _textContent(sessionIndexEl) : null,
|
|
1046
|
+
issuer: issuerEl ? _textContent(issuerEl) : null,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* @primitive b.auth.saml.sp.buildLogoutResponse
|
|
1052
|
+
* @signature b.auth.saml.sp.buildLogoutResponse(opts)
|
|
1053
|
+
* @since 0.10.16
|
|
1054
|
+
* @status stable
|
|
1055
|
+
*
|
|
1056
|
+
* Build a SAML 2.0 LogoutResponse to an IdP-initiated LogoutRequest.
|
|
1057
|
+
* Status defaults to `urn:oasis:names:tc:SAML:2.0:status:Success`.
|
|
1058
|
+
* Same HTTP-Redirect binding + optional canonical-query signature
|
|
1059
|
+
* as buildLogoutRequest.
|
|
1060
|
+
*
|
|
1061
|
+
* @opts
|
|
1062
|
+
* inResponseTo: string, // required — LogoutRequest ID being responded to
|
|
1063
|
+
* destination: string, // required — IdP SLO endpoint URL the response posts to
|
|
1064
|
+
* statusCode: string, // optional — SAML status URI; default Success
|
|
1065
|
+
* relayState: string, // optional — opaque blob from the matching LogoutRequest
|
|
1066
|
+
* signingKey: Uint8Array, // PQC private key (b.pqcSoftware.ml_dsa_*.keygen())
|
|
1067
|
+
* signingAlg: "ml-dsa-65" | "ml-dsa-87" | "ed25519", // default omitted → unsigned
|
|
1068
|
+
*
|
|
1069
|
+
* @example
|
|
1070
|
+
* var resp = sp.buildLogoutResponse({
|
|
1071
|
+
* inResponseTo: incoming.id,
|
|
1072
|
+
* destination: "https://idp.example/slo",
|
|
1073
|
+
* signingKey: kp.secretKey,
|
|
1074
|
+
* signingAlg: "ml-dsa-65",
|
|
1075
|
+
* });
|
|
1076
|
+
* res.writeHead(302, { Location: resp.redirectUrl });
|
|
1077
|
+
*/
|
|
1078
|
+
function buildLogoutResponse(bopts) {
|
|
1079
|
+
bopts = validateOpts.requireObject(bopts, "auth.saml.sp.buildLogoutResponse", AuthError, "auth-saml/bad-opts");
|
|
1080
|
+
validateOpts(bopts, ["inResponseTo", "destination", "statusCode", "relayState",
|
|
1081
|
+
"signingKey", "signingAlg"],
|
|
1082
|
+
"auth.saml.sp.buildLogoutResponse");
|
|
1083
|
+
validateOpts.requireNonEmptyString(bopts.inResponseTo, "inResponseTo", AuthError, "auth-saml/no-in-response-to");
|
|
1084
|
+
validateOpts.requireNonEmptyString(bopts.destination, "destination", AuthError, "auth-saml/no-destination");
|
|
1085
|
+
var statusCode = bopts.statusCode || "urn:oasis:names:tc:SAML:2.0:status:Success";
|
|
1086
|
+
var id = "_" + generateToken(20); // allow:raw-byte-literal — 20-byte SAML ID token
|
|
1087
|
+
var issueInstant = new Date().toISOString();
|
|
1088
|
+
var c14n = xmlC14n();
|
|
1089
|
+
var xml =
|
|
1090
|
+
"<samlp:LogoutResponse xmlns:samlp=\"" + SAML_NS.protocol + "\" " +
|
|
1091
|
+
"xmlns:saml=\"" + SAML_NS.assertion + "\" " +
|
|
1092
|
+
"ID=\"" + id + "\" " +
|
|
1093
|
+
"Version=\"2.0\" " +
|
|
1094
|
+
"IssueInstant=\"" + issueInstant + "\" " +
|
|
1095
|
+
"InResponseTo=\"" + c14n.escapeAttrValue(bopts.inResponseTo) + "\" " +
|
|
1096
|
+
"Destination=\"" + c14n.escapeAttrValue(bopts.destination) + "\">" +
|
|
1097
|
+
"<saml:Issuer>" + c14n.escapeText(opts.entityId) + "</saml:Issuer>" +
|
|
1098
|
+
"<samlp:Status><samlp:StatusCode Value=\"" + c14n.escapeAttrValue(statusCode) + "\"/></samlp:Status>" +
|
|
1099
|
+
"</samlp:LogoutResponse>";
|
|
1100
|
+
var deflated = zlib.deflateRawSync(Buffer.from(xml, "utf8"));
|
|
1101
|
+
var samlResponse = deflated.toString("base64");
|
|
1102
|
+
var query = "SAMLResponse=" + encodeURIComponent(samlResponse);
|
|
1103
|
+
if (bopts.relayState) {
|
|
1104
|
+
query += "&RelayState=" + encodeURIComponent(bopts.relayState);
|
|
1105
|
+
}
|
|
1106
|
+
if (bopts.signingKey || bopts.signingAlg) {
|
|
1107
|
+
var sigAlgUrn = _sigAlgUrn(bopts.signingAlg);
|
|
1108
|
+
if (!sigAlgUrn) {
|
|
1109
|
+
throw new AuthError("auth-saml/bad-signing-alg",
|
|
1110
|
+
"buildLogoutResponse: signingAlg must be one of " +
|
|
1111
|
+
"'rsa-sha256' / 'rsa-sha384' / 'rsa-sha512' / " +
|
|
1112
|
+
"'ecdsa-sha256' / 'ecdsa-sha384' / 'ecdsa-sha512' / " +
|
|
1113
|
+
"'ed25519' (W3C XMLDSig Core 1.1 + RFC 9231) or " +
|
|
1114
|
+
"'ml-dsa-65' / 'ml-dsa-87' (framework-experimental — " +
|
|
1115
|
+
"urn:blamejs:experimental:saml-sig-alg:*)");
|
|
1116
|
+
}
|
|
1117
|
+
var isPqcResp = bopts.signingAlg === "ml-dsa-65" || bopts.signingAlg === "ml-dsa-87";
|
|
1118
|
+
if (isPqcResp && !(bopts.signingKey instanceof Uint8Array)) {
|
|
1119
|
+
throw new AuthError("auth-saml/bad-signing-key",
|
|
1120
|
+
"buildLogoutResponse: signingKey for " + bopts.signingAlg + " must be a Uint8Array");
|
|
1121
|
+
}
|
|
1122
|
+
if (!isPqcResp && bopts.signingAlg !== "ed25519" &&
|
|
1123
|
+
typeof bopts.signingKey !== "string" &&
|
|
1124
|
+
!(bopts.signingKey && typeof bopts.signingKey === "object" &&
|
|
1125
|
+
bopts.signingKey.type === "private")) {
|
|
1126
|
+
throw new AuthError("auth-saml/bad-signing-key",
|
|
1127
|
+
"buildLogoutResponse: signingKey for classical " + bopts.signingAlg +
|
|
1128
|
+
" must be a PEM string or node:crypto KeyObject");
|
|
1129
|
+
}
|
|
1130
|
+
query += "&SigAlg=" + encodeURIComponent(sigAlgUrn.urn);
|
|
1131
|
+
var sigBytes = sigAlgUrn.sign(Buffer.from(query, "utf8"), bopts.signingKey);
|
|
1132
|
+
query += "&Signature=" + encodeURIComponent(Buffer.from(sigBytes).toString("base64"));
|
|
1133
|
+
}
|
|
1134
|
+
var url = bopts.destination + (bopts.destination.indexOf("?") === -1 ? "?" : "&") + query;
|
|
1135
|
+
_emitAudit("logoutresponse_built", "success", { id: id, inResponseTo: bopts.inResponseTo });
|
|
1136
|
+
return { id: id, redirectUrl: url, raw: xml };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* @primitive b.auth.saml.sp.parseLogoutResponse
|
|
1141
|
+
* @signature b.auth.saml.sp.parseLogoutResponse(samlResponseB64, vopts?)
|
|
1142
|
+
* @since 0.10.16
|
|
1143
|
+
* @status stable
|
|
1144
|
+
*
|
|
1145
|
+
* Parse + verify an inbound SAML 2.0 LogoutResponse (the IdP's
|
|
1146
|
+
* acknowledgement of a previously-issued LogoutRequest). Returns
|
|
1147
|
+
* `{ id, inResponseTo, statusCode, issuer, success }` where
|
|
1148
|
+
* `success` is true when `statusCode` equals the spec's success
|
|
1149
|
+
* URN. When `vopts.idpVerifyKey` is supplied, verifies the
|
|
1150
|
+
* redirect-binding signature against the IdP key (same shape as
|
|
1151
|
+
* parseLogoutRequest).
|
|
1152
|
+
*
|
|
1153
|
+
* @opts
|
|
1154
|
+
* queryString: string, // raw URL query (everything past `?`)
|
|
1155
|
+
* idpVerifyKey: Uint8Array,
|
|
1156
|
+
* idpVerifyAlg: "ml-dsa-65" | "ml-dsa-87" | "ed25519",
|
|
1157
|
+
* expectedInResponseTo: string, // optional — refuses on mismatch
|
|
1158
|
+
*
|
|
1159
|
+
* @example
|
|
1160
|
+
* var resp = sp.parseLogoutResponse(req.query.SAMLResponse, {
|
|
1161
|
+
* queryString: req.url.split("?")[1],
|
|
1162
|
+
* idpVerifyKey: idpPub,
|
|
1163
|
+
* idpVerifyAlg: "ml-dsa-65",
|
|
1164
|
+
* expectedInResponseTo: storedLogoutRequestId,
|
|
1165
|
+
* });
|
|
1166
|
+
* resp.success; // → true on Success status
|
|
1167
|
+
*/
|
|
1168
|
+
function parseLogoutResponse(samlResponseB64, vopts) {
|
|
1169
|
+
vopts = vopts || {};
|
|
1170
|
+
if (typeof samlResponseB64 !== "string" || samlResponseB64.length === 0) {
|
|
1171
|
+
throw new AuthError("auth-saml/no-saml-response",
|
|
1172
|
+
"parseLogoutResponse: samlResponseB64 must be a non-empty string");
|
|
1173
|
+
}
|
|
1174
|
+
var xml;
|
|
1175
|
+
try {
|
|
1176
|
+
var deflated = Buffer.from(samlResponseB64, "base64");
|
|
1177
|
+
xml = zlib.inflateRawSync(deflated, { maxOutputLength: 1024 * 1024 }).toString("utf8"); // allow:raw-byte-literal — 1 MiB max SAMLResponse decompressed
|
|
1178
|
+
} catch (e) {
|
|
1179
|
+
throw new AuthError("auth-saml/bad-saml-response",
|
|
1180
|
+
"parseLogoutResponse: inflate failed: " + ((e && e.message) || String(e)));
|
|
1181
|
+
}
|
|
1182
|
+
if (vopts.idpVerifyKey) {
|
|
1183
|
+
if (typeof vopts.queryString !== "string") {
|
|
1184
|
+
throw new AuthError("auth-saml/no-query-string",
|
|
1185
|
+
"parseLogoutResponse: idpVerifyKey requires queryString");
|
|
1186
|
+
}
|
|
1187
|
+
var sigAlgUrn = _sigAlgUrn(vopts.idpVerifyAlg);
|
|
1188
|
+
if (!sigAlgUrn) {
|
|
1189
|
+
throw new AuthError("auth-saml/bad-verify-alg",
|
|
1190
|
+
"parseLogoutResponse: idpVerifyAlg must be 'ml-dsa-65' / 'ml-dsa-87' / 'ed25519'");
|
|
1191
|
+
}
|
|
1192
|
+
var parts = vopts.queryString.split("&");
|
|
1193
|
+
var sigValue = null;
|
|
1194
|
+
var signedPortion = [];
|
|
1195
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
1196
|
+
if (parts[i].indexOf("Signature=") === 0) {
|
|
1197
|
+
sigValue = decodeURIComponent(parts[i].slice("Signature=".length));
|
|
1198
|
+
} else {
|
|
1199
|
+
signedPortion.push(parts[i]);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (!sigValue) {
|
|
1203
|
+
throw new AuthError("auth-saml/no-signature",
|
|
1204
|
+
"parseLogoutResponse: queryString lacks Signature parameter");
|
|
1205
|
+
}
|
|
1206
|
+
var ok;
|
|
1207
|
+
try {
|
|
1208
|
+
ok = sigAlgUrn.verify(new Uint8Array(Buffer.from(sigValue, "base64")),
|
|
1209
|
+
new Uint8Array(Buffer.from(signedPortion.join("&"), "utf8")),
|
|
1210
|
+
vopts.idpVerifyKey);
|
|
1211
|
+
} catch (eV) {
|
|
1212
|
+
throw new AuthError("auth-saml/verify-threw",
|
|
1213
|
+
"parseLogoutResponse: signature verify threw: " + ((eV && eV.message) || String(eV)));
|
|
1214
|
+
}
|
|
1215
|
+
if (!ok) {
|
|
1216
|
+
throw new AuthError("auth-saml/bad-signature",
|
|
1217
|
+
"parseLogoutResponse: HTTP-Redirect signature does not verify against idpVerifyKey");
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
var c14n = xmlC14n();
|
|
1221
|
+
var root = c14n.parse(xml);
|
|
1222
|
+
var rootLocal = root.name.indexOf(":") !== -1 ? root.name.split(":").pop() : root.name;
|
|
1223
|
+
if (rootLocal !== "LogoutResponse") {
|
|
1224
|
+
throw new AuthError("auth-saml/not-logout-response",
|
|
1225
|
+
"parseLogoutResponse: root element is " + rootLocal + ", expected LogoutResponse");
|
|
1226
|
+
}
|
|
1227
|
+
var inResponseTo = _attr(root, "InResponseTo");
|
|
1228
|
+
if (vopts.expectedInResponseTo && inResponseTo !== vopts.expectedInResponseTo) {
|
|
1229
|
+
throw new AuthError("auth-saml/inresponseto-mismatch",
|
|
1230
|
+
"parseLogoutResponse: InResponseTo '" + inResponseTo + "' != expected '" +
|
|
1231
|
+
vopts.expectedInResponseTo + "'");
|
|
1232
|
+
}
|
|
1233
|
+
var statusEl = _findChild(root, "Status", SAML_NS.protocol);
|
|
1234
|
+
var statusCodeEl = statusEl && _findChild(statusEl, "StatusCode", SAML_NS.protocol);
|
|
1235
|
+
var statusCode = statusCodeEl ? _attr(statusCodeEl, "Value") : null;
|
|
1236
|
+
var issuerEl = _findChild(root, "Issuer", SAML_NS.assertion);
|
|
1237
|
+
return {
|
|
1238
|
+
id: _attr(root, "ID"),
|
|
1239
|
+
inResponseTo: inResponseTo,
|
|
1240
|
+
destination: _attr(root, "Destination"),
|
|
1241
|
+
statusCode: statusCode,
|
|
1242
|
+
success: statusCode === "urn:oasis:names:tc:SAML:2.0:status:Success",
|
|
1243
|
+
issuer: issuerEl ? _textContent(issuerEl) : null,
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// ---- v0.10.16 — SLO HTTP-POST binding (SAML Bindings §3.5) ----
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* @primitive b.auth.saml.sp.buildLogoutRequestPost
|
|
1251
|
+
* @signature b.auth.saml.sp.buildLogoutRequestPost(opts)
|
|
1252
|
+
* @since 0.10.16
|
|
1253
|
+
* @status stable
|
|
1254
|
+
*
|
|
1255
|
+
* HTTP-POST variant of buildLogoutRequest. Returns the
|
|
1256
|
+
* base64-encoded SAMLRequest body for the IdP's /slo POST endpoint
|
|
1257
|
+
* along with an embedded XMLDSig-Enveloped signature (when
|
|
1258
|
+
* signingKey is supplied). The signature is computed over the
|
|
1259
|
+
* canonical SignedInfo element per XMLDSig §4.5 — the referenced
|
|
1260
|
+
* LogoutRequest is canonicalized via exclusive-c14n, SHA3-512
|
|
1261
|
+
* digested, and that digest goes into the Reference's DigestValue
|
|
1262
|
+
* before SignedInfo itself is canonicalized + signed.
|
|
1263
|
+
*
|
|
1264
|
+
* @opts
|
|
1265
|
+
* nameId, nameIdFormat, sessionIndex, relayState — same as buildLogoutRequest
|
|
1266
|
+
* signingKey, signingAlg — PQC ml-dsa-65 / ml-dsa-87 (Ed25519 also
|
|
1267
|
+
* accepted; URN identifies the alg)
|
|
1268
|
+
*
|
|
1269
|
+
* @example
|
|
1270
|
+
* var lr = sp.buildLogoutRequestPost({
|
|
1271
|
+
* nameId: "alice@idp", signingKey: kp.secretKey, signingAlg: "ml-dsa-65",
|
|
1272
|
+
* });
|
|
1273
|
+
* res.statusCode = 200;
|
|
1274
|
+
* res.setHeader("Content-Type", "text/html");
|
|
1275
|
+
* res.end(lr.formHtml); // auto-POSTs SAMLRequest to lr.action
|
|
1276
|
+
*/
|
|
1277
|
+
function buildLogoutRequestPost(bopts) {
|
|
1278
|
+
bopts = validateOpts.requireObject(bopts, "auth.saml.sp.buildLogoutRequestPost",
|
|
1279
|
+
AuthError, "auth-saml/bad-opts");
|
|
1280
|
+
validateOpts(bopts, ["nameId", "nameIdFormat", "sessionIndex", "relayState",
|
|
1281
|
+
"signingKey", "signingAlg", "idpSloUrl"],
|
|
1282
|
+
"auth.saml.sp.buildLogoutRequestPost");
|
|
1283
|
+
validateOpts.requireNonEmptyString(bopts.nameId, "nameId", AuthError, "auth-saml/no-nameid");
|
|
1284
|
+
var idpSloUrl = bopts.idpSloUrl || opts.idpSloUrl || opts.idpSsoUrl;
|
|
1285
|
+
if (typeof idpSloUrl !== "string" || idpSloUrl.length === 0) {
|
|
1286
|
+
throw new AuthError("auth-saml/no-idp-slo",
|
|
1287
|
+
"buildLogoutRequestPost: opts.idpSloUrl required");
|
|
1288
|
+
}
|
|
1289
|
+
var id = "_" + generateToken(20); // allow:raw-byte-literal — 20-byte SAML ID token
|
|
1290
|
+
var issueInstant = new Date().toISOString();
|
|
1291
|
+
var c14n = xmlC14n();
|
|
1292
|
+
var nameIdFormatAttr = bopts.nameIdFormat
|
|
1293
|
+
? " Format=\"" + c14n.escapeAttrValue(bopts.nameIdFormat) + "\""
|
|
1294
|
+
: "";
|
|
1295
|
+
var sessionIndexXml = bopts.sessionIndex
|
|
1296
|
+
? "<samlp:SessionIndex>" + c14n.escapeText(bopts.sessionIndex) + "</samlp:SessionIndex>"
|
|
1297
|
+
: "";
|
|
1298
|
+
var bodyXml =
|
|
1299
|
+
"<samlp:LogoutRequest xmlns:samlp=\"" + SAML_NS.protocol + "\" " +
|
|
1300
|
+
"xmlns:saml=\"" + SAML_NS.assertion + "\" " +
|
|
1301
|
+
"ID=\"" + id + "\" " +
|
|
1302
|
+
"Version=\"2.0\" " +
|
|
1303
|
+
"IssueInstant=\"" + issueInstant + "\" " +
|
|
1304
|
+
"Destination=\"" + c14n.escapeAttrValue(idpSloUrl) + "\">" +
|
|
1305
|
+
"<saml:Issuer>" + c14n.escapeText(opts.entityId) + "</saml:Issuer>" +
|
|
1306
|
+
"<saml:NameID" + nameIdFormatAttr + ">" + c14n.escapeText(bopts.nameId) + "</saml:NameID>" +
|
|
1307
|
+
sessionIndexXml +
|
|
1308
|
+
"</samlp:LogoutRequest>";
|
|
1309
|
+
|
|
1310
|
+
var signedXml = bodyXml;
|
|
1311
|
+
if (bopts.signingKey || bopts.signingAlg) {
|
|
1312
|
+
signedXml = _embedXmlDsig(bodyXml, id, bopts.signingKey, bopts.signingAlg);
|
|
1313
|
+
}
|
|
1314
|
+
var samlRequest = Buffer.from(signedXml, "utf8").toString("base64");
|
|
1315
|
+
var rs = bopts.relayState ? bopts.relayState : "";
|
|
1316
|
+
var formHtml =
|
|
1317
|
+
"<!DOCTYPE html><html><body onload=\"document.forms[0].submit()\">" +
|
|
1318
|
+
"<form method=\"POST\" action=\"" + c14n.escapeAttrValue(idpSloUrl) + "\">" +
|
|
1319
|
+
"<input type=\"hidden\" name=\"SAMLRequest\" value=\"" + c14n.escapeAttrValue(samlRequest) + "\"/>" +
|
|
1320
|
+
(rs ? "<input type=\"hidden\" name=\"RelayState\" value=\"" + c14n.escapeAttrValue(rs) + "\"/>" : "") +
|
|
1321
|
+
"<noscript><button type=\"submit\">Continue</button></noscript>" +
|
|
1322
|
+
"</form></body></html>";
|
|
1323
|
+
_emitAudit("logoutrequest_post_built", "success", {
|
|
1324
|
+
id: id, idp: opts.idpEntityId, signed: !!bopts.signingKey,
|
|
1325
|
+
});
|
|
1326
|
+
return { id: id, action: idpSloUrl, samlRequest: samlRequest, formHtml: formHtml, raw: signedXml };
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* @primitive b.auth.saml.sp.parseLogoutRequestPost
|
|
1331
|
+
* @signature b.auth.saml.sp.parseLogoutRequestPost(samlRequestB64, vopts?)
|
|
1332
|
+
* @since 0.10.16
|
|
1333
|
+
* @status stable
|
|
1334
|
+
*
|
|
1335
|
+
* HTTP-POST variant of parseLogoutRequest. Decodes the base64
|
|
1336
|
+
* SAMLRequest body, parses the XML, and (when `idpVerifyKey` /
|
|
1337
|
+
* `idpVerifyAlg` are supplied) verifies the embedded XMLDSig-
|
|
1338
|
+
* Enveloped signature against the IdP key. Refuses when the
|
|
1339
|
+
* signature element is missing, the Reference URI doesn't match
|
|
1340
|
+
* the document root ID, the digest doesn't match the canonicalized
|
|
1341
|
+
* referenced element (signature-wrapping defense), or the
|
|
1342
|
+
* SignedInfo signature doesn't verify.
|
|
1343
|
+
*
|
|
1344
|
+
* @opts
|
|
1345
|
+
* idpVerifyKey: Uint8Array, // optional — verify embedded XMLDSig signature against this IdP key
|
|
1346
|
+
* idpVerifyAlg: "ml-dsa-65" | "ml-dsa-87" | "ed25519", // required when idpVerifyKey is supplied
|
|
1347
|
+
*
|
|
1348
|
+
* @example
|
|
1349
|
+
* var req = sp.parseLogoutRequestPost(req.body.SAMLRequest, {
|
|
1350
|
+
* idpVerifyKey: idpPubKey, idpVerifyAlg: "ml-dsa-65",
|
|
1351
|
+
* });
|
|
1352
|
+
* // req.nameId / req.sessionIndex / req.issuer
|
|
1353
|
+
*/
|
|
1354
|
+
function parseLogoutRequestPost(samlRequestB64, vopts) {
|
|
1355
|
+
vopts = vopts || {};
|
|
1356
|
+
if (typeof samlRequestB64 !== "string" || samlRequestB64.length === 0) {
|
|
1357
|
+
throw new AuthError("auth-saml/bad-input",
|
|
1358
|
+
"parseLogoutRequestPost: samlRequestB64 must be a non-empty string");
|
|
1359
|
+
}
|
|
1360
|
+
var xml = Buffer.from(samlRequestB64, "base64").toString("utf8");
|
|
1361
|
+
if (vopts.idpVerifyKey || vopts.idpVerifyAlg) {
|
|
1362
|
+
_verifyEmbeddedXmlDsig(xml, vopts.idpVerifyKey, vopts.idpVerifyAlg, "LogoutRequest");
|
|
1363
|
+
}
|
|
1364
|
+
var c14n = xmlC14n();
|
|
1365
|
+
var root = c14n.parse(xml);
|
|
1366
|
+
var rootLocal = root.name.split(":").pop();
|
|
1367
|
+
if (rootLocal !== "LogoutRequest") {
|
|
1368
|
+
throw new AuthError("auth-saml/wrong-root",
|
|
1369
|
+
"parseLogoutRequestPost: root element is " + rootLocal + ", expected LogoutRequest");
|
|
1370
|
+
}
|
|
1371
|
+
var nameIdEl = _findChild(root, "NameID", SAML_NS.assertion);
|
|
1372
|
+
if (!nameIdEl) {
|
|
1373
|
+
throw new AuthError("auth-saml/no-nameid",
|
|
1374
|
+
"parseLogoutRequestPost: missing NameID");
|
|
1375
|
+
}
|
|
1376
|
+
var sessionIndexEl = _findChild(root, "SessionIndex", SAML_NS.protocol);
|
|
1377
|
+
var issuerEl = _findChild(root, "Issuer", SAML_NS.assertion);
|
|
1378
|
+
return {
|
|
1379
|
+
id: _attr(root, "ID"),
|
|
1380
|
+
destination: _attr(root, "Destination"),
|
|
1381
|
+
nameId: _textContent(nameIdEl),
|
|
1382
|
+
nameIdFormat: _attr(nameIdEl, "Format"),
|
|
1383
|
+
sessionIndex: sessionIndexEl ? _textContent(sessionIndexEl) : null,
|
|
1384
|
+
issuer: issuerEl ? _textContent(issuerEl) : null,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* @primitive b.auth.saml.sp.buildLogoutRequestSoap
|
|
1390
|
+
* @signature b.auth.saml.sp.buildLogoutRequestSoap(opts)
|
|
1391
|
+
* @since 0.10.16
|
|
1392
|
+
* @status stable
|
|
1393
|
+
*
|
|
1394
|
+
* SOAP variant of buildLogoutRequest (SAML Bindings §3.2 — synchronous
|
|
1395
|
+
* back-channel binding). Wraps the LogoutRequest in
|
|
1396
|
+
* <soapenv:Envelope><soapenv:Body>...</> for an HTTP POST to the
|
|
1397
|
+
* IdP's SOAP endpoint. Embeds an XMLDSig-Enveloped signature on
|
|
1398
|
+
* the LogoutRequest itself (not the SOAP envelope) when signingKey
|
|
1399
|
+
* is supplied — matching the IdP-side parse expectation.
|
|
1400
|
+
*
|
|
1401
|
+
* @opts
|
|
1402
|
+
* same as buildLogoutRequestPost
|
|
1403
|
+
*
|
|
1404
|
+
* @example
|
|
1405
|
+
* var lr = sp.buildLogoutRequestSoap({ nameId: "alice@idp" });
|
|
1406
|
+
* var resp = await b.httpClient.request(lr.action, {
|
|
1407
|
+
* method: "POST",
|
|
1408
|
+
* body: lr.body,
|
|
1409
|
+
* headers: { "Content-Type": "text/xml; charset=utf-8" },
|
|
1410
|
+
* });
|
|
1411
|
+
* var result = sp.parseLogoutResponseSoap(resp.body);
|
|
1412
|
+
*/
|
|
1413
|
+
function buildLogoutRequestSoap(bopts) {
|
|
1414
|
+
var post = buildLogoutRequestPost(bopts);
|
|
1415
|
+
var body =
|
|
1416
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
|
|
1417
|
+
"<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\">" +
|
|
1418
|
+
"<soapenv:Body>" + post.raw + "</soapenv:Body>" +
|
|
1419
|
+
"</soapenv:Envelope>";
|
|
1420
|
+
return { id: post.id, action: post.action, body: body, raw: post.raw };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* @primitive b.auth.saml.sp.parseLogoutResponseSoap
|
|
1425
|
+
* @signature b.auth.saml.sp.parseLogoutResponseSoap(soapXml, vopts?)
|
|
1426
|
+
* @since 0.10.16
|
|
1427
|
+
* @status stable
|
|
1428
|
+
*
|
|
1429
|
+
* Parse a SOAP-wrapped LogoutResponse from the IdP's synchronous
|
|
1430
|
+
* back-channel reply. Unwraps the soapenv:Body, optionally verifies
|
|
1431
|
+
* the XMLDSig signature, and returns the same shape as
|
|
1432
|
+
* parseLogoutResponse.
|
|
1433
|
+
*
|
|
1434
|
+
* @opts
|
|
1435
|
+
* idpVerifyKey: Uint8Array, // optional — verify embedded XMLDSig signature against this IdP key
|
|
1436
|
+
* idpVerifyAlg: "ml-dsa-65" | "ml-dsa-87" | "ed25519", // required when idpVerifyKey is supplied
|
|
1437
|
+
*
|
|
1438
|
+
* @example
|
|
1439
|
+
* var result = sp.parseLogoutResponseSoap(resp.body, {
|
|
1440
|
+
* idpVerifyKey: idpPubKey, idpVerifyAlg: "ml-dsa-65",
|
|
1441
|
+
* });
|
|
1442
|
+
* // result.success / result.statusCode / result.inResponseTo
|
|
1443
|
+
*/
|
|
1444
|
+
function parseLogoutResponseSoap(soapXml, vopts) {
|
|
1445
|
+
vopts = vopts || {};
|
|
1446
|
+
if (typeof soapXml !== "string" || soapXml.length === 0) {
|
|
1447
|
+
throw new AuthError("auth-saml/bad-input",
|
|
1448
|
+
"parseLogoutResponseSoap: soapXml must be a non-empty string");
|
|
1449
|
+
}
|
|
1450
|
+
var c14n = xmlC14n();
|
|
1451
|
+
var soapRoot;
|
|
1452
|
+
try { soapRoot = c14n.parse(soapXml); }
|
|
1453
|
+
catch (e) {
|
|
1454
|
+
throw new AuthError("auth-saml/bad-soap",
|
|
1455
|
+
"parseLogoutResponseSoap: XML parse failed: " + ((e && e.message) || String(e)));
|
|
1456
|
+
}
|
|
1457
|
+
var soapRootLocal = soapRoot.name.split(":").pop();
|
|
1458
|
+
if (soapRootLocal !== "Envelope") {
|
|
1459
|
+
throw new AuthError("auth-saml/bad-soap",
|
|
1460
|
+
"parseLogoutResponseSoap: root element is " + soapRootLocal + ", expected soap:Envelope");
|
|
1461
|
+
}
|
|
1462
|
+
var body = null;
|
|
1463
|
+
for (var ci = 0; ci < soapRoot.children.length; ci += 1) {
|
|
1464
|
+
var ch = soapRoot.children[ci];
|
|
1465
|
+
if (ch.type !== "element") continue;
|
|
1466
|
+
var local = ch.name.split(":").pop();
|
|
1467
|
+
if (local === "Body") { body = ch; break; }
|
|
1468
|
+
}
|
|
1469
|
+
if (!body) {
|
|
1470
|
+
throw new AuthError("auth-saml/bad-soap",
|
|
1471
|
+
"parseLogoutResponseSoap: missing soap:Body");
|
|
1472
|
+
}
|
|
1473
|
+
var inner = null;
|
|
1474
|
+
for (var bi = 0; bi < body.children.length; bi += 1) {
|
|
1475
|
+
var bc = body.children[bi];
|
|
1476
|
+
if (bc.type === "element") { inner = bc; break; }
|
|
1477
|
+
}
|
|
1478
|
+
if (!inner) {
|
|
1479
|
+
throw new AuthError("auth-saml/bad-soap",
|
|
1480
|
+
"parseLogoutResponseSoap: soap:Body is empty");
|
|
1481
|
+
}
|
|
1482
|
+
var innerXml = Buffer.from(c14n.canonicalize(inner)).toString("utf8");
|
|
1483
|
+
if (vopts.idpVerifyKey || vopts.idpVerifyAlg) {
|
|
1484
|
+
_verifyEmbeddedXmlDsig(innerXml, vopts.idpVerifyKey, vopts.idpVerifyAlg, "LogoutResponse");
|
|
1485
|
+
}
|
|
1486
|
+
var innerLocal = inner.name.split(":").pop();
|
|
1487
|
+
if (innerLocal !== "LogoutResponse") {
|
|
1488
|
+
throw new AuthError("auth-saml/wrong-root",
|
|
1489
|
+
"parseLogoutResponseSoap: body element is " + innerLocal + ", expected LogoutResponse");
|
|
1490
|
+
}
|
|
1491
|
+
var statusEl = _findChild(inner, "Status", SAML_NS.protocol);
|
|
1492
|
+
var statusCode = statusEl ? _attr(_findChild(statusEl, "StatusCode", SAML_NS.protocol), "Value") : null;
|
|
1493
|
+
var issuerEl = _findChild(inner, "Issuer", SAML_NS.assertion);
|
|
1494
|
+
return {
|
|
1495
|
+
id: _attr(inner, "ID"),
|
|
1496
|
+
inResponseTo: _attr(inner, "InResponseTo"),
|
|
1497
|
+
destination: _attr(inner, "Destination"),
|
|
1498
|
+
statusCode: statusCode,
|
|
1499
|
+
success: statusCode === "urn:oasis:names:tc:SAML:2.0:status:Success",
|
|
1500
|
+
issuer: issuerEl ? _textContent(issuerEl) : null,
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
|
|
677
1504
|
return {
|
|
678
|
-
buildAuthnRequest:
|
|
679
|
-
verifyResponse:
|
|
680
|
-
metadata:
|
|
681
|
-
|
|
682
|
-
|
|
1505
|
+
buildAuthnRequest: buildAuthnRequest,
|
|
1506
|
+
verifyResponse: verifyResponse,
|
|
1507
|
+
metadata: metadata,
|
|
1508
|
+
buildLogoutRequest: buildLogoutRequest,
|
|
1509
|
+
parseLogoutRequest: parseLogoutRequest,
|
|
1510
|
+
buildLogoutResponse: buildLogoutResponse,
|
|
1511
|
+
parseLogoutResponse: parseLogoutResponse,
|
|
1512
|
+
buildLogoutRequestPost: buildLogoutRequestPost,
|
|
1513
|
+
parseLogoutRequestPost: parseLogoutRequestPost,
|
|
1514
|
+
buildLogoutRequestSoap: buildLogoutRequestSoap,
|
|
1515
|
+
parseLogoutResponseSoap: parseLogoutResponseSoap,
|
|
1516
|
+
entityId: opts.entityId,
|
|
1517
|
+
idpEntityId: opts.idpEntityId,
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// ---- v0.10.16 — SAML EncryptedAssertion decrypt (XMLEnc) ----
|
|
1522
|
+
|
|
1523
|
+
// XMLEnc Algorithm URIs we support.
|
|
1524
|
+
//
|
|
1525
|
+
// Currently-available standards (W3C XMLEnc 1.1, Recommendation 2013):
|
|
1526
|
+
// Symmetric content encryption:
|
|
1527
|
+
// http://www.w3.org/2009/xmlenc11#aes128-gcm (XMLEnc 1.1 §5.2.4)
|
|
1528
|
+
// http://www.w3.org/2009/xmlenc11#aes256-gcm (XMLEnc 1.1 §5.2.4)
|
|
1529
|
+
// Asymmetric key transport:
|
|
1530
|
+
// http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p (XMLEnc 1.0 §5.4.2 + RFC 4055)
|
|
1531
|
+
// http://www.w3.org/2009/xmlenc11#rsa-oaep (XMLEnc 1.1 §5.4.2)
|
|
1532
|
+
//
|
|
1533
|
+
// AES-CBC content encryption (xmlenc#aes128-cbc / aes256-cbc) is
|
|
1534
|
+
// intentionally REFUSED: CVE-2011-1473 + the broader XML-Encryption
|
|
1535
|
+
// padding-oracle research (Jager & Somorovsky 2011) demonstrate that
|
|
1536
|
+
// CBC mode under XMLEnc is exploitable without per-content MAC.
|
|
1537
|
+
// Operators integrating with IdPs that default to CBC (older ADFS /
|
|
1538
|
+
// Azure AD / Okta / Keycloak / OneLogin) MUST switch the IdP's
|
|
1539
|
+
// content-encryption setting to AES-128-GCM or AES-256-GCM. The
|
|
1540
|
+
// framework follows W3C's CR-2013 advice that GCM be used in new
|
|
1541
|
+
// deployments; the framework's "never weaken security middleware"
|
|
1542
|
+
// rule applies here.
|
|
1543
|
+
//
|
|
1544
|
+
// SHA-1 anywhere (rsa-oaep-mgf1p with SHA-1 OAEP DigestMethod,
|
|
1545
|
+
// xmldsig#sha1 DigestMethod) is also refused — Bleichenbacher /
|
|
1546
|
+
// collision risk plus CVE-2023-49141-class advisories outweigh
|
|
1547
|
+
// "interop with stale IdPs". Operators upgrade the IdP's digest
|
|
1548
|
+
// algorithm to SHA-256+ rather than relax the framework defense.
|
|
1549
|
+
//
|
|
1550
|
+
// Experimental (framework-private URNs — no IETF/W3C registration;
|
|
1551
|
+
// these are clearly under `urn:blamejs:experimental:` so operators
|
|
1552
|
+
// grep them in logs and know the framework owns them. Swap to the
|
|
1553
|
+
// registered URI once the relevant IETF/W3C WG publishes one):
|
|
1554
|
+
// urn:blamejs:experimental:xmlenc:xchacha20-poly1305 (XChaCha20-Poly1305 content encryption)
|
|
1555
|
+
// urn:blamejs:experimental:xmlenc:ml-kem-1024 (ML-KEM-1024 key transport)
|
|
1556
|
+
function _decryptEncryptedAssertion(encAssertion, spPrivateKeyPem) {
|
|
1557
|
+
var encData = _findChild(encAssertion, "EncryptedData");
|
|
1558
|
+
if (!encData) {
|
|
1559
|
+
throw new AuthError("auth-saml/encrypted-no-encrypted-data",
|
|
1560
|
+
"EncryptedAssertion missing EncryptedData");
|
|
1561
|
+
}
|
|
1562
|
+
var encMethod = _findChild(encData, "EncryptionMethod");
|
|
1563
|
+
var contentAlg = encMethod && _attr(encMethod, "Algorithm");
|
|
1564
|
+
if (!contentAlg) {
|
|
1565
|
+
throw new AuthError("auth-saml/encrypted-no-method",
|
|
1566
|
+
"EncryptedData missing EncryptionMethod/@Algorithm");
|
|
1567
|
+
}
|
|
1568
|
+
var keyInfo = _findChild(encData, "KeyInfo");
|
|
1569
|
+
if (!keyInfo) {
|
|
1570
|
+
throw new AuthError("auth-saml/encrypted-no-keyinfo",
|
|
1571
|
+
"EncryptedData missing KeyInfo (EncryptedKey transport required)");
|
|
1572
|
+
}
|
|
1573
|
+
var encKey = _findChild(keyInfo, "EncryptedKey");
|
|
1574
|
+
if (!encKey) {
|
|
1575
|
+
throw new AuthError("auth-saml/encrypted-no-encrypted-key",
|
|
1576
|
+
"EncryptedData/KeyInfo missing EncryptedKey");
|
|
1577
|
+
}
|
|
1578
|
+
var ekMethod = _findChild(encKey, "EncryptionMethod");
|
|
1579
|
+
var keyAlg = ekMethod && _attr(ekMethod, "Algorithm");
|
|
1580
|
+
if (!keyAlg) {
|
|
1581
|
+
throw new AuthError("auth-saml/encrypted-no-key-alg",
|
|
1582
|
+
"EncryptedKey missing EncryptionMethod/@Algorithm");
|
|
1583
|
+
}
|
|
1584
|
+
var ekCipherDataNode = _findChild(encKey, "CipherData");
|
|
1585
|
+
var ekCipherValueNode = ekCipherDataNode && _findChild(ekCipherDataNode, "CipherValue");
|
|
1586
|
+
if (!ekCipherValueNode) {
|
|
1587
|
+
throw new AuthError("auth-saml/encrypted-no-key-cipher-value",
|
|
1588
|
+
"EncryptedKey missing CipherData/CipherValue");
|
|
1589
|
+
}
|
|
1590
|
+
var wrappedKey = Buffer.from(_textContent(ekCipherValueNode).replace(/\s+/g, ""), "base64");
|
|
1591
|
+
// Unwrap the CEK.
|
|
1592
|
+
var cek;
|
|
1593
|
+
if (keyAlg === "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p" ||
|
|
1594
|
+
keyAlg === "http://www.w3.org/2009/xmlenc11#rsa-oaep") {
|
|
1595
|
+
var oaepHashName = "sha1";
|
|
1596
|
+
var digestMethodEk = _findChild(ekMethod, "DigestMethod");
|
|
1597
|
+
var oaepDigestUri = digestMethodEk && _attr(digestMethodEk, "Algorithm");
|
|
1598
|
+
if (oaepDigestUri) {
|
|
1599
|
+
if (oaepDigestUri === "http://www.w3.org/2001/04/xmlenc#sha256") oaepHashName = "sha256";
|
|
1600
|
+
else if (oaepDigestUri === "http://www.w3.org/2001/04/xmlenc#sha384") oaepHashName = "sha384";
|
|
1601
|
+
else if (oaepDigestUri === "http://www.w3.org/2001/04/xmlenc#sha512") oaepHashName = "sha512";
|
|
1602
|
+
else {
|
|
1603
|
+
throw new AuthError("auth-saml/encrypted-unsupported-oaep-digest",
|
|
1604
|
+
"EncryptedKey OAEP DigestMethod " + oaepDigestUri + " not supported");
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
if (oaepHashName === "sha1") {
|
|
1608
|
+
throw new AuthError("auth-saml/encrypted-weak-oaep-digest",
|
|
1609
|
+
"EncryptedKey OAEP DigestMethod is SHA-1 — refused (CVE-2023-49141-class). " +
|
|
1610
|
+
"Require SHA-256+ on IdP side.");
|
|
1611
|
+
}
|
|
1612
|
+
var spKey;
|
|
1613
|
+
try { spKey = nodeCrypto.createPrivateKey({ key: spPrivateKeyPem, format: "pem" }); }
|
|
1614
|
+
catch (e) {
|
|
1615
|
+
throw new AuthError("auth-saml/encrypted-bad-sp-key",
|
|
1616
|
+
"spPrivateKeyPem parse failed: " + ((e && e.message) || String(e)));
|
|
1617
|
+
}
|
|
1618
|
+
try {
|
|
1619
|
+
cek = nodeCrypto.privateDecrypt({
|
|
1620
|
+
key: spKey,
|
|
1621
|
+
padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING,
|
|
1622
|
+
oaepHash: oaepHashName,
|
|
1623
|
+
}, wrappedKey);
|
|
1624
|
+
} catch (eR) {
|
|
1625
|
+
throw new AuthError("auth-saml/encrypted-key-unwrap-failed",
|
|
1626
|
+
"OAEP unwrap failed: " + ((eR && eR.message) || String(eR)));
|
|
1627
|
+
}
|
|
1628
|
+
} else if (keyAlg === "urn:blamejs:experimental:xmlenc:ml-kem-1024") {
|
|
1629
|
+
// Framework PQC envelope — wrappedKey carries the ML-KEM
|
|
1630
|
+
// ciphertext concatenated with the AEAD-wrapped CEK. We invoke
|
|
1631
|
+
// b.pqcSoftware.ml_kem_1024.decapsulate to recover the shared
|
|
1632
|
+
// secret, then ChaCha20-Poly1305 unwrap. The exact wire shape is
|
|
1633
|
+
// the framework's `b.crypto.envelope` format.
|
|
1634
|
+
try {
|
|
1635
|
+
cek = bCrypto.decryptEnvelope({
|
|
1636
|
+
envelope: wrappedKey,
|
|
1637
|
+
privateKey: nodeCrypto.createPrivateKey({ key: spPrivateKeyPem, format: "pem" }),
|
|
1638
|
+
});
|
|
1639
|
+
} catch (eM) {
|
|
1640
|
+
throw new AuthError("auth-saml/encrypted-key-unwrap-failed",
|
|
1641
|
+
"ML-KEM-1024 unwrap failed: " + ((eM && eM.message) || String(eM)));
|
|
1642
|
+
}
|
|
1643
|
+
if (!Buffer.isBuffer(cek)) cek = Buffer.from(cek);
|
|
1644
|
+
} else {
|
|
1645
|
+
throw new AuthError("auth-saml/encrypted-unsupported-key-alg",
|
|
1646
|
+
"EncryptedKey EncryptionMethod " + keyAlg + " not supported " +
|
|
1647
|
+
"(supported: W3C xmlenc#rsa-oaep-mgf1p, xmlenc11#rsa-oaep, " +
|
|
1648
|
+
"framework-experimental urn:blamejs:experimental:xmlenc:ml-kem-1024). " +
|
|
1649
|
+
"AES-CBC content encryption is refused — switch the IdP to AES-128-GCM " +
|
|
1650
|
+
"or AES-256-GCM.");
|
|
1651
|
+
}
|
|
1652
|
+
// Decrypt content with the CEK.
|
|
1653
|
+
var contentCipherDataNode = _findChild(encData, "CipherData");
|
|
1654
|
+
var contentCipherValueNode = contentCipherDataNode && _findChild(contentCipherDataNode, "CipherValue");
|
|
1655
|
+
if (!contentCipherValueNode) {
|
|
1656
|
+
throw new AuthError("auth-saml/encrypted-no-content-cipher-value",
|
|
1657
|
+
"EncryptedData missing CipherData/CipherValue");
|
|
1658
|
+
}
|
|
1659
|
+
var contentBlob = Buffer.from(_textContent(contentCipherValueNode).replace(/\s+/g, ""), "base64");
|
|
1660
|
+
var clearBytes;
|
|
1661
|
+
if (contentAlg === "http://www.w3.org/2009/xmlenc11#aes128-gcm" ||
|
|
1662
|
+
contentAlg === "http://www.w3.org/2009/xmlenc11#aes256-gcm") {
|
|
1663
|
+
var aesBits = contentAlg.indexOf("aes128") !== -1 ? 128 : 256; // allow:raw-byte-literal — AES key size
|
|
1664
|
+
var expectedKeyBytes = aesBits / 8; // allow:raw-byte-literal — bits→bytes
|
|
1665
|
+
if (cek.length !== expectedKeyBytes) {
|
|
1666
|
+
throw new AuthError("auth-saml/encrypted-wrong-cek-len",
|
|
1667
|
+
"AES-" + aesBits + "-GCM CEK length is " + cek.length + ", expected " + expectedKeyBytes);
|
|
1668
|
+
}
|
|
1669
|
+
if (contentBlob.length < 28) { // allow:raw-byte-literal — 12 IV + 16 tag
|
|
1670
|
+
throw new AuthError("auth-saml/encrypted-content-too-short",
|
|
1671
|
+
"AES-GCM CipherValue too short to contain IV (12) + tag (16)");
|
|
1672
|
+
}
|
|
1673
|
+
var iv = contentBlob.subarray(0, 12); // allow:raw-byte-literal — GCM IV size
|
|
1674
|
+
var tag = contentBlob.subarray(contentBlob.length - 16); // allow:raw-byte-literal — GCM tag size
|
|
1675
|
+
var ct = contentBlob.subarray(12, contentBlob.length - 16);
|
|
1676
|
+
var decipher = nodeCrypto.createDecipheriv("aes-" + aesBits + "-gcm", cek, iv);
|
|
1677
|
+
decipher.setAuthTag(tag);
|
|
1678
|
+
try { clearBytes = Buffer.concat([decipher.update(ct), decipher.final()]); }
|
|
1679
|
+
catch (eD) {
|
|
1680
|
+
throw new AuthError("auth-saml/encrypted-content-tag-mismatch",
|
|
1681
|
+
"AES-GCM authentication tag mismatch: " + ((eD && eD.message) || String(eD)));
|
|
1682
|
+
}
|
|
1683
|
+
} else if (contentAlg === "urn:blamejs:experimental:xmlenc:xchacha20-poly1305") {
|
|
1684
|
+
if (cek.length !== 32) { // allow:raw-byte-literal — XChaCha20 key size
|
|
1685
|
+
throw new AuthError("auth-saml/encrypted-wrong-cek-len",
|
|
1686
|
+
"XChaCha20-Poly1305 CEK length is " + cek.length + ", expected 32");
|
|
1687
|
+
}
|
|
1688
|
+
if (contentBlob.length < 40) { // allow:raw-byte-literal — 24 nonce + 16 tag
|
|
1689
|
+
throw new AuthError("auth-saml/encrypted-content-too-short",
|
|
1690
|
+
"XChaCha20-Poly1305 CipherValue too short");
|
|
1691
|
+
}
|
|
1692
|
+
var xnonce = contentBlob.subarray(0, 24); // allow:raw-byte-literal — XChaCha20 nonce size
|
|
1693
|
+
var xtag = contentBlob.subarray(contentBlob.length - 16); // allow:raw-byte-literal — Poly1305 tag size
|
|
1694
|
+
var xct = contentBlob.subarray(24, contentBlob.length - 16);
|
|
1695
|
+
try {
|
|
1696
|
+
clearBytes = bCrypto.aeadDecrypt({
|
|
1697
|
+
alg: "xchacha20-poly1305",
|
|
1698
|
+
key: cek,
|
|
1699
|
+
nonce: xnonce,
|
|
1700
|
+
ct: xct,
|
|
1701
|
+
tag: xtag,
|
|
1702
|
+
});
|
|
1703
|
+
} catch (eX) {
|
|
1704
|
+
throw new AuthError("auth-saml/encrypted-content-tag-mismatch",
|
|
1705
|
+
"XChaCha20-Poly1305 tag mismatch: " + ((eX && eX.message) || String(eX)));
|
|
1706
|
+
}
|
|
1707
|
+
} else {
|
|
1708
|
+
throw new AuthError("auth-saml/encrypted-unsupported-content-alg",
|
|
1709
|
+
"EncryptedData EncryptionMethod " + contentAlg + " not supported " +
|
|
1710
|
+
"(supported: W3C xmlenc11#aes128-gcm, xmlenc11#aes256-gcm, " +
|
|
1711
|
+
"framework-experimental urn:blamejs:experimental:xmlenc:xchacha20-poly1305). " +
|
|
1712
|
+
"AES-CBC content encryption is refused — switch the IdP to AES-128-GCM or AES-256-GCM " +
|
|
1713
|
+
"(CVE-2011-1473 padding-oracle class).");
|
|
1714
|
+
}
|
|
1715
|
+
return clearBytes.toString("utf8");
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// ---- v0.10.16 — SAML SLO XMLDSig-Enveloped (HTTP-POST/SOAP) ----
|
|
1719
|
+
|
|
1720
|
+
// PQC SignatureMethod URIs used by the embedded XMLDSig signatures.
|
|
1721
|
+
// Standard XMLDSig vocabulary classical signing URIs (W3C XMLDSig
|
|
1722
|
+
// Core 1.1 + RFC 9231 for Ed25519) are dispatched via _sigAlgUrn /
|
|
1723
|
+
// _sigAlgFromUri. The framework adds two non-standard URNs for
|
|
1724
|
+
// ML-DSA because no W3C/IETF XMLDSig URI registration exists for
|
|
1725
|
+
// post-quantum signers yet (LAMPS WG has open drafts but none final).
|
|
1726
|
+
// Operators integrating with PQC-aware IdPs that exchange those URNs
|
|
1727
|
+
// out-of-band can use them; operators integrating with classical IdPs
|
|
1728
|
+
// (the public SAML deployment baseline today) use the W3C URIs.
|
|
1729
|
+
|
|
1730
|
+
function _embedXmlDsig(bodyXml, refId, signingKey, signingAlg) {
|
|
1731
|
+
// XMLDSig-Enveloped over the LogoutRequest / LogoutResponse root.
|
|
1732
|
+
// Pipeline:
|
|
1733
|
+
// 1. exclusive-c14n digest the LogoutRequest element with the
|
|
1734
|
+
// operator-supplied SignatureMethod's digest (SHA3-512 for
|
|
1735
|
+
// PQC + Ed25519, SHA-256/384/512 for classical RSA/ECDSA).
|
|
1736
|
+
// 2. Build SignedInfo with that digest in the Reference.
|
|
1737
|
+
// 3. exclusive-c14n SignedInfo and sign it via the chosen alg.
|
|
1738
|
+
// 4. Emit the ds:Signature element inside the root.
|
|
1739
|
+
var sigAlgUrn = _sigAlgUrn(signingAlg);
|
|
1740
|
+
if (!sigAlgUrn) {
|
|
1741
|
+
throw new AuthError("auth-saml/bad-signing-alg",
|
|
1742
|
+
"_embedXmlDsig: signingAlg must be 'ml-dsa-65' / 'ml-dsa-87' / 'ed25519' / " +
|
|
1743
|
+
"'rsa-sha256' / 'rsa-sha384' / 'rsa-sha512' / " +
|
|
1744
|
+
"'ecdsa-sha256' / 'ecdsa-sha384' / 'ecdsa-sha512'");
|
|
1745
|
+
}
|
|
1746
|
+
// PQC requires a Uint8Array; classical accepts PEM string or
|
|
1747
|
+
// KeyObject. ed25519 accepts both raw Uint8Array (32 bytes) and
|
|
1748
|
+
// KeyObject/PEM. We validate the key shape per alg family.
|
|
1749
|
+
var isPqc = signingAlg === "ml-dsa-65" || signingAlg === "ml-dsa-87";
|
|
1750
|
+
if (isPqc && !(signingKey instanceof Uint8Array)) {
|
|
1751
|
+
throw new AuthError("auth-saml/bad-signing-key",
|
|
1752
|
+
"_embedXmlDsig: signingKey for " + signingAlg + " must be a Uint8Array");
|
|
1753
|
+
}
|
|
1754
|
+
if (!isPqc && signingAlg !== "ed25519" &&
|
|
1755
|
+
typeof signingKey !== "string" &&
|
|
1756
|
+
!(signingKey && typeof signingKey === "object" && signingKey.type === "private")) {
|
|
1757
|
+
throw new AuthError("auth-saml/bad-signing-key",
|
|
1758
|
+
"_embedXmlDsig: signingKey for classical " + signingAlg +
|
|
1759
|
+
" must be a PEM string or node:crypto KeyObject");
|
|
1760
|
+
}
|
|
1761
|
+
var sigMethodUri = sigAlgUrn.urn;
|
|
1762
|
+
// DigestMethod follows the SignatureMethod family:
|
|
1763
|
+
// classical SHA-256 family → xmlenc#sha256/384/512 (W3C XMLDSig)
|
|
1764
|
+
// PQC + Ed25519 → xmldsig-more#sha3-512 (framework default)
|
|
1765
|
+
var digestMethodUri;
|
|
1766
|
+
if (signingAlg === "rsa-sha256" || signingAlg === "ecdsa-sha256") {
|
|
1767
|
+
digestMethodUri = "http://www.w3.org/2001/04/xmlenc#sha256";
|
|
1768
|
+
} else if (signingAlg === "rsa-sha384" || signingAlg === "ecdsa-sha384") {
|
|
1769
|
+
digestMethodUri = "http://www.w3.org/2001/04/xmlenc#sha384";
|
|
1770
|
+
} else if (signingAlg === "rsa-sha512" || signingAlg === "ecdsa-sha512") {
|
|
1771
|
+
digestMethodUri = "http://www.w3.org/2001/04/xmlenc#sha512";
|
|
1772
|
+
} else {
|
|
1773
|
+
digestMethodUri = "http://www.w3.org/2007/05/xmldsig-more#sha3-512";
|
|
1774
|
+
}
|
|
1775
|
+
var c14n = xmlC14n();
|
|
1776
|
+
// Pick the digest function matching digestMethodUri.
|
|
1777
|
+
var digestNodeAlg;
|
|
1778
|
+
if (digestMethodUri === "http://www.w3.org/2001/04/xmlenc#sha256") digestNodeAlg = "sha256";
|
|
1779
|
+
else if (digestMethodUri === "http://www.w3.org/2001/04/xmlenc#sha384") digestNodeAlg = "sha384";
|
|
1780
|
+
else if (digestMethodUri === "http://www.w3.org/2001/04/xmlenc#sha512") digestNodeAlg = "sha512";
|
|
1781
|
+
else digestNodeAlg = "sha3-512";
|
|
1782
|
+
var refDigest = nodeCrypto.createHash(digestNodeAlg).update(c14n.canonicalize(c14n.parse(bodyXml))).digest();
|
|
1783
|
+
var signedInfo =
|
|
1784
|
+
"<ds:SignedInfo xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\">" +
|
|
1785
|
+
"<ds:CanonicalizationMethod Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>" +
|
|
1786
|
+
"<ds:SignatureMethod Algorithm=\"" + sigMethodUri + "\"/>" +
|
|
1787
|
+
"<ds:Reference URI=\"#" + refId + "\">" +
|
|
1788
|
+
"<ds:Transforms>" +
|
|
1789
|
+
"<ds:Transform Algorithm=\"http://www.w3.org/2000/09/xmldsig#enveloped-signature\"/>" +
|
|
1790
|
+
"<ds:Transform Algorithm=\"http://www.w3.org/2001/10/xml-exc-c14n#\"/>" +
|
|
1791
|
+
"</ds:Transforms>" +
|
|
1792
|
+
"<ds:DigestMethod Algorithm=\"" + digestMethodUri + "\"/>" +
|
|
1793
|
+
"<ds:DigestValue>" + refDigest.toString("base64") + "</ds:DigestValue>" +
|
|
1794
|
+
"</ds:Reference>" +
|
|
1795
|
+
"</ds:SignedInfo>";
|
|
1796
|
+
var signedInfoCanonical = c14n.canonicalize(c14n.parse(signedInfo));
|
|
1797
|
+
var sigBytes = sigAlgUrn.sign(signedInfoCanonical, signingKey);
|
|
1798
|
+
var sigEl =
|
|
1799
|
+
"<ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\">" +
|
|
1800
|
+
signedInfo +
|
|
1801
|
+
"<ds:SignatureValue>" + Buffer.from(sigBytes).toString("base64") + "</ds:SignatureValue>" +
|
|
1802
|
+
"</ds:Signature>";
|
|
1803
|
+
// Insert Signature as the second child after Issuer (per SAML 2.0
|
|
1804
|
+
// schema — saml:Issuer always precedes ds:Signature).
|
|
1805
|
+
var issuerCloseIdx = bodyXml.indexOf("</saml:Issuer>");
|
|
1806
|
+
if (issuerCloseIdx === -1) {
|
|
1807
|
+
throw new AuthError("auth-saml/no-issuer",
|
|
1808
|
+
"_embedXmlDsig: bodyXml missing saml:Issuer element");
|
|
1809
|
+
}
|
|
1810
|
+
var splitAt = issuerCloseIdx + "</saml:Issuer>".length;
|
|
1811
|
+
return bodyXml.substring(0, splitAt) + sigEl + bodyXml.substring(splitAt);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function _verifyEmbeddedXmlDsig(xml, idpVerifyKey, idpVerifyAlg, expectedRootLocal) {
|
|
1815
|
+
if (!idpVerifyKey || !idpVerifyAlg) return;
|
|
1816
|
+
var sigAlgUrn = _sigAlgUrn(idpVerifyAlg);
|
|
1817
|
+
if (!sigAlgUrn) {
|
|
1818
|
+
throw new AuthError("auth-saml/bad-verify-alg",
|
|
1819
|
+
"idpVerifyAlg must be 'ml-dsa-65' / 'ml-dsa-87' / 'ed25519' / " +
|
|
1820
|
+
"'rsa-sha256' / 'rsa-sha384' / 'rsa-sha512' / " +
|
|
1821
|
+
"'ecdsa-sha256' / 'ecdsa-sha384' / 'ecdsa-sha512'");
|
|
1822
|
+
}
|
|
1823
|
+
var expectedSigUri = sigAlgUrn.urn;
|
|
1824
|
+
var c14n = xmlC14n();
|
|
1825
|
+
var root = c14n.parse(xml);
|
|
1826
|
+
var rootLocal = root.name.split(":").pop();
|
|
1827
|
+
if (rootLocal !== expectedRootLocal) {
|
|
1828
|
+
throw new AuthError("auth-saml/wrong-root",
|
|
1829
|
+
"_verifyEmbeddedXmlDsig: root is " + rootLocal + ", expected " + expectedRootLocal);
|
|
1830
|
+
}
|
|
1831
|
+
var sigNode = _findChild(root, "Signature");
|
|
1832
|
+
if (!sigNode) {
|
|
1833
|
+
throw new AuthError("auth-saml/no-signature",
|
|
1834
|
+
"_verifyEmbeddedXmlDsig: " + expectedRootLocal + " has no embedded ds:Signature");
|
|
1835
|
+
}
|
|
1836
|
+
var signedInfo = _findChild(sigNode, "SignedInfo");
|
|
1837
|
+
if (!signedInfo) {
|
|
1838
|
+
throw new AuthError("auth-saml/no-signed-info",
|
|
1839
|
+
"_verifyEmbeddedXmlDsig: Signature missing SignedInfo");
|
|
1840
|
+
}
|
|
1841
|
+
// CanonicalizationMethod check (W3C XMLDSig Core 1.1 §4.5). Only
|
|
1842
|
+
// exclusive-c14n (with or without comments) is supported because the
|
|
1843
|
+
// framework's xml-c14n module canonicalizes via xml-exc-c14n. Older
|
|
1844
|
+
// SAML deployments using inclusive c14n
|
|
1845
|
+
// (http://www.w3.org/TR/2001/REC-xml-c14n-20010315) would silently
|
|
1846
|
+
// digest-mismatch — refuse explicitly with a clear error.
|
|
1847
|
+
var canonMethodNode = _findChild(signedInfo, "CanonicalizationMethod");
|
|
1848
|
+
var canonUri = canonMethodNode && _attr(canonMethodNode, "Algorithm");
|
|
1849
|
+
if (canonUri !== "http://www.w3.org/2001/10/xml-exc-c14n#" &&
|
|
1850
|
+
canonUri !== "http://www.w3.org/2001/10/xml-exc-c14n#WithComments") {
|
|
1851
|
+
throw new AuthError("auth-saml/unsupported-c14n",
|
|
1852
|
+
"_verifyEmbeddedXmlDsig: CanonicalizationMethod " + canonUri + " not supported " +
|
|
1853
|
+
"(only W3C exclusive xml-exc-c14n is supported; inclusive c14n is refused — " +
|
|
1854
|
+
"switch the IdP to exclusive canonicalization)");
|
|
1855
|
+
}
|
|
1856
|
+
var sigMethodNode = _findChild(signedInfo, "SignatureMethod");
|
|
1857
|
+
var sigUri = sigMethodNode && _attr(sigMethodNode, "Algorithm");
|
|
1858
|
+
if (sigUri !== expectedSigUri) {
|
|
1859
|
+
throw new AuthError("auth-saml/wrong-sig-alg",
|
|
1860
|
+
"_verifyEmbeddedXmlDsig: SignatureMethod " + sigUri + " != expected " + expectedSigUri +
|
|
1861
|
+
" (alg-confusion defense)");
|
|
1862
|
+
}
|
|
1863
|
+
var refNode = _findChild(signedInfo, "Reference");
|
|
1864
|
+
if (!refNode) {
|
|
1865
|
+
throw new AuthError("auth-saml/no-reference",
|
|
1866
|
+
"_verifyEmbeddedXmlDsig: SignedInfo missing Reference");
|
|
1867
|
+
}
|
|
1868
|
+
var refUri = _attr(refNode, "URI") || "";
|
|
1869
|
+
if (refUri.charAt(0) !== "#") {
|
|
1870
|
+
throw new AuthError("auth-saml/external-reference",
|
|
1871
|
+
"_verifyEmbeddedXmlDsig: Reference URI must be a same-document fragment");
|
|
1872
|
+
}
|
|
1873
|
+
var refId = refUri.substring(1);
|
|
1874
|
+
var rootId = _attr(root, "ID");
|
|
1875
|
+
if (rootId !== refId) {
|
|
1876
|
+
throw new AuthError("auth-saml/ref-mismatch",
|
|
1877
|
+
"_verifyEmbeddedXmlDsig: Reference URI '#" + refId + "' does not match root ID '" + rootId +
|
|
1878
|
+
"' (signature-wrapping defense)");
|
|
1879
|
+
}
|
|
1880
|
+
var digestMethodNode = _findChild(refNode, "DigestMethod");
|
|
1881
|
+
var digestUri = digestMethodNode && _attr(digestMethodNode, "Algorithm");
|
|
1882
|
+
// Allow either sha3-512 (framework default) or the SHA-2 family.
|
|
1883
|
+
var digestAlgName;
|
|
1884
|
+
if (digestUri === "http://www.w3.org/2007/05/xmldsig-more#sha3-512") digestAlgName = "sha3-512";
|
|
1885
|
+
else if (SUPPORTED_DIGEST[digestUri]) digestAlgName = SUPPORTED_DIGEST[digestUri];
|
|
1886
|
+
else {
|
|
1887
|
+
throw new AuthError("auth-saml/unsupported-digest",
|
|
1888
|
+
"_verifyEmbeddedXmlDsig: DigestMethod " + digestUri + " not supported");
|
|
1889
|
+
}
|
|
1890
|
+
var digestValueNode = _findChild(refNode, "DigestValue");
|
|
1891
|
+
var expectedDigestB64 = _textContent(digestValueNode);
|
|
1892
|
+
if (!expectedDigestB64) {
|
|
1893
|
+
throw new AuthError("auth-saml/no-digest-value",
|
|
1894
|
+
"_verifyEmbeddedXmlDsig: Reference missing DigestValue");
|
|
1895
|
+
}
|
|
1896
|
+
// Recompute the digest over the root with Signature stripped
|
|
1897
|
+
// (enveloped-signature transform). Clone root + filter out
|
|
1898
|
+
// ds:Signature children, then canonicalize.
|
|
1899
|
+
var rootForDigest = structuredClone(root);
|
|
1900
|
+
rootForDigest.children = rootForDigest.children.filter(function (c) {
|
|
1901
|
+
if (c.type !== "element") return true;
|
|
1902
|
+
return c.name.split(":").pop() !== "Signature";
|
|
1903
|
+
});
|
|
1904
|
+
var canonical = c14n.canonicalize(rootForDigest);
|
|
1905
|
+
var actualDigest = nodeCrypto.createHash(digestAlgName).update(canonical).digest();
|
|
1906
|
+
if (!timingSafeEqual(Buffer.from(expectedDigestB64, "base64"), actualDigest)) {
|
|
1907
|
+
throw new AuthError("auth-saml/digest-mismatch",
|
|
1908
|
+
"_verifyEmbeddedXmlDsig: Reference DigestValue does not match canonicalized root " +
|
|
1909
|
+
"(signature-wrapping or tampered content)");
|
|
1910
|
+
}
|
|
1911
|
+
// Canonicalize SignedInfo + PQC-verify signature.
|
|
1912
|
+
var signedInfoCanonical = c14n.canonicalize(signedInfo);
|
|
1913
|
+
var sigValueNode = _findChild(sigNode, "SignatureValue");
|
|
1914
|
+
var sigB64 = sigValueNode ? _textContent(sigValueNode).replace(/\s+/g, "") : "";
|
|
1915
|
+
if (!sigB64) {
|
|
1916
|
+
throw new AuthError("auth-saml/no-signature-value",
|
|
1917
|
+
"_verifyEmbeddedXmlDsig: Signature missing SignatureValue");
|
|
1918
|
+
}
|
|
1919
|
+
var sigBytes = Buffer.from(sigB64, "base64");
|
|
1920
|
+
var ok = false;
|
|
1921
|
+
try { ok = sigAlgUrn.verify(sigBytes, signedInfoCanonical, idpVerifyKey); }
|
|
1922
|
+
catch (e) {
|
|
1923
|
+
throw new AuthError("auth-saml/sig-verify-threw",
|
|
1924
|
+
"_verifyEmbeddedXmlDsig: signature verify threw: " + ((e && e.message) || String(e)));
|
|
1925
|
+
}
|
|
1926
|
+
if (!ok) {
|
|
1927
|
+
throw new AuthError("auth-saml/bad-signature",
|
|
1928
|
+
"_verifyEmbeddedXmlDsig: embedded XMLDSig signature does not verify against idpVerifyKey");
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// ---- v0.10.16 SAML SLO signature-alg dispatch ----
|
|
1933
|
+
|
|
1934
|
+
function _sigAlgUrn(alg) {
|
|
1935
|
+
// PQC signers — framework-private experimental URIs. The `urn:`
|
|
1936
|
+
// prefix lives under `urn:blamejs:experimental:` so operators
|
|
1937
|
+
// grepping their IdP / SP logs immediately see the framework
|
|
1938
|
+
// ownership and know these are NOT IANA/W3C-registered. No IETF /
|
|
1939
|
+
// W3C XMLDSig assignment for ML-DSA exists yet; the IETF LAMPS WG
|
|
1940
|
+
// has open drafts (draft-ietf-lamps-x509-mldsa, -lamps-cms-mldsa)
|
|
1941
|
+
// but no XMLDSig URI registration. Once a registered URI exists,
|
|
1942
|
+
// we'll add it alongside and deprecate the experimental one.
|
|
1943
|
+
//
|
|
1944
|
+
// These URNs interop only with peers that share them out-of-band
|
|
1945
|
+
// (e.g. two SPs of the same vendor). Operators integrating with
|
|
1946
|
+
// real-world classical IdPs use the W3C XMLDSig URIs below.
|
|
1947
|
+
if (alg === "ml-dsa-65") {
|
|
1948
|
+
return {
|
|
1949
|
+
urn: "urn:blamejs:experimental:saml-sig-alg:ml-dsa-65",
|
|
1950
|
+
sign: function (bytes, sk) { return pqcSoftware.ml_dsa_65.sign(new Uint8Array(bytes), sk); },
|
|
1951
|
+
verify: function (sig, msg, pk) { return pqcSoftware.ml_dsa_65.verify(sig, msg, pk); },
|
|
1952
|
+
experimental: true,
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
if (alg === "ml-dsa-87") {
|
|
1956
|
+
return {
|
|
1957
|
+
urn: "urn:blamejs:experimental:saml-sig-alg:ml-dsa-87",
|
|
1958
|
+
sign: function (bytes, sk) { return pqcSoftware.ml_dsa_87.sign(new Uint8Array(bytes), sk); },
|
|
1959
|
+
verify: function (sig, msg, pk) { return pqcSoftware.ml_dsa_87.verify(sig, msg, pk); },
|
|
1960
|
+
experimental: true,
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
// Ed25519 — W3C XMLDSig URN registered in RFC 9231.
|
|
1964
|
+
if (alg === "ed25519") {
|
|
1965
|
+
return {
|
|
1966
|
+
urn: "http://www.w3.org/2021/04/xmldsig-more#ed25519",
|
|
1967
|
+
sign: function (bytes, sk) {
|
|
1968
|
+
var keyObj = (sk && typeof sk === "object" && sk.type === "private") ? sk
|
|
1969
|
+
: (typeof sk === "string" || (sk && sk.kty)) ? nodeCrypto.createPrivateKey(sk)
|
|
1970
|
+
: nodeCrypto.createPrivateKey({ key: Buffer.concat([
|
|
1971
|
+
Buffer.from("302e020100300506032b657004220420", "hex"), // allow:raw-byte-literal — Ed25519 PKCS#8 prefix
|
|
1972
|
+
Buffer.from(sk),
|
|
1973
|
+
]), format: "der", type: "pkcs8" });
|
|
1974
|
+
return nodeCrypto.sign(null, Buffer.from(bytes), keyObj);
|
|
1975
|
+
},
|
|
1976
|
+
verify: function (sig, msg, pk) {
|
|
1977
|
+
var keyObj = (pk && typeof pk === "object" && pk.type === "public") ? pk
|
|
1978
|
+
: (typeof pk === "string" || (pk && pk.kty)) ? nodeCrypto.createPublicKey(pk)
|
|
1979
|
+
: nodeCrypto.createPublicKey({ key: Buffer.concat([
|
|
1980
|
+
Buffer.from("302a300506032b6570032100", "hex"), // allow:raw-byte-literal — Ed25519 SPKI prefix
|
|
1981
|
+
Buffer.from(pk),
|
|
1982
|
+
]), format: "der", type: "spki" });
|
|
1983
|
+
return nodeCrypto.verify(null, Buffer.from(msg), keyObj, Buffer.from(sig));
|
|
1984
|
+
},
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
// Classical XMLDSig algorithms registered in W3C XMLDSig Core 1.1 /
|
|
1988
|
+
// RFC 4051. Keys are PEM-formatted strings or node:crypto KeyObject
|
|
1989
|
+
// instances. Operators integrating with real-world IdPs that
|
|
1990
|
+
// haven't moved to PQC use these — RSA-SHA-256 is by far the most
|
|
1991
|
+
// common signing algorithm on the public SAML IdP wire today.
|
|
1992
|
+
var classical = {
|
|
1993
|
+
"rsa-sha256": { urn: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", hash: "sha256" },
|
|
1994
|
+
"rsa-sha384": { urn: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", hash: "sha384" },
|
|
1995
|
+
"rsa-sha512": { urn: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", hash: "sha512" },
|
|
1996
|
+
"ecdsa-sha256": { urn: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", hash: "sha256", ec: true },
|
|
1997
|
+
"ecdsa-sha384": { urn: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384", hash: "sha384", ec: true },
|
|
1998
|
+
"ecdsa-sha512": { urn: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512", hash: "sha512", ec: true },
|
|
683
1999
|
};
|
|
2000
|
+
if (Object.prototype.hasOwnProperty.call(classical, alg)) {
|
|
2001
|
+
var spec = classical[alg];
|
|
2002
|
+
return {
|
|
2003
|
+
urn: spec.urn,
|
|
2004
|
+
sign: function (bytes, sk) {
|
|
2005
|
+
var keyObj = (sk && typeof sk === "object" && sk.type === "private") ? sk
|
|
2006
|
+
: nodeCrypto.createPrivateKey(sk);
|
|
2007
|
+
var opts2 = { key: keyObj };
|
|
2008
|
+
if (spec.ec) opts2.dsaEncoding = "der";
|
|
2009
|
+
return nodeCrypto.sign(spec.hash, Buffer.from(bytes), opts2);
|
|
2010
|
+
},
|
|
2011
|
+
verify: function (sig, msg, pk) {
|
|
2012
|
+
var keyObj = (pk && typeof pk === "object" && pk.type === "public") ? pk
|
|
2013
|
+
: nodeCrypto.createPublicKey(pk);
|
|
2014
|
+
var opts2 = { key: keyObj };
|
|
2015
|
+
if (spec.ec) opts2.dsaEncoding = "der";
|
|
2016
|
+
return nodeCrypto.verify(spec.hash, Buffer.from(msg), opts2, Buffer.from(sig));
|
|
2017
|
+
},
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
return null;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// Reverse lookup — SignatureMethod URI on the inbound wire → alg
|
|
2024
|
+
// shorthand for _sigAlgUrn dispatch. Used by _verifyEmbeddedXmlDsig
|
|
2025
|
+
// to pick the right verifier when an IdP signs with a classical alg.
|
|
2026
|
+
function _sigAlgFromUri(uri) {
|
|
2027
|
+
if (uri === "urn:blamejs:experimental:saml-sig-alg:ml-dsa-65") return "ml-dsa-65";
|
|
2028
|
+
if (uri === "urn:blamejs:experimental:saml-sig-alg:ml-dsa-87") return "ml-dsa-87";
|
|
2029
|
+
if (uri === "http://www.w3.org/2021/04/xmldsig-more#ed25519") return "ed25519";
|
|
2030
|
+
if (uri === "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256") return "rsa-sha256";
|
|
2031
|
+
if (uri === "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384") return "rsa-sha384";
|
|
2032
|
+
if (uri === "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512") return "rsa-sha512";
|
|
2033
|
+
if (uri === "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256") return "ecdsa-sha256";
|
|
2034
|
+
if (uri === "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384") return "ecdsa-sha384";
|
|
2035
|
+
if (uri === "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512") return "ecdsa-sha512";
|
|
2036
|
+
return null;
|
|
684
2037
|
}
|
|
685
2038
|
|
|
686
2039
|
/**
|