Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue with Signature Verification When 'Transforms' Tag is Absent in 'Reference' Element #378

Closed
DiegoMajluf opened this issue Aug 18, 2023 · 5 comments · Fixed by #379
Closed

Comments

@DiegoMajluf
Copy link

DiegoMajluf commented Aug 18, 2023

Good Day

I have this signture on a XML Document that it content has been deliberately modified to fail the verification

<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
	<SignedInfo>
		<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
		<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
		<Reference URI="#T33F19384429">
			<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
			<DigestValue>uU+OaebbefWVOIXXP4q8FV7F9JQ=</DigestValue>
		</Reference>
	</SignedInfo>
	<SignatureValue>q7ENUbjtG9WauSnjDM7jwDePwnQVVeQDrsaL6ZitDLfJ+dtPi833JubqjD4on8TU+xSDAjVHYV9s
EByiiOvWLw981QICjG3g+N8AA2xpjx8nZhbZDIXvkFKmFsaII651Te/Pe+qYH6ONUYWs6Hf9cCnx
933q9A/YanGR4bRfWhyxMXyRKe+rysDCOZA/7gx2jclnaDkGVcC4Cfe28cxaHVw9xDMMZk0MEroQ
YvkJ1BttKHx8BfjIaT17aands2rW6meUH5etkMEoET19QCZO4Ez/7PI7Xr379azgrqX9QuBr+Lg9
nIGjS0Iz7E7AUgaHz6fErYug7P4/PEcNN8uAZQ==</SignatureValue>
.....
</Signature>

As the Reference tag does not contain a Transforms tag, when I apply the checkSignature function to that document, the result is true. However, this is due to the function performing verification solely on the signature's digest, without recalculating the digest itself.

To reproduce the problem, take any verified document that doesn't has the Transforms tag, and then make any modification to the content that would cause the digest calculation to fail to match the original. You'll observe that the signature is still verified.

Let me know if I need to provide more details

@srd90
Copy link

srd90 commented Aug 20, 2023

Disclaimer: I'm not too familiar with XML signature specification. This comment is just a result
of trying few things (starting with trying to come up with test material hinted by @DiegoMajluf ) and writing down some findings. It is up to someone else to decide what to do with these findings.


First of all there seems to be implementation difference between 3.2.0 and 4.1.0 in loadReference function.

It seems that quite a lot of code is at 3.2.0 version outside of this if statement:

nodes = utils.findChilds(ref, "Transforms");
if (nodes.length != 0) {
var transformsNode = nodes[0];
var transformsAll = utils.findChilds(transformsNode, "Transform");
for (var t in transformsAll) {
if (!transformsAll.hasOwnProperty(t)) continue;
var trans = transformsAll[t];
transforms.push(utils.findAttr(trans, "Algorithm").value);
}
var inclusiveNamespaces = utils.findChilds(trans, "InclusiveNamespaces");
if (inclusiveNamespaces.length > 0) {
//Should really only be one prefix list, but maybe there's some circumstances where more than one to lets handle it
for (var i = 0; i < inclusiveNamespaces.length; i++) {
if (inclusiveNamespacesPrefixList) {
inclusiveNamespacesPrefixList =
inclusiveNamespacesPrefixList + " " + inclusiveNamespaces[i].getAttribute("PrefixList");
} else {
inclusiveNamespacesPrefixList = inclusiveNamespaces[i].getAttribute("PrefixList");
}
}
}
}

i.e. these lines are NOT inside that if statement block at version 3.2.0:

