Problem
node-saml verifies every signature through its own wrapper around SignedXml, getVerifiedXml(fullXml, currentNode, pemFiles): xml.ts#L60-L158. The wrapper adds checks that xml-crypto doesn't make, then returns the bytes the signature covers.
That wrapper is reachable today as @node-saml/node-saml/lib/xml, but it was never public API. node-saml's next major closes lib/ (node-saml/node-saml#458), and it won't promote the wrapper to its root (node-saml/node-saml#441). Anyone verifying signed XML outside a SAML flow therefore needs a replacement. SignedXml's verification path isn't a good destination, because #517 and #519 are replacing it with XmlDSigVerifier. node-saml/node-saml#461 is holding its migration note until it can point there.
At #519's head (e1986cb), the verifier can't yet express what node-saml's wrapper enforces:
| Check |
node-saml's getVerifiedXml |
XmlDSigVerifier (#519) |
Exactly one Signature among the element's children |
yes |
Only "one Signature in the whole document", and only when signatureNode is omitted |
The signature has exactly one Reference |
yes |
No. signedReferences returns every reference, so a caller reading [0] gets whichever came first |
That Reference resolves to the signature's own parent |
yes |
No |
The URI is a same-document reference (#id) |
yes |
No (#594) |
| Transform limit |
at most 2 per signature |
maxTransforms, per reference, default 4 |
No quote in the URI; the ID unique in the document |
yes |
yes, from SignedXml |
| Several pinned certificates, for IdP key rollover |
tries each |
publicCert takes one key. truststore takes several, but only through getCertFromKeyInfo, i.e. only when the document's KeyInfo names the certificate |
The parent check is the one that matters most. node-saml/node-saml#460 has a fixture that shows why. Take an IdP that signs only its assertions. Move an assertion's Signature, unchanged, from the assertion up to the Response. The signature still verifies, because its reference still resolves to the assertion. Without the parent check, the unsigned Response then passes as signed. signedReferences returns the assertion's bytes, which are authentic, but the caller asked whether the Response was signed. Nothing in the result tells it the answer was "no".
Proposal
Give XmlDSigVerifier a way to verify the enveloped signature of a given element, returning only that element's signed bytes. That is, given an element, verify that:
- exactly one
Signature is a direct child of the element;
- that signature has exactly one
Reference;
- its
URI is # followed by an ID that resolves to exactly one element, the signature's parent;
- the transforms stay within
maxTransforms;
then return the bytes that reference covers. Each failure should throw its own message, so that a caller can tell a misconfiguration from an attack.
Also let a CertificateKeySelector take several pinned keys, and try each one, without reading KeyInfo. A SAML service provider has to accept an IdP's old and new certificates during a rollover, and must not let the document pick its own key.
XmlDSigVerifier is new API, so item 3 can reject a bare URI from the start. #594 needs a major only because it changes SignedXml. This belongs in 7.0 with #519.
When it ships, node-saml/node-saml#461 can send deep importers of getVerifiedXml() and validateSignature() to it. node-saml's own getVerifiedXml() could then become a thin call to it.
🤖 Generated with Claude Code
Problem
node-saml verifies every signature through its own wrapper around
SignedXml,getVerifiedXml(fullXml, currentNode, pemFiles): xml.ts#L60-L158. The wrapper adds checks that xml-crypto doesn't make, then returns the bytes the signature covers.That wrapper is reachable today as
@node-saml/node-saml/lib/xml, but it was never public API. node-saml's next major closeslib/(node-saml/node-saml#458), and it won't promote the wrapper to its root (node-saml/node-saml#441). Anyone verifying signed XML outside a SAML flow therefore needs a replacement.SignedXml's verification path isn't a good destination, because #517 and #519 are replacing it withXmlDSigVerifier. node-saml/node-saml#461 is holding its migration note until it can point there.At #519's head (e1986cb), the verifier can't yet express what node-saml's wrapper enforces:
getVerifiedXmlXmlDSigVerifier(#519)Signatureamong the element's childrenSignaturein the whole document", and only whensignatureNodeis omittedReferencesignedReferencesreturns every reference, so a caller reading[0]gets whichever came firstReferenceresolves to the signature's own parentURIis a same-document reference (#id)maxTransforms, per reference, default 4URI; the ID unique in the documentSignedXmlpublicCerttakes one key.truststoretakes several, but only throughgetCertFromKeyInfo, i.e. only when the document'sKeyInfonames the certificateThe parent check is the one that matters most. node-saml/node-saml#460 has a fixture that shows why. Take an IdP that signs only its assertions. Move an assertion's
Signature, unchanged, from the assertion up to theResponse. The signature still verifies, because its reference still resolves to the assertion. Without the parent check, the unsignedResponsethen passes as signed.signedReferencesreturns the assertion's bytes, which are authentic, but the caller asked whether theResponsewas signed. Nothing in the result tells it the answer was "no".Proposal
Give
XmlDSigVerifiera way to verify the enveloped signature of a given element, returning only that element's signed bytes. That is, given an element, verify that:Signatureis a direct child of the element;Reference;URIis#followed by an ID that resolves to exactly one element, the signature's parent;maxTransforms;then return the bytes that reference covers. Each failure should throw its own message, so that a caller can tell a misconfiguration from an attack.
Also let a
CertificateKeySelectortake several pinned keys, and try each one, without readingKeyInfo. A SAML service provider has to accept an IdP's old and new certificates during a rollover, and must not let the document pick its own key.XmlDSigVerifieris new API, so item 3 can reject a bareURIfrom the start. #594 needs a major only because it changesSignedXml. This belongs in 7.0 with #519.When it ships, node-saml/node-saml#461 can send deep importers of
getVerifiedXml()andvalidateSignature()to it. node-saml's owngetVerifiedXml()could then become a thin call to it.🤖 Generated with Claude Code