Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7e30184
fix: omit <Transforms> element when no transforms are specified
Aug 11, 2026
e33343c
test: replace non-null assertions with deep.equal on ref.transforms
Aug 11, 2026
21df3b8
test: cover both omitted and empty transforms in Transforms-omission …
Aug 12, 2026
c5d1976
fix: derive ancestor namespaces from the referenced node, not re-runn…
Aug 12, 2026
3a2a63c
docs: expand buildAncestorNsForElement JSDoc and fix prettier formatting
Aug 17, 2026
ebd058c
Merge branch 'master' into fix/omit-empty-transforms-clean
cjbarth Sep 8, 2026
9e24d28
fix: guard against undefined transforms in enveloped-signature check
Sep 8, 2026
82bc488
test: exercise addAllReferences multi-match in ancestor-namespace reg…
Sep 8, 2026
1834d27
Merge branch 'master' into pr/542
cjbarth Sep 8, 2026
6e16874
fix: keep Reference.transforms required and tighten review follow-ups
cjbarth Sep 8, 2026
6a7ac33
refactor: fold findAncestorNsForNode wrapper into its helper
cjbarth Sep 8, 2026
6920712
refactor: replace `export *` in index.ts with explicit re-export lists
cjbarth Sep 8, 2026
bcc7e78
Merge branch 'node-saml:master' into fix/omit-empty-transforms-clean
msheby Sep 8, 2026
c6bb8c0
test: cover the non-element document subset guard in findAncestorNs
cjbarth Sep 8, 2026
a0b3e14
Lint
cjbarth Sep 8, 2026
6395317
fix: assert element-ness in getCanonReferenceXml instead of silently …
cjbarth Sep 9, 2026
1efc2c1
test: assert refusal rather than a dependency's error wording
cjbarth Sep 9, 2026
ce8d32a
fix: reject signing a reference that encloses the signature without a…
cjbarth Sep 9, 2026
abfa1ad
refactor: let algorithms declare whether they drop nodes
cjbarth Sep 9, 2026
a752144
fix!: require removesNodes on CanonicalizationOrTransformationAlgorithm
cjbarth Sep 9, 2026
f7d916d
fix: enforce removesNodes at runtime and let subclasses declare it
cjbarth Sep 9, 2026
7514d6d
fix: validate removesNodes in findCanonicalizationAlgorithm
cjbarth Sep 9, 2026
dcc89d4
docs: state that removesNodes is enforced on verification too
cjbarth Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,9 @@ To sign xml documents:

- `addReference({ xpath, transforms, digestAlgorithm, id, type })` - adds a reference to a xml element where:
- `xpath` - a string containing a XPath expression referencing a xml element
- `transforms` - an array of [transform algorithms](#canonicalization-and-transformation-algorithms), the referenced element will be transformed for each value in the array
- `transforms` - an array of [transform algorithms](#canonicalization-and-transformation-algorithms), the referenced element will be transformed for each value in the array.
Omit it, or pass an empty array, to emit no `Transforms` element; the referenced element is then digested after canonicalization alone.
If the referenced element encloses the signature, this array has to include `http://www.w3.org/2000/09/xmldsig#enveloped-signature`, which removes the `Signature` before digesting; `computeSignature()` rejects the reference otherwise rather than emit a signature that cannot verify. Supply your own transform and that check defers to it, since only you know whether it removes the `Signature`.
- `digestAlgorithm` - one of the supported [hashing algorithms](#hashing-algorithms)
- `id` - an optional `Id` attribute to add to the reference element
- `type` - the optional `Type` attribute to add to the reference element (represented as a URI)
Expand Down Expand Up @@ -342,6 +344,9 @@ Custom transformation algorithm.

```javascript
function MyTransformation() {
/*whether this drops nodes from the node-set, rather than only re-serializing it*/
this.removesNodes = false;

/*given a node (from the xmldom module) return its canonical representation (as string)*/
this.process = function (node) {
//you should apply your transformation before returning
Expand All @@ -354,10 +359,16 @@ function MyTransformation() {
}
```

`removesNodes` is required on every canonicalization and transformation algorithm, including ones written in plain JavaScript. It is checked as the algorithm is instantiated, so a missing or non-boolean value throws from `checkSignature()` just as it does from `computeSignature()` — a verifier whose own registry is misconfigured refuses rather than canonicalizing with an algorithm that never said what it does. Registrations you never use are not checked.

Declare `false` if the algorithm returns the node-set it was given, and `true` if it filters nodes at all. `computeSignature()` also reads this to decide whether a reference enclosing the signature can ever verify, and the two mistakes cost you different things: declaring `true` when the algorithm actually preserves everything skips that check, so you can sign a reference nobody can verify, while declaring `false` when it actually filters makes the check reject a reference that would have worked.

Custom canonicalization is actually the same as custom transformation. It is applied on the SignedInfo rather than on references.

```javascript
function MyCanonicalization() {
this.removesNodes = false;

/*given a node (from the xmldom module) return its canonical representation (as string)*/
this.process = function (node) {
//you should apply your transformation before returning
Expand Down
2 changes: 2 additions & 0 deletions src/c14n-canonicalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as utils from "./utils";
import * as isDomNode from "@xmldom/is-dom-node";

export class C14nCanonicalization implements CanonicalizationOrTransformationAlgorithm {
readonly removesNodes: boolean = false;

protected includeComments = false;

constructor() {
Expand Down
2 changes: 2 additions & 0 deletions src/enveloped-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
} from "./types";

export class EnvelopedSignature implements CanonicalizationOrTransformationAlgorithm {
readonly removesNodes: boolean = true;

protected includeComments = false;

constructor() {
Expand Down
2 changes: 2 additions & 0 deletions src/exclusive-canonicalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ function isPrefixInScope(prefixesInScope, prefix, namespaceURI) {
}

export class ExclusiveCanonicalization implements CanonicalizationOrTransformationAlgorithm {
readonly removesNodes: boolean = false;

protected includeComments = false;

constructor() {
Expand Down
49 changes: 47 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,50 @@ export {
ExclusiveCanonicalizationWithComments,
} from "./exclusive-canonicalization";
export { SignedXml } from "./signed-xml";
export * from "./types";
export * from "./utils";

// These lists replace `export * from` and are exhaustive on purpose: they
// reproduce the surface the wildcards already published, so that narrowing it
// becomes a deliberate, separately reviewable break rather than a side effect.
export { createOptionalCallbackFunction } from "./types";
export type {
CanonicalizationAlgorithmType,
CanonicalizationOrTransformAlgorithmType,
CanonicalizationOrTransformationAlgorithm,
CanonicalizationOrTransformationAlgorithmProcessOptions,
ComputeSignatureOptions,
ComputeSignatureOptionsLocation,
ErrorFirstCallback,
GetKeyInfoContentArgs,
HashAlgorithm,
HashAlgorithmType,
NamespacePrefix,
ObjectAttributes,
Reference,
RenderedNamespace,
SignatureAlgorithm,
SignatureAlgorithmType,
SignedXmlOptions,
TransformAlgorithm,
} from "./types";

export {
BASE64_REGEX,
EXTRACT_X509_CERTS,
PEM_FORMAT_REGEX,
derToPem,
encodeSpecialCharactersInAttribute,
encodeSpecialCharactersInText,
findAncestorNs,
findAncestorNsForNode,
findAttr,
findChildren,
// Deprecated alias of `findChildren`, still published because `export *` did.
// Removal tracked in https://github.com/node-saml/xml-crypto/issues/550
// eslint-disable-next-line deprecation/deprecation
findChilds,
isArrayHasLength,
isDescendantOf,
normalizePem,
pemToDer,
validateDigestValue,
} from "./utils";
113 changes: 76 additions & 37 deletions src/signed-xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,13 +433,12 @@ export class SignedXml {
return this.getCanonXml([this.canonicalizationAlgorithm], signedInfo[0], c14nOptions);
}

private getCanonReferenceXml(doc: Document, ref: Reference, node: Node) {
/**
* Search for ancestor namespaces before canonicalization.
*/
if (Array.isArray(ref.transforms)) {
ref.ancestorNamespaces = utils.findAncestorNs(doc, ref.xpath, this.namespaceResolver);
}
private getCanonReferenceXml(ref: Reference, node: Node) {
// Derive the scope from the node being digested: findAncestorNs re-runs
// ref.xpath and keeps only the first match, which digests every reference
// addAllReferences() created from that xpath in the first match's scope.
isDomNode.assertIsElementNode(node);
ref.ancestorNamespaces = utils.findAncestorNsForNode(node);

const c14nOptions = {
inclusiveNamespacesPrefixList: ref.inclusiveNamespacesPrefixList,
Expand Down Expand Up @@ -478,7 +477,17 @@ export class SignedXml {
if (name != null) {
const algo = this.CanonicalizationAlgorithms[name];
if (algo) {
return new algo();
const instance = new algo();
// Registering is reachable from JavaScript, where the required type buys
// nothing. Checking here covers every algorithm we instantiate, rather than
// only the ones a particular caller happens to look at.
if (typeof instance.removesNodes !== "boolean") {
throw new Error(
`canonicalization algorithm '${name}' must declare a boolean 'removesNodes'`,
);
}

return instance;
}
}

Expand Down Expand Up @@ -516,7 +525,7 @@ export class SignedXml {
}
}

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

Expand Down Expand Up @@ -576,7 +585,7 @@ export class SignedXml {
return false;
}

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

Expand Down Expand Up @@ -825,13 +834,9 @@ export class SignedXml {
throw new Error("digestAlgorithm is required");
}

if (!utils.isArrayHasLength(transforms)) {
throw new Error("transforms must contain at least one transform algorithm");
}

this.references.push({
xpath,
transforms,
transforms: transforms ?? [],
digestAlgorithm,
uri,
digestValue,
Expand Down Expand Up @@ -1141,6 +1146,22 @@ export class SignedXml {
);
}

// The Signature is already in the document, and its SignatureValue is still
// empty, so digesting content that encloses it can never match on verification.
// https://www.w3.org/TR/xmldsig-core1/#sec-EnvelopedSignature
if (
utils.isDescendantOf(signatureElem, node) &&
(ref.transforms ?? [])
.map((transform) => this.findCanonicalizationAlgorithm(transform))
.every((algorithm) => !algorithm.removesNodes)
) {
throw new Error(
`The reference ${ref.xpath} encloses the signature, so it requires the ` +
"http://www.w3.org/2000/09/xmldsig#enveloped-signature transform. " +
"Without it the signature cannot be verified.",
);
}

// Compute the target URI (ID already ensured earlier, extract it)
let targetUri: string;
if (ref.isEmptyUri) {
Expand All @@ -1166,36 +1187,40 @@ export class SignedXml {
referenceElem.setAttribute("Type", ref.type);
}

const transformsElem = signatureDoc.createElementNS(
signatureNamespace,
`${currentPrefix}Transforms`,
);

for (const trans of ref.transforms || []) {
const transform = this.findCanonicalizationAlgorithm(trans);
const transformElem = signatureDoc.createElementNS(
if (utils.isArrayHasLength(ref.transforms)) {
const transformsElem = signatureDoc.createElementNS(
signatureNamespace,
`${currentPrefix}Transform`,
`${currentPrefix}Transforms`,
);
transformElem.setAttribute("Algorithm", transform.getAlgorithmName());

if (utils.isArrayHasLength(ref.inclusiveNamespacesPrefixList)) {
const inclusiveNamespacesElem = signatureDoc.createElementNS(
transform.getAlgorithmName(),
"InclusiveNamespaces",
for (const trans of ref.transforms) {
const transform = this.findCanonicalizationAlgorithm(trans);
const transformElem = signatureDoc.createElementNS(
signatureNamespace,
`${currentPrefix}Transform`,
);
inclusiveNamespacesElem.setAttribute(
"PrefixList",
ref.inclusiveNamespacesPrefixList.join(" "),
);
transformElem.appendChild(inclusiveNamespacesElem);
transformElem.setAttribute("Algorithm", transform.getAlgorithmName());

if (utils.isArrayHasLength(ref.inclusiveNamespacesPrefixList)) {
const inclusiveNamespacesElem = signatureDoc.createElementNS(
transform.getAlgorithmName(),
"InclusiveNamespaces",
);
inclusiveNamespacesElem.setAttribute(
"PrefixList",
ref.inclusiveNamespacesPrefixList.join(" "),
);
transformElem.appendChild(inclusiveNamespacesElem);
}

transformsElem.appendChild(transformElem);
}

transformsElem.appendChild(transformElem);
referenceElem.appendChild(transformsElem);
}

// Get the canonicalized XML
const canonXml = this.getCanonReferenceXml(doc, ref, node);
const canonXml = this.getCanonReferenceXml(ref, node);

// Get the digest algorithm and compute the digest value
const digestAlgorithm = this.findHashAlgorithm(ref.digestAlgorithm);
Expand All @@ -1212,7 +1237,6 @@ export class SignedXml {
);
digestValueElem.textContent = digestAlgorithm.getHash(canonXml);

referenceElem.appendChild(transformsElem);
referenceElem.appendChild(digestMethodElem);
referenceElem.appendChild(digestValueElem);

Expand Down Expand Up @@ -1313,6 +1337,21 @@ export class SignedXml {
//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.
});

if (typeof transformedXml === "string") {
return transformedXml;
}

// A same-document reference dereferences to a node-set, which must be
// canonicalized to an octet stream before digesting. `loadReference` appends
// the same C14N on the verification side, so both sides digest equal bytes.
// https://www.w3.org/TR/xmldsig-core1/#sec-ReferenceProcessingModel
if (!utils.isArrayHasLength(transforms)) {
const c14n = this.findCanonicalizationAlgorithm(
"http://www.w3.org/TR/2001/REC-xml-c14n-20010315",
);
return String(c14n.process(transformedXml, options));
}

return transformedXml.toString();
}

Expand Down
25 changes: 25 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ export interface Reference {
xpath?: string;

// An array of transforms to be applied to the data before signing.
// An empty array emits no Transforms element; the referenced node is then
// digested directly, after C14N, per the XMLDSig processing model.
transforms: ReadonlyArray<CanonicalizationOrTransformAlgorithmType>;

// The algorithm used to calculate the digest value of the data.
Expand Down Expand Up @@ -167,6 +169,29 @@ export interface CanonicalizationOrTransformationAlgorithm {
): Node | string;

getAlgorithmName(): CanonicalizationOrTransformAlgorithmType;

/**
* Whether this algorithm can drop nodes from the node-set it is given, rather
* than only re-serializing it. Canonicalization algorithms declare `false`;
* the enveloped-signature transform declares `true`.
*
* Required, and enforced on both sides: every algorithm is checked as it is
* instantiated, so a missing or non-boolean value throws from `computeSignature()`
* and from `checkSignature()` alike. That covers algorithms registered from
* JavaScript, which this type cannot reach. Registrations that are never used are
* not checked.
*
* `computeSignature()` also reads the value to decide whether a reference that
* encloses the signature can ever verify: if every transform on that reference
* declares `false`, the `Signature` provably survives into the digest, so the
* reference is rejected rather than signed into something unverifiable.
*
* Declare `false` if the algorithm returns the node-set it was given, `true` if it
* filters nodes at all. Declaring `true` when it actually preserves everything
* skips the check above and can sign a reference nobody can verify; declaring
* `false` when it actually filters can reject a reference that would have worked.
*/
removesNodes: boolean;
}

/** Implement this to create a new HashAlgorithm */
Expand Down
Loading