@blamejs/core 0.10.15 → 0.11.1

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/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 { generateToken, timingSafeEqual } = require("../crypto");
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
- if (_attr(sc, "Method") !== "urn:oasis:names:tc:SAML:2.0:cm:bearer") continue;
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-bearer",
582
- "verifyResponse: no Bearer SubjectConfirmation passed time/recipient checks");
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: buildAuthnRequest,
679
- verifyResponse: verifyResponse,
680
- metadata: metadata,
681
- entityId: opts.entityId,
682
- idpEntityId: opts.idpEntityId,
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
  /**