var hasImplicitTransforms =
Array.isArray(this.implicitTransforms) && this.implicitTransforms.length > 0;
if (hasImplicitTransforms) {
this.implicitTransforms.forEach(function (t) {
transforms.push(t);
});
}
/**
* DigestMethods take an octet stream rather than a node set. If the output of the last transform is a node set, we
* need to canonicalize the node set to an octet stream using non-exclusive canonicalization. If there are no
* transforms, we need to canonicalize because URI dereferencing for a same-document reference will return a node-set.
* See:
* https://www.w3.org/TR/xmldsig-core1/#sec-DigestMethod
* https://www.w3.org/TR/xmldsig-core1/#sec-ReferenceProcessingModel
* https://www.w3.org/TR/xmldsig-core1/#sec-Same-Document
*/
if (
transforms.length === 0 ||
transforms[transforms.length - 1] === "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
) {
transforms.push("http://www.w3.org/TR/2001/REC-xml-c14n-20010315");
}
this.addReference(
null,
transforms,
digestAlgo,
utils.findAttr(ref, "URI").value,
digestValue,
inclusiveNamespacesPrefixList,
false
);

4.1.0 version contains all of those inside aforementioned if statement block

nodes = utils.findChildren(ref, "Transforms");
if (nodes.length !== 0) {
const transformsNode = nodes[0];
const transformsAll = utils.findChildren(transformsNode, "Transform");
for (const transform of transformsAll) {
const transformAttr = utils.findAttr(transform, "Algorithm");
if (transformAttr) {
transforms.push(transformAttr.value);
}
}
// This is a little strange, we are looking for children of the last child of `transformsNode`
const inclusiveNamespaces = utils.findChildren(
transformsAll[transformsAll.length - 1],
"InclusiveNamespaces",
);
if (utils.isArrayHasLength(inclusiveNamespaces)) {
// Should really only be one prefix list, but maybe there's some circumstances where more than one to let's handle it
inclusiveNamespacesPrefixList = inclusiveNamespaces
.flatMap((namespace) => (namespace.getAttribute("PrefixList") ?? "").split(" "))
.filter((value) => value.length > 0);
}
if (utils.isArrayHasLength(this.implicitTransforms)) {
this.implicitTransforms.forEach(function (t) {
transforms.push(t);
});
}
/**
* DigestMethods take an octet stream rather than a node set. If the output of the last transform is a node set, we
* need to canonicalize the node set to an octet stream using non-exclusive canonicalization. If there are no
* transforms, we need to canonicalize because URI dereferencing for a same-document reference will return a node-set.
* See:
* https://www.w3.org/TR/xmldsig-core1/#sec-DigestMethod
* https://www.w3.org/TR/xmldsig-core1/#sec-ReferenceProcessingModel
* https://www.w3.org/TR/xmldsig-core1/#sec-Same-Document
*/
if (
transforms.length === 0 ||
transforms[transforms.length - 1] ===
"http://www.w3.org/2000/09/xmldsig#enveloped-signature"
) {
transforms.push("http://www.w3.org/TR/2001/REC-xml-c14n-20010315");
}
this.addReference({
transforms,
digestAlgorithm: digestAlgo,
uri: utils.findAttr(ref, "URI")?.value,
digestValue,
inclusiveNamespacesPrefixList,
isEmptyUri: false,
});
}

Looks something that might not have been planned change of behaviour.


About this actual GH issue.

Since @DiegoMajluf did not provide concrete examples here is script that produces two documents.

  1. /tmp/xml-crypto-issue-378/signed_document.xml
    • does not have Transforms element
  2. /tmp/xml-crypto-issue-378/tampered_signed_document.xml
    • is a result for (1) after changing with sed one string in order to have tampered signed content

actual script to generate that material locally is:

#!/bin/bash

# prepare test material to investigate issue
# https://github.com/node-saml/xml-crypto/issues/378

# client.pem        == https://github.com/node-saml/xml-crypto/blob/v3.2.0/test/static/client.pem
CLIENT_PEM=/tmp/xml-crypto-issue-378/client.pem
# client_public.pem == https://github.com/node-saml/xml-crypto/blob/v3.2.0/test/static/client_public.pem
CLIENT_PUBLIC_PEM=/tmp/xml-crypto-issue-378/client_public.pem

KEYSTORE=/tmp/xml-crypto-issue-378/keystore.p12
SIGNED_DOCUMENT_TEMPLATE=/tmp/xml-crypto-issue-378/signed_document_template.xml
SIGNED_DOCUMENT=/tmp/xml-crypto-issue-378/signed_document.xml
TAMPERED_SIGNED_DOCUMENT=/tmp/xml-crypto-issue-378/tampered_signed_document.xml

# crete p12 keystore to be used with xmlsec1 to create signed
# document without Transforms element
openssl \
  pkcs12 \
  -nodes \
  -export \
  -out $KEYSTORE \
  -inkey $CLIENT_PEM \
  -in $CLIENT_PUBLIC_PEM \
  -passout pass:password

echo -n '<library><book ID="bookid"><name>some text</name></book><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#bookid"><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue></DigestValue></Reference></SignedInfo><SignatureValue/></Signature></library>' \
> $SIGNED_DOCUMENT_TEMPLATE

echo -e "\n\n----------------------------------"
echo "Created template to be used to generate signed document"
echo "to $SIGNED_DOCUMENT_TEMPLATE -file"
echo "NOTE: it does not containt Transformations element"
echo "Pretty printed version looks like this:"
cat $SIGNED_DOCUMENT_TEMPLATE | xmllint --format -

echo "----------------------------------"

echo -e "\n\nSign aforementioned template"
xmlsec1 \
  --sign \
  --id-attr:ID book \
  --output $SIGNED_DOCUMENT \
  --pkcs12 $KEYSTORE \
  --pwd password \
  $SIGNED_DOCUMENT_TEMPLATE

echo "Signed document:"
cat $SIGNED_DOCUMENT

echo -e "\nPretty printed version of signed document ( $SIGNED_DOCUMENT ):"
cat $SIGNED_DOCUMENT | xmllint --format -
echo "----------------------------------"

echo "Create tampered signed document:"
cat $SIGNED_DOCUMENT | sed -e 's/some text/some tampered text/' > $TAMPERED_SIGNED_DOCUMENT
cat $TAMPERED_SIGNED_DOCUMENT
echo -e "\nPretty printed version of tampered signed document ( $TAMPERED_SIGNED_DOCUMENT )"
cat $TAMPERED_SIGNED_DOCUMENT | xmllint --format -
echo "----------------------------------"

echo "use these files when testing xml-crypto 3.2.0 and 4.1.0 validation functionality"
echo "         signed document file: $SIGNED_DOCUMENT"
echo "tampered signed document file: $TAMPERED_SIGNED_DOCUMENT"
echo "it is important that you use aforementioned files as is from the disk instead of"
echo "copy pasting those in order to prevent any additional formatting being made by"
echo "echo IDEs etc."

For the sake of readability of this comment here are pretty printed versions of key bits of that script. DO NOT use pretty printed versions for actual issue debugging.

Input for xmlsec1 (aka "template"):

<?xml version="1.0"?>
<library>
  <book ID="bookid">
    <name>some text</name>
  </book>
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
      <SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
      <Reference URI="#bookid">
        <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
        <DigestValue/>
      </Reference>
    </SignedInfo>
    <SignatureValue/>
  </Signature>
</library>

Signed document produced by xmlsec1:

<?xml version="1.0"?>
<library>
  <book ID="bookid">
    <name>some text</name>
  </book>
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
      <SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
      <Reference URI="#bookid">
        <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
        <DigestValue>LsMoqo1d6Sqh8DKLp00MK0fSBDA=</DigestValue>
      </Reference>
    </SignedInfo>
    <SignatureValue>OR1SYcyU18qELj+3DX/bW/r5DqueuyPAnNFEh3hNKFaj8ZKLB/mdsR9w8GDBCmZ2
lsCTEvJqWC37oF8rm2eBSonNbdBnA+TM6Y22C8rffVzaoM3zpNoeWMH2LwFmpdKB
UXOMWVExEaz/s4fOcyv1ajVuk42I3nl0xcD95+i7PjY=</SignatureValue>
  </Signature>
</library>

