Skip to content

SignedInfo ancestor-namespace resolution is inconsistent between signing and verification (regression since #623) #663

Open

Description

@msheby

Summary

Since #623 (released in 6.3.3, still present in 6.4.0), SignedInfo's ancestor-namespace resolution can disagree between signing and verification, producing a signature that fails to verify even though nothing in between was tampered with. This affects any document that:

  • uses non-exclusive canonicalization (http://www.w3.org/TR/2001/REC-xml-c14n-20010315), and
  • signs with a prefixed Signature/SignedInfo (e.g. ds:Signature) nested under an element that declares a default namespace the signature doesn't override.

Root cause

getCanonSignedInfoXml resolves SignedInfo's ancestor namespaces via findSignedInfoAncestorContext →
findLoadedSignature(doc), which locates "the loaded signature" within doc by matching the decoded SignatureValue bytes, rather than via the blind //*[local-name()='SignedInfo'] document search used before #623.

That lookup behaves differently depending on when it runs:

  • During calculateSignatureValue (signing): the match fails — the in-progress signature node doesn't resolve against doc the same way it does during verification — so findSignedInfoAncestorContext silently returns { ancestorNamespaces: [] }, dropping any default namespace SignedInfo inherits from the document root.
  • During checkSignature (verification): the match succeeds and the real, non-empty ancestor-namespace list is returned.

Signing and verification therefore canonicalize different bytes for SignedInfo, so the computed SignatureValue never matches what verification reconstructs.

I confirmed this by monkeypatching findLoadedSignature/findSignedInfoAncestorNamespaces at runtime and logging both calls against the same logical document: the signing-time call reported no match and an empty ancestor-namespace list; the verification-time call against the same signature found the match and the correct non-empty list.

Bisected with git bisect (building each commit's lib/ and testing against a real-world signing/verification suite) to confirm #623 (c13e30b) is the exact commit that introduces this — the commit immediately before it still works correctly.

Minimal reproduction

const { DOMParser } = require('@xmldom/xmldom')
const original = DOMParser.prototype.parseFromString
DOMParser.prototype.parseFromString = function (source, mimeType) {
  return original.call(this, source, mimeType ?? 'text/xml')
}

const { SignedXml } = require('xml-crypto')

const xml = `<?xml version="1.0" encoding="UTF-8"?>
<Root xmlns="urn:example:default-ns">
  <Data Id="data1">hello</Data>
</Root>`

const { generateKeyPairSync } = require('crypto')
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
const privatePem = privateKey.export({ type: 'pkcs1', format: 'pem' })
const publicPem = publicKey.export({ type: 'spki', format: 'pem' })

const sig = new SignedXml({ privateKey: privatePem })
sig.canonicalizationAlgorithm = 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315'
sig.signatureAlgorithm = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'
sig.addReference({
  xpath: "//*[local-name()='Data']",
  transforms: ['http://www.w3.org/TR/2001/REC-xml-c14n-20010315'],
  digestAlgorithm: 'http://www.w3.org/2000/09/xmldsig#sha1'
})
sig.computeSignature(xml, { prefix: 'ds' })
const signedXml = sig.getSignedXml()

console.log('=== SIGNED XML ===')
console.log(signedXml)

const verifier = new SignedXml({ publicCert: publicPem })
verifier.loadSignature(signedXml.match(/<ds:Signature[\s\S]*<\/ds:Signature>/)[0])

try {
  const ok = verifier.checkSignature(signedXml)
  console.log('VERIFY RESULT:', ok)
} catch (err) {
  console.log('VERIFY THREW:', err.message)
}

Output on 6.4.0:

VERIFY THREW: invalid signature: the signature value ... is incorrect

Expected: VERIFY RESULT: true.

If Signature is signed without a prefix at the document root (so it declares its own default namespace rather than inheriting one), or with exclusive canonicalization, the bug doesn't surface — which is presumably why it wasn't caught by the existing test suite.

Versions

  • Affected: 6.3.3, 6.4.0 (not checked against unreleased master)
  • Last known good: 6.3.2

Possible direction

findLoadedSignature's byte-matching approach seems correct in principle for verification, where a user-supplied document and a separately-loaded signature node may legitimately come from different parses. During signing, though, calculateSignatureValue already has a trustworthy reference to the node actually being built — getCanonSignedInfoXml doesn't need to rediscover it via byte-matching in that path, and falling back to [] on a failed match silently produces a corrupt signature rather than failing loudly. Happy to help narrow this down further or put together a PR once there's agreement on the right fix (we already have a few merged/in-review PRs here — #541, #542).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions