Repository navigation
SignedInfo ancestor-namespace resolution is inconsistent between signing and verification (regression since #623) #663
Description
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 againstdocthe same way it does during verification — sofindSignedInfoAncestorContextsilently returns{ ancestorNamespaces: [] }, dropping any default namespaceSignedInfoinherits 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