Skip to content

DRAFT: v7.x #493

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

Open
wants to merge 11 commits into
base: 6.x
Choose a base branch
from
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 6.1.0 (...)

# Changelog

- Introduced new .signedReferences property of signature to help prevent signature wrapping attacks.
- After calling .checkSignature() with your public certificate, obtain .signedReferences to use. Array of signed strings by the certificate


## 6.0.0 (2024-01-26)
=======

## 6.0.1 (2025-03-14)

- Address CVEs: CVE-2025-29774 and CVE-2025-29775
Expand Down
74 changes: 43 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
# xml-crypto


# Upgrading

The `.getReferences() AND the .references` API is deprecated.
Please do not attempt to access it. The content in there should be treated as unsigned.

Instead, we strongly encourage users to migrate to the .signedReferences API. See the `Verifying XML document` section
We understand that this may take a lot of efforts to migrate, feel free to ask for help.
This will help prevent future XML signature wrapping attacks in the future.

``


![Build](https://github.com/node-saml/xml-crypto/actions/workflows/ci.yml/badge.svg)
[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/from-referrer/)

An xml digital signature library for node. Xml encryption is coming soon. Written in pure javascript!
---

# Upgrading

The `.getReferences()` AND the `.references` APIs are deprecated.
Please do not attempt to access them. The content in them should be treated as unsigned.

Instead, we strongly encourage users to migrate to the `.getSignedReferences()` API. See the [Verifying XML document](#verifying-xml-documents) section
We understand that this may take a lot of efforts to migrate, feel free to ask for help.
This will help prevent future XML signature wrapping attacks.

For more information visit [my blog](http://webservices20.blogspot.com/) or [my twitter](https://twitter.com/YaronNaveh).
---

## Install

Expand Down Expand Up @@ -161,6 +183,11 @@ var select = require("xml-crypto").xpath,
var xml = fs.readFileSync("signed.xml").toString();
var doc = new dom().parseFromString(xml);

// DO NOT attempt to parse whatever data object you have here in `doc`
// and then use it to verify the signature. This can lead to security issues.
// i.e. BAD: parseAssertion(doc),
// good: see below

var signature = select(
doc,
"//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']",
Expand All @@ -172,44 +199,29 @@ try {
} catch (ex) {
console.log(ex);
}


```

In order to protect from some attacks we must check the content we want to use is the one that has been signed:

```javascript
// Roll your own
const elem = xpath.select("/xpath_to_interesting_element", doc);
const uri = sig.getReferences()[0].uri; // might not be 0; it depends on the document
const id = uri[0] === "#" ? uri.substring(1) : uri;
if (
elem.getAttribute("ID") != id &&
elem.getAttribute("Id") != id &&
elem.getAttribute("id") != id
) {
throw new Error("The interesting element was not the one verified by the signature");
if (!res) {
throw "Invalid Signature";
}
// good: The XML Signature has been verified, meaning some subset of XML is verified.
var signedBytes = sig.signedReferences;

// Get the validated element directly from a reference
const elem = sig.references[0].getValidatedElement(); // might not be 0; it depends on the document
const matchingReference = xpath.select1("/xpath_to_interesting_element", elem);
if (!isDomNode.isNodeLike(matchingReference)) {
throw new Error("The interesting element was not the one verified by the signature");
}
var authenticatedDoc = new dom().parseFromString(signedBytes[0]); // take the first of the signed references
// load SAML or whatever from now
// obtain the assertion XML from here
// use only authenticated data
let signedAssertionNode = extractAssertion(authenticatedDoc);
let parsedAssertion = parseAssertion(signedAssertionNode)
return parsedAssertion; // now return the client, the signed Assertion

// Use the built-in method
const elem = xpath.select1("/xpath_to_interesting_element", doc);
try {
const matchingReference = sig.validateElementAgainstReferences(elem, doc);
} catch {
throw new Error("The interesting element was not the one verified by the signature");
}

// Use the built-in method with a an xpath expression
try {
const matchingReference = sig.validateReferenceWithXPath("/xpath_to_interesting_element", doc);
} catch {
throw new Error("The interesting element was not the one verified by the signature");
}
// BAD example: DO not use the .getReferences() API.
```

Note:
Expand Down
186 changes: 186 additions & 0 deletions src/XMLVerifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import type {
Reference,
SignedXmlOptions,
} from "./types";

import * as xpath from "xpath";
import * as xmldom from "@xmldom/xmldom";
import * as utils from "./utils";
import * as crypto from "crypto";
import { SignedXml } from "./signed-xml";


// used to verify XML Signatures class
class XMLVerifier {
// xmlSignatureOptions, XML signature options, i.e. IdMode
// keyInfoProvider, function: finds a trusted given, given optionally the keyInfo

private signatureOptions: SignedXmlOptions;
private signedXMLInstance: SignedXml
// private keyInfoProvider;
// this is designed to throw error, but maybe we should do boolean isntead
private referencePrevalidator: (ref: Reference) => void;

constructor(xmlSignatureOptions: SignedXmlOptions = {}, referencePrevalidator: (ref: Reference) => void) {
this.signatureOptions = xmlSignatureOptions;
this.signedXMLInstance = new SignedXml(xmlSignatureOptions);
// this.keyInfoProvider = keyInfoProvider;
this.referencePrevalidator = referencePrevalidator;
}

getAuthenticatedReferencesWithCallback(signature: Node, contextXml: string, keyInfoProvider: (keyInfo: Node) => crypto.KeyObject, callback : (err: unknown, authenticatedReferences: string[]) => void) {
try {
callback(null, this.getAuthenticatedReferences(signature, contextXml, keyInfoProvider));
} catch (e) {
callback(e, []);
}
}

/**
* Validates the signature of the provided XML document synchronously using the configured key info provider.
*
* @param xml The XML document containing the signature to be validated.
* @returns an array of utf-8 encoded bytes which are authenticated by the KeyInfoProvider
* Note: This function does NOT return a boolean value.
* Please DO NOT rely on the length of the array to make security decisions
* Only use the **contents** of the returned array to make security decisions.
* @throws Error if no key info resolver is provided.
*/
getAuthenticatedReferences(signature: Node, contextXml: string, keyInfoProvider: (keyInfo: Node) => crypto.KeyObject): string[] {
// I: authenticate the keying material
const signer = this.findSignatureAlgorithm(this.signatureAlgorithm);
// Now it returns a crypto.KeyObject, forcing user to distinguish between which type to use
const key = this.getCertFromKeyInfo(this.keyInfo);
if (key == null) {
throw new Error("KeyInfo or publicCert or privateKey is required to validate signature");
}


// II: authenticate signedInfo utf-8 encoded canonical XML string.
const doc = new xmldom.DOMParser().parseFromString(contextXml);

const unverifiedSignedInfoCanon = this.getCanonSignedInfoXml(doc);
if (!unverifiedSignedInfoCanon) {
throw new Error("Canonical signed info cannot be empty");
}

// let's clear the callback up a little bit, so we can access it's results,
// and decide whether to reset signature value or not
const sigRes = signer.verifySignature(unverifiedSignedInfoCanon, key, this.signatureValue);
// true case
if (sigRes === true) {
// continue on
} else {
throw new Error(`invalid signature: the signature value ${this.signatureValue} is incorrect`)
}

// unverifiedSignedInfoCanon is verified

// unsigned, verify later to keep with consistent callback behavior
const signedInfo = new xmldom.DOMParser().parseFromString(
unverifiedSignedInfoCanon,
"text/xml",
);

const unverifiedSignedInfoDoc = signedInfo.documentElement;
if (!unverifiedSignedInfoDoc) {
throw new Error("Could not parse unverifiedSignedInfoCanon into a document");
}

const references = utils.findChildren(unverifiedSignedInfoDoc, "Reference");
if (!utils.isArrayHasLength(references)) {
throw new Error("could not find any Reference elements");
}

// load each reference Node
const unmarshalledReference = references.map((r) => this.loadReferenceNode(r));

// now authenticate each Reference i.e. verify the Digest Value
// map & return the utf-8 canon XML from each Reference i.e. the same digest input
return unmarshalledReference.map((refObj) => this.getVerifiedBytes(refObj, doc));
}

// returns a Reference object
private loadReferenceNode(ref: Node): Reference {

return ref; // TODO
}



// processes a Reference node to get the authenticated bytes
private getVerifiedBytes(ref: Reference, doc: Document): string {


const uri = ref.uri?.[0] === "#" ? ref.uri.substring(1) : ref.uri;
let elem: xpath.SelectSingleReturnType = null;
for (const attr of this.idAttributes) {
const tmp_elemXpath = `//*[@*[local-name(.)='${attr}']='${uri}']`;
const tmp_elem = xpath.select(tmp_elemXpath, doc);
if (utils.isArrayHasLength(tmp_elem)) {
num_elements_for_id += tmp_elem.length;

if (num_elements_for_id > 1) {
throw new Error(
"Cannot validate a document which contains multiple elements with the " +
"same value for the ID / Id / Id attributes, in order to prevent " +
"signature wrapping attack.",
);
}

elem = tmp_elem[0];
}
}
// TODO, fix private issues?
const canonXml = this.signedXMLInstance.getCanonReferenceXml(doc, ref, elem);
const hash = this.signedXMLInstance.findHashAlgorithm(ref.digestAlgorithm);
const digest = hash.getHash(canonXml);

if (!utils.validateDigestValue(digest, ref.digestValue)) {
throw new Error(`invalid signature: for uri ${ref.uri} calculated digest is ${digest} but the xml to validate supplies digest ${ref.digestValue}`)
}
return canonXml;
}



// TODO maybe prevalidate a reference. Ideally this should be handled at the processReference stage
// but this would help to abstract the function away for SAML.
private preValidateReference(ref: Reference, contextDoc: Document): void {
// assert that there are only 5 nodes.
const uri = ref.uri;
// spurious pre-verifications
if (uri === "") {
elem = xpath.select1("//*", doc);
} else if (uri?.indexOf("'") !== -1) {
// xpath injection
throw new Error("Cannot validate a uri with quotes inside it");
} else {
let num_elements_for_id = 0;
for (const attr of this.idAttributes) {
const tmp_elemXpath = `//*[@*[local-name(.)='${attr}']='${uri}']`;
const tmp_elem = xpath.select(tmp_elemXpath, doc);
if (utils.isArrayHasLength(tmp_elem)) {
num_elements_for_id += tmp_elem.length;

if (num_elements_for_id > 1) {
throw new Error(
"Cannot validate a document which contains multiple elements with the " +
"same value for the ID / Id / Id attributes, in order to prevent " +
"signature wrapping attack.",
);
}

elem = tmp_elem[0];
ref.xpath = tmp_elemXpath;
}
}
}

// ref
if (ref.transforms.length >= 5) {
throw new Error('...')
}
}

}
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export { C14nCanonicalization, C14nCanonicalizationWithComments } from "./c14n-canonicalization";
export {
ExclusiveCanonicalization,
ExclusiveCanonicalizationWithComments
} from "./exclusive-canonicalization";
export { SignedXml } from "./signed-xml";
export * from "./utils";
export * from "./types";
export * from "./utils";
Loading