diff --git a/README.md b/README.md index ecfa261..bba708d 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ The `SignedXml` constructor provides an abstraction for sign and verify xml docu - `keyInfoAttributes` - object - default `{}` - a hash of attributes and values `attrName: value` to add to the KeyInfo node - `getKeyInfoContent` - function - default `noop` - a function that returns the content of the KeyInfo node - `getCertFromKeyInfo` - function - default `SignedXml.getCertFromKeyInfo` - a function that returns the certificate from the `` node +- `objects` - array - default `undefined` - an array of objects defining the content of the `` nodes #### API @@ -268,10 +269,12 @@ A `SignedXml` object provides the following methods: To sign xml documents: -- `addReference(xpath, transforms, digestAlgorithm)` - adds a reference to a xml element where: +- `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 - `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) - `computeSignature(xml, [options])` - compute the signature of the given xml where: - `xml` - a string containing a xml document - `options` - an object with the following properties: @@ -534,6 +537,42 @@ sig.computeSignature(xml, { }); ``` +### How to add custom Objects to the signature + +Use the `objects` option when creating a SignedXml instance to add custom Objects to the signature. + +```javascript +var SignedXml = require("xml-crypto").SignedXml, + fs = require("fs"); + +var xml = "" + "" + "Harry Potter" + "" + ""; + +const sig = new SignedXml({ + privateKey: fs.readFileSync("client.pem"), + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + objects: [ + { + content: "Test data in Object", + attributes: { + Id: "Object1", + MimeType: "text/xml", + }, + }, + ], +}); + +// Add a reference to the Object element +sig.addReference({ + xpath: "//*[@Id='Object1']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], +}); + +sig.computeSignature(xml); +fs.writeFileSync("signed.xml", sig.getSignedXml()); +``` + ### more examples (_coming soon_) ## Development diff --git a/src/signed-xml.ts b/src/signed-xml.ts index 05dae41..c068656 100644 --- a/src/signed-xml.ts +++ b/src/signed-xml.ts @@ -8,6 +8,7 @@ import type { GetKeyInfoContentArgs, HashAlgorithm, HashAlgorithmType, + ObjectAttributes, Reference, SignatureAlgorithm, SignatureAlgorithmType, @@ -56,6 +57,7 @@ export class SignedXml { keyInfoAttributes: { [attrName: string]: string } = {}; getKeyInfoContent = SignedXml.getKeyInfoContent; getCertFromKeyInfo = SignedXml.getCertFromKeyInfo; + objects?: Array<{ content: string; attributes?: ObjectAttributes }>; // Internal state private id = 0; @@ -70,7 +72,7 @@ export class SignedXml { * Contains the references that were signed. * @see {@link Reference} */ - private references: Reference[] = []; + private references: (Reference & { wasProcessed: boolean })[] = []; /** * Contains the canonicalized XML of the references that were validly signed. @@ -143,6 +145,7 @@ export class SignedXml { keyInfoAttributes, getKeyInfoContent, getCertFromKeyInfo, + objects, } = options; // Options @@ -164,6 +167,7 @@ export class SignedXml { this.keyInfoAttributes = keyInfoAttributes ?? this.keyInfoAttributes; this.getKeyInfoContent = getKeyInfoContent ?? this.getKeyInfoContent; this.getCertFromKeyInfo = getCertFromKeyInfo ?? SignedXml.noop; + this.objects = objects; this.CanonicalizationAlgorithms; this.HashAlgorithms; this.SignatureAlgorithms; @@ -796,6 +800,8 @@ export class SignedXml { * @param digestValue The expected digest value for the reference. * @param inclusiveNamespacesPrefixList The prefix list for inclusive namespace canonicalization. * @param isEmptyUri Indicates whether the URI is empty. Defaults to `false`. + * @param id An optional `Id` attribute for the reference. + * @param type An optional `Type` attribute for the reference. */ addReference({ xpath, @@ -805,6 +811,8 @@ export class SignedXml { digestValue, inclusiveNamespacesPrefixList = [], isEmptyUri = false, + id = undefined, + type = undefined, }: Partial & Pick): void { if (digestAlgorithm == null) { throw new Error("digestAlgorithm is required"); @@ -822,6 +830,9 @@ export class SignedXml { digestValue, inclusiveNamespacesPrefixList, isEmptyUri, + id, + type, + wasProcessed: false, getValidatedNode: () => { throw new Error( "Reference has not been validated yet; Did you call `sig.checkSignature()`?", @@ -965,6 +976,7 @@ export class SignedXml { signatureXml += this.createSignedInfo(doc, prefix); signatureXml += this.getKeyInfo(prefix); + signatureXml += this.getObjects(prefix); signatureXml += ``; this.originalXmlWithIds = doc.toString(); @@ -980,8 +992,9 @@ export class SignedXml { const nodeXml = new xmldom.DOMParser().parseFromString(dummySignatureWrapper); // Because we are using a dummy wrapper hack described above, we know there will be a `firstChild` + // and that it will be an `Element` node. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const signatureDoc = nodeXml.documentElement.firstChild!; + const signatureElem = nodeXml.documentElement.firstChild! as Element; const referenceNode = xpath.select1(location.reference, doc); @@ -998,26 +1011,29 @@ export class SignedXml { } if (location.action === "append") { - referenceNode.appendChild(signatureDoc); + referenceNode.appendChild(signatureElem); } else if (location.action === "prepend") { - referenceNode.insertBefore(signatureDoc, referenceNode.firstChild); + referenceNode.insertBefore(signatureElem, referenceNode.firstChild); } else if (location.action === "before") { if (referenceNode.parentNode == null) { throw new Error( "`location.reference` refers to the root node (by default), so we can't insert `before`", ); } - referenceNode.parentNode.insertBefore(signatureDoc, referenceNode); + referenceNode.parentNode.insertBefore(signatureElem, referenceNode); } else if (location.action === "after") { if (referenceNode.parentNode == null) { throw new Error( "`location.reference` refers to the root node (by default), so we can't insert `after`", ); } - referenceNode.parentNode.insertBefore(signatureDoc, referenceNode.nextSibling); + referenceNode.parentNode.insertBefore(signatureElem, referenceNode.nextSibling); } - this.signatureNode = signatureDoc; + // Process any signature references after the signature has been added to the document + this.processSignatureReferences(doc, signatureElem, prefix); + + this.signatureNode = signatureElem; const signedInfoNodes = utils.findChildren(this.signatureNode, "SignedInfo"); if (signedInfoNodes.length === 0) { const err3 = new Error("could not find SignedInfo element in the message"); @@ -1037,8 +1053,8 @@ export class SignedXml { callback(err); } else { this.signatureValue = signature || ""; - signatureDoc.insertBefore(this.createSignature(prefix), signedInfoNode.nextSibling); - this.signatureXml = signatureDoc.toString(); + signatureElem.insertBefore(this.createSignature(prefix), signedInfoNode.nextSibling); + this.signatureXml = signatureElem.toString(); this.signedXml = doc.toString(); callback(null, this); } @@ -1046,8 +1062,8 @@ export class SignedXml { } else { // Synchronous flow this.calculateSignatureValue(doc); - signatureDoc.insertBefore(this.createSignature(prefix), signedInfoNode.nextSibling); - this.signatureXml = signatureDoc.toString(); + signatureElem.insertBefore(this.createSignature(prefix), signedInfoNode.nextSibling); + this.signatureXml = signatureElem.toString(); this.signedXml = doc.toString(); } } @@ -1070,6 +1086,38 @@ export class SignedXml { return ""; } + /** + * Creates XML for Object elements to be included in the signature + * + * @param prefix Optional namespace prefix + * @returns XML string with Object elements or empty string if none + */ + private getObjects(prefix?: string) { + const currentPrefix = prefix ? `${prefix}:` : ""; + + if (!this.objects || this.objects.length === 0) { + return ""; + } + + let result = ""; + + for (const obj of this.objects) { + let objectAttrs = ""; + if (obj.attributes) { + Object.keys(obj.attributes).forEach((name) => { + const value = obj.attributes?.[name]; + if (value !== undefined) { + objectAttrs += ` ${name}="${value}"`; + } + }); + } + + result += `<${currentPrefix}Object${objectAttrs}>${obj.content}`; + } + + return result; + } + /** * Generate the Reference nodes (as part of the signature process) * @@ -1085,19 +1133,30 @@ export class SignedXml { const nodes = xpath.selectWithResolver(ref.xpath ?? "", doc, this.namespaceResolver); if (!utils.isArrayHasLength(nodes)) { - throw new Error( - `the following xpath cannot be signed because it was not found: ${ref.xpath}`, - ); + // Don't throw here - we'll handle this in processSignatureReferences + continue; } for (const node of nodes) { + let referenceAttrs = ""; + if (ref.isEmptyUri) { - res += `<${prefix}Reference URI="">`; + referenceAttrs = 'URI=""'; } else { const id = this.ensureHasId(node); ref.uri = id; - res += `<${prefix}Reference URI="#${id}">`; + referenceAttrs = `URI="#${id}"`; } + + if (ref.id) { + referenceAttrs += ` Id="${ref.id}"`; + } + + if (ref.type) { + referenceAttrs += ` Type="${ref.type}"`; + } + + res += `<${prefix}Reference ${referenceAttrs}>`; res += `<${prefix}Transforms>`; for (const trans of ref.transforms || []) { const transform = this.findCanonicalizationAlgorithm(trans); @@ -1122,6 +1181,7 @@ export class SignedXml { `<${prefix}DigestValue>${digestAlgorithm.getHash(canonXml)}` + ``; } + ref.wasProcessed = true; } return res; @@ -1262,6 +1322,135 @@ export class SignedXml { return doc.documentElement.firstChild!; } + /** + * Process references that weren't found in the initial document + * This is called after the initial signature has been created to handle references to signature elements + */ + private processSignatureReferences(doc: Document, signatureElem: Element, prefix?: string) { + // Get unprocessed references + const unprocessedReferences = this.references.filter((ref) => !ref.wasProcessed); + if (unprocessedReferences.length === 0) { + return; + } + + prefix = prefix || ""; + prefix = prefix ? `${prefix}:` : prefix; + const signatureNamespace = "http://www.w3.org/2000/09/xmldsig#"; + + // Find the SignedInfo element to append to + const signedInfoNode = xpath.select1( + `./*[local-name(.)='SignedInfo']`, + signatureElem, + ) as Element; + if (!signedInfoNode) { + throw new Error("Could not find SignedInfo element in signature"); + } + + // Signature document is technically the same document as the one we are signing, + // but we will extract it here for clarity (and also make it support detached signatures in the future) + const signatureDoc = signatureElem.ownerDocument; + + // Process each unprocessed reference + for (const ref of unprocessedReferences) { + const nodes = xpath.selectWithResolver(ref.xpath ?? "", doc, this.namespaceResolver); + + if (!utils.isArrayHasLength(nodes)) { + throw new Error( + `the following xpath cannot be signed because it was not found: ${ref.xpath}`, + ); + } + + // Process the reference + for (const node of nodes) { + // Must not be a reference to Signature, SignedInfo, or a child of SignedInfo + if ( + node === signatureElem || + node === signedInfoNode || + utils.isDescendantOf(node, signedInfoNode) + ) { + throw new Error( + `Cannot sign a reference to the Signature or SignedInfo element itself: ${ref.xpath}`, + ); + } + + // Create the reference element directly using DOM methods to avoid namespace issues + const referenceElem = signatureDoc.createElementNS( + signatureNamespace, + `${prefix}Reference`, + ); + if (ref.isEmptyUri) { + referenceElem.setAttribute("URI", ""); + } else { + const id = this.ensureHasId(node); + ref.uri = id; + referenceElem.setAttribute("URI", `#${id}`); + } + + if (ref.id) { + referenceElem.setAttribute("Id", ref.id); + } + + if (ref.type) { + referenceElem.setAttribute("Type", ref.type); + } + + const transformsElem = signatureDoc.createElementNS( + signatureNamespace, + `${prefix}Transforms`, + ); + + for (const trans of ref.transforms || []) { + const transform = this.findCanonicalizationAlgorithm(trans); + const transformElem = signatureDoc.createElementNS( + signatureNamespace, + `${prefix}Transform`, + ); + 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); + } + + // Get the canonicalized XML + const canonXml = this.getCanonReferenceXml(doc, ref, node); + + // Get the digest algorithm and compute the digest value + const digestAlgorithm = this.findHashAlgorithm(ref.digestAlgorithm); + + const digestMethodElem = signatureDoc.createElementNS( + signatureNamespace, + `${prefix}DigestMethod`, + ); + digestMethodElem.setAttribute("Algorithm", digestAlgorithm.getAlgorithmName()); + + const digestValueElem = signatureDoc.createElementNS( + signatureNamespace, + `${prefix}DigestValue`, + ); + digestValueElem.textContent = digestAlgorithm.getHash(canonXml); + + referenceElem.appendChild(transformsElem); + referenceElem.appendChild(digestMethodElem); + referenceElem.appendChild(digestValueElem); + + // Append the reference element to SignedInfo + signedInfoNode.appendChild(referenceElem); + } + ref.wasProcessed = true; + } + } + /** * Returns just the signature part, must be called only after {@link computeSignature} * diff --git a/src/types.ts b/src/types.ts index f102c4c..146bdd1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,6 +43,21 @@ export interface GetKeyInfoContentArgs { prefix?: string | null; } +/** + * Object attributes as defined in XMLDSig spec + * @see https://www.w3.org/TR/xmldsig-core/#sec-Object + */ +export interface ObjectAttributes { + /** Optional ID attribute */ + Id?: string; + /** Optional MIME type attribute */ + MimeType?: string; + /** Optional encoding attribute */ + Encoding?: string; + /** Any additional custom attributes */ + [key: string]: string | undefined; +} + /** * Options for the SignedXml constructor. */ @@ -58,6 +73,7 @@ export interface SignedXmlOptions { keyInfoAttributes?: Record; getKeyInfoContent?(args?: GetKeyInfoContentArgs): string | null; getCertFromKeyInfo?(keyInfo?: Node | null): string | null; + objects?: Array<{ content: string; attributes?: ObjectAttributes }>; } export interface NamespacePrefix { @@ -127,6 +143,12 @@ export interface Reference { // Optional. Indicates whether the URI is empty. isEmptyUri: boolean; + // Optional. The `Id` attribute of the reference node. + id?: string; + + // Optional. The `Type` attribute of the reference node. + type?: string; + // Optional. The type of the reference node. ancestorNamespaces?: NamespacePrefix[]; diff --git a/src/utils.ts b/src/utils.ts index 6098286..466b252 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -313,3 +313,21 @@ export function validateDigestValue(digest, expectedDigest) { return true; } + +// Check if the given node is descendant of the given parent node +export function isDescendantOf(node: Node, parent: Node): boolean { + if (!node || !parent) { + return false; + } + + let currentNode: Node | null = node.parentNode; + + while (currentNode) { + if (currentNode === parent) { + return true; + } + currentNode = currentNode.parentNode; + } + + return false; +} diff --git a/test/signature-object-tests.spec.ts b/test/signature-object-tests.spec.ts new file mode 100644 index 0000000..f9fefd4 --- /dev/null +++ b/test/signature-object-tests.spec.ts @@ -0,0 +1,520 @@ +import * as fs from "fs"; +import { expect, assert } from "chai"; +import * as xpath from "xpath"; +import * as xmldom from "@xmldom/xmldom"; +import * as isDomNode from "@xmldom/is-dom-node"; +import { SignedXml } from "../src"; +import { Sha256 } from "../src/hash-algorithms"; + +const privateKey = fs.readFileSync("./test/static/client.pem", "utf-8"); +const publicCert = fs.readFileSync("./test/static/client_public.pem", "utf-8"); +const publicCertDer = fs.readFileSync("./test/static/client_public.der"); +const selectNs = (expression: string, node: Node, ns?: Record) => + xpath.useNamespaces({ + ds: "http://www.w3.org/2000/09/xmldsig#", + xades: "http://uri.etsi.org/01903/v1.3.2#", + ...ns, + })(expression, node, false); +const select1Ns = (expression: string, node: Node, ns?: Record) => + xpath.useNamespaces({ + ds: "http://www.w3.org/2000/09/xmldsig#", + xades: "http://uri.etsi.org/01903/v1.3.2#", + ...ns, + })(expression, node, true); + +const checkSignature = (signedXml: string, signedDoc: Document) => { + const verifier = new SignedXml({ publicCert }); + const signatureNode = select1Ns("//ds:Signature", signedDoc); + isDomNode.assertIsNodeLike(signatureNode); + verifier.loadSignature(signatureNode); + const valid = verifier.checkSignature(signedXml); + + return { + valid, + errorMessage: verifier + .getReferences() + .flatMap((ref) => ref.validationError?.message || []) + .join(", "), + }; +}; + +describe("ds:Object support in XML signatures", function () { + it("should add custom ds:Object elements with attributes to the signature", function () { + const xml = ''; + + const sig = new SignedXml({ + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + objects: [ + { + content: "Test data in Object element", + attributes: { + Id: "object1", + MimeType: "text/xml", + Encoding: "", + }, + }, + { + content: "Plain text content", + attributes: { + Id: "object2", + MimeType: "text/plain", + }, + }, + { + content: Buffer.from("This is base64 encoded data").toString("base64"), + attributes: { + Id: "object3", + MimeType: "text/plain", + Encoding: "http://www.w3.org/2000/09/xmldsig#base64", + }, + }, + ], + }); + + sig.addReference({ + xpath: "//*[local-name(.)='x']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.computeSignature(xml); + const signedXml = sig.getSignedXml(); + const doc = new xmldom.DOMParser().parseFromString(signedXml); + + // Should have three Object elements + const objectNodes = selectNs("/root/ds:Signature/ds:Object", doc); + isDomNode.assertIsArrayOfNodes(objectNodes); + expect(objectNodes.length).to.equal(3); + + // Verify the first Object element + const object1 = objectNodes[0]; + isDomNode.assertIsElementNode(object1); + expect(object1.getAttribute("Id")).to.equal("object1"); + expect(object1.getAttribute("MimeType")).to.equal("text/xml"); + expect(object1.hasAttribute("Encoding")).to.be.true; + expect(object1.getAttribute("Encoding")).to.equal(""); + const object1Data = select1Ns("ds:Data", object1); + isDomNode.assertIsElementNode(object1Data); + expect(object1Data.textContent).to.equal("Test data in Object element"); + + // Verify the second Object element + const object2 = objectNodes[1]; + isDomNode.assertIsElementNode(object2); + expect(object2.getAttribute("Id")).to.equal("object2"); + expect(object2.getAttribute("MimeType")).to.equal("text/plain"); + expect(object2.hasAttribute("Encoding")).to.be.false; + expect(object2.textContent).to.equal("Plain text content"); + + // Verify the third Object element + const object3 = objectNodes[2]; + isDomNode.assertIsElementNode(object3); + expect(object3.getAttribute("Id")).to.equal("object3"); + expect(object3.getAttribute("MimeType")).to.equal("text/plain"); + expect(object3.getAttribute("Encoding")).to.equal("http://www.w3.org/2000/09/xmldsig#base64"); + assert(object3.textContent); + expect(Buffer.from(object3.textContent, "base64").toString("utf-8")).to.equal( + "This is base64 encoded data", + ); + }); + + it("should have correct ds:Object namespace when there is no default namespace", function () { + const xml = ""; + + const sig = new SignedXml({ + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + objects: [ + { + content: "Test data", + attributes: { + Id: "object1", + MimeType: "text/plain", + }, + }, + ], + }); + + sig.addReference({ + xpath: "/*", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + // When we add a prefix to the signature, there is no default namespace + sig.computeSignature(xml, { prefix: "ds" }); + const signedXml = sig.getSignedXml(); + const doc = new xmldom.DOMParser().parseFromString(signedXml); + + // Verify the namespace of the ds:Object element + const objectNode = select1Ns("/root/ds:Signature/ds:Object[@Id='object1']", doc); + isDomNode.assertIsElementNode(objectNode); + }); + + it("should handle empty or undefined objects", function () { + const xml = ""; + + // Test with undefined objects + const sigWithNull = new SignedXml({ + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + objects: undefined, + }); + + sigWithNull.addReference({ + xpath: "/*", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sigWithNull.computeSignature(xml); + const signedXmlWithNull = sigWithNull.getSignedXml(); + const docWithNull = new xmldom.DOMParser().parseFromString(signedXmlWithNull); + + // Verify that no Object elements exist + const objectNodesWithNull = selectNs("//ds:Object", docWithNull); + isDomNode.assertIsArrayOfNodes(objectNodesWithNull); + expect(objectNodesWithNull.length).to.equal(0); + + // Test with empty array objects + const sigWithEmpty = new SignedXml({ + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + objects: [], + }); + + sigWithEmpty.addReference({ + xpath: "/*", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sigWithEmpty.computeSignature(xml); + const signedXmlWithEmpty = sigWithEmpty.getSignedXml(); + const docWithEmpty = new xmldom.DOMParser().parseFromString(signedXmlWithEmpty); + + // Verify that no Object elements exist + const objectNodesWithEmpty = selectNs("//ds:Object", docWithEmpty); + isDomNode.assertIsArrayOfNodes(objectNodesWithEmpty); + expect(objectNodesWithEmpty.length).to.equal(0); + }); + + it("should handle Rerefence to Object", function () { + const xml = ""; + + const sig = new SignedXml({ + privateKey: privateKey, + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + objects: [ + { + content: + "Content", + attributes: { + Id: "object1", + }, + }, + ], + }); + + sig.addReference({ + xpath: "//*[local-name(.)='Object' and @Id='object1']", + inclusiveNamespacesPrefixList: ["ns1", "ns2"], + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.computeSignature(xml); + const signedXml = sig.getSignedXml(); + const signedDoc = new xmldom.DOMParser().parseFromString(signedXml); + + // Verify that there is exactly one ds:Reference + const referenceNodes = selectNs("/root/ds:Signature/ds:SignedInfo/ds:Reference", signedDoc); + isDomNode.assertIsArrayOfNodes(referenceNodes); + expect(referenceNodes.length).to.equal(1); + const referenceEl = referenceNodes[0]; + isDomNode.assertIsElementNode(referenceEl); + + // Verify that the Reference URI points to the Object + expect(referenceEl.getAttribute("URI")).to.equal("#object1"); + + // Verify that the Reference contains the correct Transform + const transformEl = select1Ns("ds:Transforms/ds:Transform", referenceEl); + isDomNode.assertIsElementNode(transformEl); + expect(transformEl.getAttribute("Algorithm")).to.equal( + "http://www.w3.org/2001/10/xml-exc-c14n#", + ); + + // Verify that the InclusiveNamespacesPrefixList is set correctly + const inclusiveNamespacesEl = select1Ns("ec:InclusiveNamespaces", transformEl, { + ec: "http://www.w3.org/2001/10/xml-exc-c14n#", + }); + isDomNode.assertIsElementNode(inclusiveNamespacesEl); + expect(inclusiveNamespacesEl.getAttribute("PrefixList")).to.equal("ns1 ns2"); + + // Verify that the Reference contains the correct DigestMethod + const digestMethodEl = select1Ns("ds:DigestMethod", referenceEl); + isDomNode.assertIsElementNode(digestMethodEl); + expect(digestMethodEl.getAttribute("Algorithm")).to.equal( + "http://www.w3.org/2000/09/xmldsig#sha1", + ); + + // Verify that the Reference contains a non-empty DigestValue + const digestValueEl = select1Ns("ds:DigestValue", referenceEl); + isDomNode.assertIsElementNode(digestValueEl); + expect(digestValueEl.textContent).to.not.be.empty; + }); +}); + +describe("Valid signatures with ds:Object elements", function () { + it("should create valid signatures with NO references to ds:Object", function () { + const xml = ""; + + const sig = new SignedXml({ + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + objects: [ + { + content: "Test data in Object element", + attributes: { + Id: "object1", + MimeType: "text/xml", + }, + }, + ], + }); + + sig.addReference({ + xpath: "/*", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: [ + "http://www.w3.org/2000/09/xmldsig#enveloped-signature", + "http://www.w3.org/2001/10/xml-exc-c14n#", + ], + }); + + sig.computeSignature(xml); + const signedXml = sig.getSignedXml(); + const doc = new xmldom.DOMParser().parseFromString(signedXml); + + // Verify that the signature is valid + const { valid, errorMessage } = checkSignature(signedXml, doc); + expect(valid, errorMessage).to.be.true; + }); + + it("should create valid signatures with references to ds:Object", () => { + const xml = ''; + + const sig = new SignedXml({ + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + inclusiveNamespacesPrefixList: ["ns1", "ns2"], + objects: [ + { + content: + 'Test data in Object element', + attributes: { + Id: "object1", + MimeType: "text/xml", + }, + }, + ], + }); + + sig.addReference({ + xpath: "/*", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: [ + "http://www.w3.org/2000/09/xmldsig#enveloped-signature", + "http://www.w3.org/2001/10/xml-exc-c14n#", + ], + }); + + sig.addReference({ + xpath: "//*[local-name(.)='Object' and @Id='object1']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: [ + "http://www.w3.org/2000/09/xmldsig#enveloped-signature", + "http://www.w3.org/2001/10/xml-exc-c14n#", + ], + inclusiveNamespacesPrefixList: ["ns1", "ns3"], + }); + + sig.computeSignature(xml); + const signedXml = sig.getSignedXml(); + const doc = new xmldom.DOMParser().parseFromString(signedXml); + + // Verify that there are two Reference elements + const referenceNodes = selectNs("/ns1:root/ds:Signature/ds:SignedInfo/ds:Reference", doc, { + ns1: "uri:ns1", + }); + isDomNode.assertIsArrayOfNodes(referenceNodes); + expect(referenceNodes.length).to.equal(2); + + // Verify that the second Reference points to the ds:Object + const objectReference = referenceNodes[1]; + isDomNode.assertIsElementNode(objectReference); + expect(objectReference.getAttribute("URI")).to.equal("#object1"); + + // Verify that the signature is valid + const { valid, errorMessage } = checkSignature(signedXml, doc); + expect(valid, errorMessage).to.be.true; + }); +}); + +describe("XAdES Object support in XML signatures", function () { + it("should be able to add and sign XAdES objects", function () { + const signatureId = "signature_0"; + const signedPropertiesId = "signedProperties_0"; + + const publicCertDigest = new Sha256().getHash(publicCertDer); + const xml = `text`; + + const sig = new SignedXml({ + publicCert, + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + objects: [ + { + content: + `` + + `` + + `` + + `2025-06-21T12:00:00Z` + + `` + + `` + + `${publicCertDigest}` + + `` + + `` + + `` + + ``, + }, + ], + }); + + sig.addReference({ + xpath: `/*`, + isEmptyUri: true, + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: [ + "http://www.w3.org/2000/09/xmldsig#enveloped-signature", + "http://www.w3.org/2001/10/xml-exc-c14n#", + ], + }); + + sig.addReference({ + xpath: `//*[@Id='${signedPropertiesId}']`, + type: "http://uri.etsi.org/01903#SignedProperties", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.computeSignature(xml, { + prefix: "ds", + location: { + action: "append", + reference: "/root", + }, + attrs: { + Id: signatureId, + }, + }); + + const signedXml = sig.getSignedXml(); + const signedDoc = new xmldom.DOMParser().parseFromString(signedXml); + + // ds:Signature exists and has the expected Id + const elSig = select1Ns(`/root/ds:Signature[@Id='${signatureId}']`, signedDoc); + isDomNode.assertIsElementNode(elSig); + + // ds:Object/xades:QualifyingProperties exists within the signature + const elQP = select1Ns("ds:Object/xades:QualifyingProperties", elSig); + isDomNode.assertIsElementNode(elQP); + + // The Reference to SignedProperties exists and has the correct URI and Type + const elSPRef = select1Ns( + `ds:SignedInfo/ds:Reference[@URI='#${signedPropertiesId}' and @Type='http://uri.etsi.org/01903#SignedProperties']`, + elSig, + ); + isDomNode.assertIsElementNode(elSPRef); + + // Verify that the signature is valid + const { valid, errorMessage } = checkSignature(signedXml, signedDoc); + expect(valid, errorMessage).to.be.true; + }); +}); + +describe("Signature self-reference prevention", function () { + it("should not allow self-referencing the Signature element", function () { + const xml = ""; + + const sig = new SignedXml({ + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + }); + + sig.addReference({ + xpath: ".//*[local-name(.)='Signature']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + expect(() => { + sig.computeSignature(xml); + }).to.throw(/Cannot sign a reference to the Signature or SignedInfo element itself/); + }); + + it("should not allow self-referencing the SignedInfo element", function () { + const xml = ""; + + const sig = new SignedXml({ + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + }); + + sig.addReference({ + xpath: ".//*[local-name(.)='SignedInfo']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + expect(() => { + sig.computeSignature(xml); + }).to.throw(/Cannot sign a reference to the Signature or SignedInfo element itself/); + }); + + it("should not allow signing children of the SignedInfo element", function () { + const xml = ""; + + const sig = new SignedXml({ + privateKey, + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + }); + + sig.addReference({ + xpath: "/*", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.addReference({ + xpath: ".//*[local-name(.)='Reference']/*", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + expect(() => { + sig.computeSignature(xml); + }).to.throw(/Cannot sign a reference to the Signature or SignedInfo element itself/); + }); +}); diff --git a/test/signature-unit-tests.spec.ts b/test/signature-unit-tests.spec.ts index baa382d..16b66e1 100644 --- a/test/signature-unit-tests.spec.ts +++ b/test/signature-unit-tests.spec.ts @@ -1279,4 +1279,60 @@ describe("Signature unit tests", function () { "MIIDZ", ); }); + + it("adds id and type attributes to Reference elements when provided", function () { + const xml = ""; + const sig = new SignedXml(); + sig.privateKey = fs.readFileSync("./test/static/client.pem"); + + sig.addReference({ + xpath: "//*[local-name(.)='x']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + id: "ref-1", + type: "http://www.w3.org/2000/09/xmldsig#Object", + }); + + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + sig.signatureAlgorithm = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"; + sig.computeSignature(xml); + const signedXml = sig.getSignedXml(); + + const doc = new xmldom.DOMParser().parseFromString(signedXml); + const referenceElements = xpath.select("//*[local-name(.)='Reference']", doc); + isDomNode.assertIsArrayOfNodes(referenceElements); + expect(referenceElements.length, "Reference element should exist").to.equal(1); + + const referenceElement = referenceElements[0]; + isDomNode.assertIsElementNode(referenceElement); + + const idAttribute = referenceElement.getAttribute("Id"); + expect(idAttribute, "Reference element should have the correct Id attribute value").to.equal( + "ref-1", + ); + + const typeAttribute = referenceElement.getAttribute("Type"); + expect( + typeAttribute, + "Reference element should have the correct Type attribute value", + ).to.equal("http://www.w3.org/2000/09/xmldsig#Object"); + }); + + it("should throw if xpath matches no nodes", () => { + const sig = new SignedXml({ + privateKey: fs.readFileSync("./test/static/client.pem"), + canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#", + signatureAlgorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + }); + + sig.addReference({ + xpath: "//definitelyNotThere", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + expect(() => sig.computeSignature("")).to.throw( + /the following xpath cannot be signed because it was not found/, + ); + }); });