Summary
findNSPrefix in src/utils.ts returns only the first xmlns-like attribute on the referenced subset root. findAncestorNs then filters ancestor namespaces by that single prefix. If the subset root declares any prefixed namespace (xmlns:enc="...", xmlns:xsi="...", etc.) and inherits a default namespace from an ancestor, the inherited default (prefix "") is not filtered from ancestorNamespaces. During non-exclusive C14N (http://www.w3.org/TR/2001/REC-xml-c14n-20010315[#WithComments]), the default namespace is then emitted twice — once via renderNs's defaultNs !== currNs branch, once via the ancestorNamespaces merge — producing a duplicate xmlns="..." and a digest that no spec-compliant implementation will match.
Impact
Any signature whose referenced subset declares a prefixed namespace and inherits a default namespace fails to interoperate. We hit this in a SMPTE standard where the referenced <AuthenticatedPrivate> element carries xmlns:enc="http://www.w3.org/2001/04/xmlenc#" — signatures produced by other conformant signers verify against each other but not against xml-crypto, and vice versa.
Repro (Node 20, xml-crypto 6.1.2)
import { DOMParser } from '@xmldom/xmldom'
import xmlCrypto from 'xml-crypto'
const { C14nCanonicalization, SignedXml } = xmlCrypto
const xml = `<Root xmlns="http://example.com/root">
<Body Id="B" xmlns:enc="http://www.w3.org/2001/04/xmlenc#">
<enc:CipherValue>x</enc:CipherValue>
</Body>
</Root>`
const doc = new DOMParser().parseFromString(xml, 'text/xml')
const body = doc.getElementsByTagName('Body')[0]
const ancestors = SignedXml.findAncestorNs(doc, "//*[local-name()='Body']")
console.log('ancestorNamespaces:', ancestors)
// Actual: [{ prefix: '', namespaceURI: 'http://example.com/root' }]
// Expected: [] (Body already inherits the default; nothing to hoist)
const c14n = new C14nCanonicalization().process(body, {
ancestorNamespaces: ancestors
})
console.log(c14n)
// Actual: <Body xmlns="http://example.com/root" xmlns:enc="..." xmlns="http://example.com/root" Id="B">…
// Expected: <Body xmlns="http://example.com/root" xmlns:enc="..." Id="B">…
Root cause
src/utils.ts, function findNSPrefix (returns on the first match):
function findNSPrefix(subset) {
const subsetAttributes = subset.attributes;
for (let k = 0; k < subsetAttributes.length; k++) {
const nodeName = subsetAttributes[k].nodeName;
if (nodeName.search(/^xmlns:?/) !== -1) {
return nodeName.replace(/^xmlns:?/, ""); // <-- first match wins
}
}
return subset.prefix || "";
}
And findAncestorNs (compares against that single prefix):
const subsetNsPrefix = findNSPrefix(docSubset[0]);
for (const ancestorNs of ancestorNsWithoutDuplicate) {
if (ancestorNs.prefix !== subsetNsPrefix) {
returningNs.push(ancestorNs);
}
}
If the subset has both xmlns:enc and would need xmlns (default) filtered, only one is handled.
Proposed fix
Collect all xmlns declarations on the subset root into a Set<string> (including "" for the default), then filter any ancestor whose prefix is in that set. Happy to open a PR — sketch:
function findSubsetNSPrefixes(subset: Element): Set<string> {
const prefixes = new Set<string>();
const subsetAttributes = subset.attributes;
for (let k = 0; k < subsetAttributes.length; k++) {
const nodeName = subsetAttributes[k].nodeName;
if (nodeName.search(/^xmlns:?/) !== -1) {
prefixes.add(nodeName.replace(/^xmlns:?/, ""));
}
}
if (prefixes.size === 0 && subset.prefix) {
prefixes.add(subset.prefix);
}
return prefixes;
}
// in findAncestorNs, replace the trailing loop with:
const subsetPrefixes = findSubsetNSPrefixes(docSubset[0]);
return ancestorNsWithoutDuplicate.filter((ns) => !subsetPrefixes.has(ns.prefix));
Behavioral compatibility: the previous code stripped a single prefix; the new code strips every prefix declared on the subset root. That's a strict superset — no ancestor previously stripped will start being retained; only ancestors that were incorrectly retained (like the duplicate default) will now be filtered out.
Summary
findNSPrefixinsrc/utils.tsreturns only the first xmlns-like attribute on the referenced subset root.findAncestorNsthen filters ancestor namespaces by that single prefix. If the subset root declares any prefixed namespace (xmlns:enc="...",xmlns:xsi="...", etc.) and inherits a default namespace from an ancestor, the inherited default (prefix"") is not filtered fromancestorNamespaces. During non-exclusive C14N (http://www.w3.org/TR/2001/REC-xml-c14n-20010315[#WithComments]), the default namespace is then emitted twice — once viarenderNs'sdefaultNs !== currNsbranch, once via the ancestorNamespaces merge — producing a duplicatexmlns="..."and a digest that no spec-compliant implementation will match.Impact
Any signature whose referenced subset declares a prefixed namespace and inherits a default namespace fails to interoperate. We hit this in a SMPTE standard where the referenced
<AuthenticatedPrivate>element carriesxmlns:enc="http://www.w3.org/2001/04/xmlenc#"— signatures produced by other conformant signers verify against each other but not against xml-crypto, and vice versa.Repro (Node 20, xml-crypto 6.1.2)
Root cause
src/utils.ts, functionfindNSPrefix(returns on the first match):And
findAncestorNs(compares against that single prefix):If the subset has both
xmlns:encand would needxmlns(default) filtered, only one is handled.Proposed fix
Collect all xmlns declarations on the subset root into a
Set<string>(including""for the default), then filter any ancestor whose prefix is in that set. Happy to open a PR — sketch:Behavioral compatibility: the previous code stripped a single prefix; the new code strips every prefix declared on the subset root. That's a strict superset — no ancestor previously stripped will start being retained; only ancestors that were incorrectly retained (like the duplicate default) will now be filtered out.