Signed document which content has been tampered (some text is replaced with sed to some tampered text):

<?xml version="1.0"?>
<library>
  <book ID="bookid">
    <name>some tampered text</name>
  </book>
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
    <SignedInfo>
      <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
      <SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
      <Reference URI="#bookid">
        <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
        <DigestValue>LsMoqo1d6Sqh8DKLp00MK0fSBDA=</DigestValue>
      </Reference>
    </SignedInfo>
    <SignatureValue>OR1SYcyU18qELj+3DX/bW/r5DqueuyPAnNFEh3hNKFaj8ZKLB/mdsR9w8GDBCmZ2
lsCTEvJqWC37oF8rm2eBSonNbdBnA+TM6Y22C8rffVzaoM3zpNoeWMH2LwFmpdKB
UXOMWVExEaz/s4fOcyv1ajVuk42I3nl0xcD95+i7PjY=</SignatureValue>
  </Signature>
</library>

Now that we have some test material which might be close enought something that issue reported had lets proceed to test what happens when these are validated with 3.2.0 and 4.1.0.


Case 3.2.0:

// https://github.com/node-saml/xml-crypto/issues/378
//
// test with xml-crypto 3.2.0

// npm init
// npm insall [email protected]

const select = require("xml-crypto").xpath,
  dom = require("@xmldom/xmldom").DOMParser,
  SignedXml = require("xml-crypto").SignedXml,
  FileKeyInfo = require("xml-crypto").FileKeyInfo,
  fs = require("fs");

// https://github.com/node-saml/xml-crypto/blob/v3.2.0/test/static/client_public.pem
const client_public_pem = "/tmp/xml-crypto-issue-378/client_public.pem";

// these files are produced by separaed shell script
const signed_xml = fs.readFileSync("/tmp/xml-crypto-issue-378/signed_document.xml").toString();
const tampered_signed_xml = fs.readFileSync("/tmp/xml-crypto-issue-378/tampered_signed_document.xml").toString();

function validateXml(xml, key) {
  const doc = new dom().parseFromString(xml);
  const signature = select(
    doc,
    "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']"
  )[0];
  const sig = new SignedXml();
  sig.keyInfoProvider = new FileKeyInfo(client_public_pem);
  sig.loadSignature(signature.toString());
  const res = sig.checkSignature(xml);
  if (!res) console.log(sig.validationErrors);
  return res;
}

console.log("Test signed document signature validation.");
if (validateXml(signed_xml)) {
  console.log("Signature was valid")
} else {
  console.log("signature validation failed.")
}

console.log("Test tampered document signature validation ( IT MUST return validation error)");
if (validateXml(tampered_signed_xml)) {
  console.log("Signature was valid...it should not have been valid!!!!!")
} else {
  console.log("signature validation failed....as it should have...")
}

Output:

Test signed document signature validation.
Signature was valid
Test tampered document signature validation ( IT MUST return validation error)
[
  'invalid signature: for uri #bookid calculated digest is WyqD++sYNPkb4d9XUrGqn1c8xqo= but the xml to validate supplies digest LsMoqo1d6Sqh8DKLp00MK0fSBDA='
]
signature validation failed....as it should have...

Case 4.1.0

// https://github.com/node-saml/xml-crypto/issues/378
//
// test with xml-crypto 4.1.0

// npm init
// npm insall [email protected]

const xpath = require("xpath"),
  dom = require("@xmldom/xmldom").DOMParser,
  SignedXml = require("xml-crypto").SignedXml,
  FileKeyInfo = require("xml-crypto").FileKeyInfo,
  fs = require("fs");

// https://github.com/node-saml/xml-crypto/blob/v3.2.0/test/static/client_public.pem
const client_public_pem = fs.readFileSync("/tmp/xml-crypto-issue-378/client_public.pem").toString();

const signed_xml = fs.readFileSync("/tmp/xml-crypto-issue-378/signed_document.xml").toString();
const tampered_signed_xml = fs.readFileSync("/tmp/xml-crypto-issue-378/tampered_signed_document.xml").toString();

function validateXml(xml, key) {
  const doc = new dom().parseFromString(xml);
  const signature = xpath.select(
    "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']",
    doc
  )[0];
  const sig = new SignedXml();
  sig.publicCert = client_public_pem;
  sig.loadSignature(signature.toString());
  const res = sig.checkSignature(xml);
  if (!res) console.log(sig.validationErrors);
  return res;
}

console.log("Test signed document signature validation.");
if (validateXml(signed_xml)) {
  console.log("Signature was valid")
} else {
  console.log("signature validation failed.")
}

console.log("Test tampered document signature validation ( IT MUST return validation error)");
if (validateXml(tampered_signed_xml)) {
  console.log("Signature was valid...it should not have been valid!!!!!")
} else {
  console.log("signature validation failed....as it should have...")
}

Output:

Test signed document signature validation.
Signature was valid
Test tampered document signature validation ( IT MUST return validation error)
Signature was valid...it should not have been valid!!!!!

This issue looks as if current version ( 4.x ) of xml-crypto might have signature validation bypass vulnerability (NOTE: I do not know how validation must work if Transforms is missing).

ping @LoneRifle @cjbarth @djaqua

@srd90
Copy link

srd90 commented Aug 21, 2023

Code in this comment is written against xml-crypto version 2e32d502c99a08936c73885405c8b5a4e217dfce which is content of the master branch at the time of writing this.


Here are two test cases with documents with Signature without Transforms element.

diff --git a/test/signature-unit-tests.spec.ts b/test/signature-unit-tests.spec.ts
index f1cbe3f..8a185f8 100644
--- a/test/signature-unit-tests.spec.ts
+++ b/test/signature-unit-tests.spec.ts
@@ -877,6 +877,10 @@ describe("Signature unit tests", function () {
     passValidSignature("./test/static/valid_signature_with_unused_prefixes.xml");
   });
 
+  it("verifies valid signature without transforms element", function () {
+    passValidSignature("./test/static/valid_signature_without_transforms_element.xml");
+  });
+
   it("fails invalid signature - signature value", function () {
     failInvalidSignature("./test/static/invalid_signature - signature value.xml");
   });
@@ -918,6 +922,10 @@ describe("Signature unit tests", function () {
     );
   });
 
+  it("fails invalid signature without transforms element", function () {
+    failInvalidSignature("./test/static/invalid_signature_without_transforms_element.xml");
+  });
+
   it("allow empty reference uri when signing", function () {
     const xml = "<root><x /></root>";
     const sig = new SignedXml();
diff --git a/test/static/invalid_signature_without_transforms_element.xml b/test/static/invalid_signature_without_transforms_element.xml
new file mode 100644
index 0000000..9c48372
--- /dev/null
+++ b/test/static/invalid_signature_without_transforms_element.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<library><book ID="bookid"><name>some tampered text</name></book><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#bookid"><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>LsMoqo1d6Sqh8DKLp00MK0fSBDA=</DigestValue></Reference></SignedInfo><SignatureValue>OR1SYcyU18qELj+3DX/bW/r5DqueuyPAnNFEh3hNKFaj8ZKLB/mdsR9w8GDBCmZ2
+lsCTEvJqWC37oF8rm2eBSonNbdBnA+TM6Y22C8rffVzaoM3zpNoeWMH2LwFmpdKB
+UXOMWVExEaz/s4fOcyv1ajVuk42I3nl0xcD95+i7PjY=</SignatureValue></Signature></library>
diff --git a/test/static/valid_signature_without_transforms_element.xml b/test/static/valid_signature_without_transforms_element.xml
new file mode 100644
index 0000000..5d22a29
--- /dev/null
+++ b/test/static/valid_signature_without_transforms_element.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0"?>
+<library><book ID="bookid"><name>some text</name></book><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"><SignedInfo><CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><Reference URI="#bookid"><DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><DigestValue>LsMoqo1d6Sqh8DKLp00MK0fSBDA=</DigestValue></Reference></SignedInfo><SignatureValue>OR1SYcyU18qELj+3DX/bW/r5DqueuyPAnNFEh3hNKFaj8ZKLB/mdsR9w8GDBCmZ2
+lsCTEvJqWC37oF8rm2eBSonNbdBnA+TM6Y22C8rffVzaoM3zpNoeWMH2LwFmpdKB
+UXOMWVExEaz/s4fOcyv1ajVuk42I3nl0xcD95+i7PjY=</SignatureValue></Signature></library>

Result:

  • fails invalid signature without transforms element fails
  • verifies valid signature without transforms element passes, BUT...

...xml-crypto considers both cases valid and why it does so is because:

  1. This line is never executed
    this.addReference({
  2. and because there are no refenrences this code block return always true (i.e. it doesn't iterate anything):
    validateReferences(doc: Document) {
    for (const ref of this.references) {
    if (!this.validateReference(ref, doc)) {
    return false;
    }
    }
    return true;
    }

Next step is to try to re-introduce < 4.0.0 versions control flow to loadReference function (dunno if this was too naive fix. I did not read code too carefully):

diff --git a/src/signed-xml.ts b/src/signed-xml.ts
index 5511769..629f577 100644
--- a/src/signed-xml.ts
+++ b/src/signed-xml.ts
@@ -596,38 +596,40 @@ export class SignedXml {
           .filter((value) => value.length > 0);
       }
 
-      if (utils.isArrayHasLength(this.implicitTransforms)) {
-        this.implicitTransforms.forEach(function (t) {
-          transforms.push(t);
-        });
-      }
-
-      /**
-       * DigestMethods take an octet stream rather than a node set. If the output of the last transform is a node set, we
-       * need to canonicalize the node set to an octet stream using non-exclusive canonicalization. If there are no
-       * transforms, we need to canonicalize because URI dereferencing for a same-document reference will return a node-set.
-       * @see:
-       * https://www.w3.org/TR/xmldsig-core1/#sec-DigestMethod
-       * https://www.w3.org/TR/xmldsig-core1/#sec-ReferenceProcessingModel
-       * https://www.w3.org/TR/xmldsig-core1/#sec-Same-Document
-       */
-      if (
-        transforms.length === 0 ||
-        transforms[transforms.length - 1] ===
-          "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
-      ) {
-        transforms.push("http://www.w3.org/TR/2001/REC-xml-c14n-20010315");
-      }
+    }
 
-      this.addReference({
-        transforms,
-        digestAlgorithm: digestAlgo,
-        uri: xpath.isElement(refNode) ? utils.findAttr(refNode, "URI")?.value : undefined,
-        digestValue,
-        inclusiveNamespacesPrefixList,
-        isEmptyUri: false,
+    if (utils.isArrayHasLength(this.implicitTransforms)) {
+      this.implicitTransforms.forEach(function (t) {
+        transforms.push(t);
       });
     }
+
+    /**
+     * DigestMethods take an octet stream rather than a node set. If the output of the last transform is a node set, we
+     * need to canonicalize the node set to an octet stream using non-exclusive canonicalization. If there are no
+     * transforms, we need to canonicalize because URI dereferencing for a same-document reference will return a node-set.
+     * @see:
+     * https://www.w3.org/TR/xmldsig-core1/#sec-DigestMethod
+     * https://www.w3.org/TR/xmldsig-core1/#sec-ReferenceProcessingModel
+     * https://www.w3.org/TR/xmldsig-core1/#sec-Same-Document
+     */
+    if (
+      transforms.length === 0 ||
+      transforms[transforms.length - 1] ===
+        "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
+    ) {
+      transforms.push("http://www.w3.org/TR/2001/REC-xml-c14n-20010315");
+    }
+
+    this.addReference({
+      transforms,
+      digestAlgorithm: digestAlgo,
+      uri: xpath.isElement(refNode) ? utils.findAttr(refNode, "URI")?.value : undefined,
+      digestValue,
+      inclusiveNamespacesPrefixList,
+      isEmptyUri: false,
+    });
+
   }
 
   /**

Result:

  • fails invalid signature without transforms element passes
  • verifies valid signature without transforms element fails

Actually both fails due same reason (i.e. reason is not what one would expect) and reason is that this function

getCanonXml(
transforms: Reference["transforms"],
node: Node,
options: CanonicalizationOrTransformationAlgorithmProcessOptions = {},
) {
options.defaultNsForPrefix = options.defaultNsForPrefix ?? SignedXml.defaultNsForPrefix;
options.signatureNode = this.signatureNode;
const canonXml = node.cloneNode(true); // Deep clone
let transformedXml: string = canonXml.toString();
transforms.forEach((transformName) => {
const transform = this.findCanonicalizationAlgorithm(transformName);
transformedXml = transform.process(canonXml, options).toString();
//TODO: currently transform.process may return either Node or String value (enveloped transformation returns Node, exclusive-canonicalization returns String).
//This either needs to be more explicit in the API, or all should return the same.
//exclusive-canonicalization returns String since it builds the Xml by hand. If it had used xmldom it would incorrectly minimize empty tags
//to <x/> instead of <x></x> and also incorrectly handle some delicate line break issues.
//enveloped transformation returns Node since if it would return String consider this case:
//<x xmlns:p='ns'><p:y/></x>
//if only y is the node to sign then a string would be <p:y/> without the definition of the p namespace. probably xmldom toString() should have added it.
});
return transformedXml;
}

ends up returning incorrectly(?) canonicalized xml.
In case of verifies valid signature without transforms element XML should look like this:

<book ID="bookid"><name>some text</name></book>

but getCanonXml returns:

<book ID="bookid"><name></name></book>

and digest is calculated from incorrect content here:

const canonXml = this.getCanonReferenceXml(doc, ref, elem);
const hash = this.findHashAlgorithm(ref.digestAlgorithm);
const digest = hash.getHash(canonXml);

(fails invalid signature without transforms element ends up to failure due same reason...i.e. getCanonXml function returning XML without text node and not because text node would have had text some tampered text)

FWIW,
xmlsec1 (which was used to produce signed document) calculated sha1 LsMoqo1d6Sqh8DKLp00MK0fSBDA= for canonicalized document (<book ID="bookid"><name>some text</name></book>) and one gets same hash for that from xml-crypto's sha1 implementation. I.e. either xmlsec1 is applying canonicalization incorrectly or xml-crypto's c14n implementation has a bug (but since xml-crypto 3.2.0 seems to handle this correctly I would say that there is a bug at 4.1.0 version. NOTE: I haven't (yet) step debugged 3.2.0).

console.log(
  require('crypto')
    .createHash("sha1")
   .update('<book ID="bookid"><name>some text</name></book>', "utf8")
   .digest("base64")
);
// output: LsMoqo1d6Sqh8DKLp00MK0fSBDA=

Reason for missing text node might be this/these
From current master:

if (xpath.isComment(node)) {
return this.renderComment(node);
}
if (xpath.isComment(node)) {
return utils.encodeSpecialCharactersInText(node.data);
}

if (xpath.isComment(node)) {
return this.renderComment(node);
}
if (node.data) {
return utils.encodeSpecialCharactersInText(node.data);
}

From 3.2.0:

if (node.nodeType === 8) {
return this.renderComment(node);
}
if (node.data) {
return utils.encodeSpecialCharactersInText(node.data);
}

if (node.nodeType === 8) {
return this.renderComment(node);
}
if (node.data) {
return utils.encodeSpecialCharactersInText(node.data);
}

i.e. at least current master version's c14n-canonicalization has incorrect if statement. Dunno if any of those would handle CDATA correctly.


Side note:

Is this program logic correct?

const canonXml = node.cloneNode(true); // Deep clone
let transformedXml: string = canonXml.toString();
transforms.forEach((transformName) => {
const transform = this.findCanonicalizationAlgorithm(transformName);
transformedXml = transform.process(canonXml, options).toString();
//TODO: currently transform.process may return either Node or String value (enveloped transformation returns Node, exclusive-canonicalization returns String).
//This either needs to be more explicit in the API, or all should return the same.
//exclusive-canonicalization returns String since it builds the Xml by hand. If it had used xmldom it would incorrectly minimize empty tags
//to <x/> instead of <x></x> and also incorrectly handle some delicate line break issues.
//enveloped transformation returns Node since if it would return String consider this case:
//<x xmlns:p='ns'><p:y/></x>
//if only y is the node to sign then a string would be <p:y/> without the definition of the p namespace. probably xmldom toString() should have added it.
});
return transformedXml;

AFAIK it applies potentially multiple transforms so that input is always original (canonXml) instead of output of previous transform operation (transformedXml).

From XML Signature Syntax and Processing Version 1.1 https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-Transforms

... The optional Transforms element contains an ordered list of Transform elements; these describe how the signer obtained the data object that was digested. The output of each Transform serves as input to the next Transform. The input to the first Transform is the result of dereferencing the URI attribute of the Reference element. The output from the last Transform is the input for the DigestMethod algorithm. ...

I do not know if that version of spec is correct source to look things up. There is also XML Signature Syntax and Processing Version 2.0 which has following statements https://www.w3.org/TR/xmldsig-core2/#sec-Transforms and https://www.w3.org/TR/xmldsig-core2/#sec-TransformsProcessingModel

Same program block from 3.2.0 version:

xml-crypto/lib/signed-xml.js

Lines 994 to 1009 in 777e157

var canonXml = node.cloneNode(true); // Deep clone
for (var t in transforms) {
if (!transforms.hasOwnProperty(t)) continue;
var transform = this.findCanonicalizationAlgorithm(transforms[t]);
canonXml = transform.process(canonXml, options);
//TODO: currently transform.process may return either Node or String value (enveloped transformation returns Node, exclusive-canonicalization returns String).
//This either needs to be more explicit in the API, or all should return the same.
//exclusive-canonicalization returns String since it builds the Xml by hand. If it had used xmldom it would incorrectly minimize empty tags
//to <x/> instead of <x></x> and also incorrectly handle some delicate line break issues.
//enveloped transformation returns Node since if it would return String consider this case:
//<x xmlns:p='ns'><p:y/></x>
//if only y is the node to sign then a string would be <p:y/> without the definition of the p namespace. probably xmldom toString() should have added it.
}
return canonXml.toString();


Disclaimer: purpose of this comment was to share some additional findings after I looked around out of curiosity. I do not know how valid these findings are.

@cjbarth
Copy link
Contributor

cjbarth commented Aug 22, 2023

Is this program logic correct?

Some preliminary testing I've done indicates that it isn't correct, but I don't have a test case for it yet.

As for the other item reported here, I've put up #379 to address it. Thanks for your thorough analysis @srd90.

@srd90
Copy link

srd90 commented Aug 22, 2023

@DiegoMajluf you bumped into and reported this issue (which escalated little bit) in the first place. Consider reviewing / testing PR's ( #379 and #380 ) that @cjbarth introduced with material you use.

Original issue is present in published versions [ 4.0.0 , 4.1.0 ]. Additional finding (incorrect C14N implementation) was introduced after 4.1.0 to master. What I'm trying to say is that if/when you bumped into this issue in production or in some E2E test env it means that there is good chance that signature bypass issue has ended up to production at some (other) place and maybe there should be CVE for this.

@DiegoMajluf
Copy link
Author

@srd90, thanks for your superb work on this issue.

The issue with the skip of digest calculation seems to be rectified. However, at first glance, I'm still experiencing some problems with the canonicalization. So, allow me to verify a little more thoroughly with my testing documents

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants