From 0380113b7248aacfe9b9a494ec6540f92b26b41b Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Tue, 13 Oct 2026 13:07:26 -0400 Subject: [PATCH 1/4] chore: deprecate the callback form of computeSignature() and checkSignature() 7.0 replaces the callback forms with promise-returning entry points. Mark the callback overloads @deprecated, print a DeprecationWarning when a callback is passed, and deprecate createOptionalCallbackFunction and ErrorFirstCallback at the package root. Everything keeps working. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++++++++ src/index.ts | 17 ++++++++++++++ src/signed-xml.ts | 19 +++++++++++++++ src/types.ts | 3 +++ test/signature-integration-tests.spec.ts | 2 ++ test/signature-unit-tests.spec.ts | 2 ++ test/types-tests.spec.ts | 30 ++++++++++++++++++++++++ 7 files changed, 83 insertions(+) diff --git a/README.md b/README.md index f5309ed9..e27c7045 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,10 @@ JavaScript users get no signal until the names go away. `toPem`, `pemToDer`, `pemCertificates`, `normalizePem` and `findAncestorNs` are **not** deprecated and stay exported. +The callback form of `computeSignature()` and `checkSignature()` is deprecated, and so are +`createOptionalCallbackFunction` and `ErrorFirstCallback`; see +[Asynchronous signing](#asynchronous-signing). + `getReferences()` and `references` are deprecated. Do not use them to obtain signed XML; use `getSignedReferences()` instead, as shown in [Verifying Xml documents](#verifying-xml-documents). @@ -529,6 +533,12 @@ You can always look at the actual code as a sample. If the private key is not stored locally, and you wish to use a signing server or Hardware Security Module (HSM) to sign documents, you can create a custom signing algorithm that uses an asynchronous callback. Register it under the URI of the algorithm it implements, which is the `SignatureMethod` a verifier reads. +The callback form of `computeSignature()` and `checkSignature()` is deprecated and will be removed +in 7.0, which signs and verifies asynchronously through methods that return a promise. Until then +an asynchronous algorithm such as this one needs the callback, and passing one prints a +`DeprecationWarning`. `checkSignature()` verifies synchronously whether or not it is given a +callback, so call it without one. + ```javascript const { SignedXml } = require("xml-crypto"); const crypto = require("crypto"); diff --git a/src/index.ts b/src/index.ts index 2260806c..1a17f932 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { deprecate } from "util"; +import * as types from "./types"; import * as utils from "./utils"; export { C14nCanonicalization, C14nCanonicalizationWithComments } from "./c14n-canonicalization"; @@ -22,6 +23,22 @@ export { findAncestorNs, normalizePem, pemCertificates, pemToDer, toPem } from " * own calls is what made #497 unpleasant. */ +/** + * @deprecated Will be removed in 7.0, with the callback form of `computeSignature()` and + * `checkSignature()` that it adds to a signature algorithm's methods. + */ +export const createOptionalCallbackFunction = deprecate( + types.createOptionalCallbackFunction, + "`createOptionalCallbackFunction()` is deprecated and will be removed in version 7.0, with the callback form of `computeSignature()` and `checkSignature()`.", + "XML_CRYPTO_CREATE_OPTIONAL_CALLBACK_FUNCTION", +); + +/** + * @deprecated Will be removed in 7.0, with the callback form of `computeSignature()` and + * `checkSignature()`. + */ +export type ErrorFirstCallback = types.ErrorFirstCallback; + /** * @deprecated Will be removed in 7.0. This is an internal predicate with no replacement; use * `Array.isArray(x) && x.length > 0`. diff --git a/src/signed-xml.ts b/src/signed-xml.ts index 841e23c1..415a9faf 100644 --- a/src/signed-xml.ts +++ b/src/signed-xml.ts @@ -78,6 +78,12 @@ function certificatesToPublish(publicCert: string): string[] { } } +const warnCallbackForm = deprecate( + () => {}, + "The callback form of `computeSignature()` and `checkSignature()` is deprecated and will be removed in version 7.0. Call them without a callback. An asynchronous signature algorithm needs the callback until 7.0, which signs and verifies through methods that return a promise.", + "XML_CRYPTO_CALLBACK_FORM", +); + const warnOriginalXmlWithIds = deprecate( () => {}, "`getOriginalXmlWithIds()` is deprecated and will be removed in a future version. Use the `location` option of `computeSignature()` to place the signature, then `getSignedXml()`. For a detached signature, put an ID attribute the signer recognizes on each referenced element (`wsu:Id` for WS-Security), sign that document, and send it alongside `getSignatureXml()`.", @@ -317,6 +323,7 @@ export class SignedXml { * @param xml The XML document containing the signature to be validated. * @param callback Called with the validation result before `checkSignature` returns. * @throws Error if the last parameter is provided and is not a function, or if no key info resolver is provided. + * @deprecated Will be removed in 7.0. Call `checkSignature(xml)` and use what it returns. */ checkSignature(xml: string, callback: (error: Error | null, isValid?: boolean) => void): void; checkSignature( @@ -326,6 +333,9 @@ export class SignedXml { if (callback != null && typeof callback !== "function") { throw new Error("Last parameter must be a callback function"); } + if (callback) { + warnCallbackForm(); + } // Nothing this check has not authenticated may be visible, even if it throws partway. Success // adds to what earlier successful checks on this instance published. @@ -532,6 +542,7 @@ export class SignedXml { throw new Error("Private key is required to compute signature"); } if (typeof callback === "function") { + /* eslint-disable-next-line deprecation/deprecation */ signer.getSignature(signedInfoCanon, this.privateKey, callback); } else { this.signatureValue = signer.getSignature(signedInfoCanon, this.privateKey); @@ -952,6 +963,8 @@ export class SignedXml { * @param callback A callback function to handle the signature computation asynchronously. * @returns void * @throws TypeError If the xml can not be parsed. + * @deprecated Will be removed in 7.0. Call `computeSignature(xml)`. An asynchronous signature + * algorithm needs the callback until 7.0, which signs through a method that returns a promise. */ computeSignature(xml: string, callback: ErrorFirstCallback): void; @@ -973,6 +986,9 @@ export class SignedXml { * @param callback A callback function to handle the signature computation asynchronously. * @returns void * @throws TypeError If the xml can not be parsed, or Error if there were invalid options passed. + * @deprecated Will be removed in 7.0. Call `computeSignature(xml, options)`. An asynchronous + * signature algorithm needs the callback until 7.0, which signs through a method that returns + * a promise. */ computeSignature( xml: string, @@ -993,6 +1009,9 @@ export class SignedXml { callback = callbackParam as ErrorFirstCallback; options = (options ?? {}) as ComputeSignatureOptions; } + if (typeof callback === "function") { + warnCallbackForm(); + } const doc = new xmldom.DOMParser().parseFromString(xml); let xmlNsAttr = "xmlns"; diff --git a/src/types.ts b/src/types.ts index 05ce63c9..fdc42f9b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -201,6 +201,7 @@ export interface SignatureAlgorithm { * Sign the given string using the given key */ getSignature(signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string; + /** @deprecated Will be removed in 7.0, with the callback form of `computeSignature()`. */ getSignature( signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike, @@ -214,6 +215,8 @@ export interface SignatureAlgorithm { verifySignature(material: string, key: crypto.KeyLike, signatureValue: string): boolean; /** * `SignedXml.checkSignature` calls only the synchronous form. + * + * @deprecated Will be removed in 7.0, with the callback form of `checkSignature()`. */ verifySignature( material: string, diff --git a/test/signature-integration-tests.spec.ts b/test/signature-integration-tests.spec.ts index 50b8ef93..6b1fddaf 100644 --- a/test/signature-integration-tests.spec.ts +++ b/test/signature-integration-tests.spec.ts @@ -1005,6 +1005,7 @@ describe("Signature integration tests", function () { detached === "input IDs" ? 'trusted' : "trusted"; if (useCallback) { await new Promise((resolve, reject) => { + /* eslint-disable-next-line deprecation/deprecation */ signer.computeSignature(xml, (err) => (err ? reject(err) : resolve())); }); } else { @@ -1325,6 +1326,7 @@ describe("Signature integration tests", function () { const xml = "a b c"; if (useCallback) { await new Promise((resolve, reject) => { + /* eslint-disable-next-line deprecation/deprecation */ signer.computeSignature(xml, (err) => (err ? reject(err) : resolve())); }); } else { diff --git a/test/signature-unit-tests.spec.ts b/test/signature-unit-tests.spec.ts index f1006b8e..80465d97 100644 --- a/test/signature-unit-tests.spec.ts +++ b/test/signature-unit-tests.spec.ts @@ -857,6 +857,7 @@ describe("Signature unit tests", function () { return true; }; + /* eslint-disable-next-line deprecation/deprecation */ getSignature = createOptionalCallbackFunction( (signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike) => { const signer = crypto.createSign("RSA-SHA1"); @@ -895,6 +896,7 @@ describe("Signature unit tests", function () { }); sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + /* eslint-disable-next-line deprecation/deprecation */ sig.computeSignature(xml, function () { const signedXml = sig.getSignedXml(); const expected = diff --git a/test/types-tests.spec.ts b/test/types-tests.spec.ts index 037b4e47..73af6939 100644 --- a/test/types-tests.spec.ts +++ b/test/types-tests.spec.ts @@ -22,11 +22,13 @@ describe("Callback invocation", function () { const sig = createSigner(fs.readFileSync("./test/static/client.pem")); const errorsSeen: (string | null)[] = []; + /* eslint-disable-next-line deprecation/deprecation */ const callback: ErrorFirstCallback = (err) => { errorsSeen.push(err ? err.message : null); throw new Error("Error Thrown"); }; + /* eslint-disable-next-line deprecation/deprecation */ expect(() => sig.computeSignature(xml, callback)).to.throw("Error Thrown"); expect(errorsSeen).to.deep.equal([null]); }); @@ -35,9 +37,37 @@ describe("Callback invocation", function () { const sig = createSigner(fs.readFileSync("./test/static/client_public.pem")); const outcomes: string[] = []; + /* eslint-disable-next-line deprecation/deprecation */ sig.computeSignature(xml, (err) => outcomes.push(err ? "error" : "success")); expect(outcomes).to.deep.equal(["error"]); expect(sig.getSignedXml()).to.equal(""); }); + + it("gives the result of checkSignature() to its callback", function () { + 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: "/*", + 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.computeSignature(xml); + const verifier = new SignedXml({ + publicCert: fs.readFileSync("./test/static/client_public.pem"), + }); + verifier.loadSignature(sig.getSignatureXml()); + + const results: (boolean | undefined)[] = []; + /* eslint-disable-next-line deprecation/deprecation */ + verifier.checkSignature(sig.getSignedXml(), (err, isValid) => results.push(isValid)); + + expect(results).to.deep.equal([true]); + }); }); From a00047e44b4cdbeac76ddf9d81f9f8321209f55b Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Tue, 13 Oct 2026 14:04:13 -0400 Subject: [PATCH 2/4] chore: say only that the verifySignature() callback overload goes in 7.0 checkSignature() never passes a callback to verifySignature(), so the deprecation text should not tie the two together. Co-Authored-By: Claude Opus 5.5 --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index fdc42f9b..3ca775ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -216,7 +216,7 @@ export interface SignatureAlgorithm { /** * `SignedXml.checkSignature` calls only the synchronous form. * - * @deprecated Will be removed in 7.0, with the callback form of `checkSignature()`. + * @deprecated Will be removed in 7.0. */ verifySignature( material: string, From 80d27b3e6e91a04cdf3798f2f1c5e97bccb9145b Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Tue, 13 Oct 2026 17:46:21 -0400 Subject: [PATCH 3/4] chore: drop the new test of checkSignature()'s deprecated callback form Co-Authored-By: Claude Opus 5.5 --- test/types-tests.spec.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/test/types-tests.spec.ts b/test/types-tests.spec.ts index 73af6939..15a2d4e6 100644 --- a/test/types-tests.spec.ts +++ b/test/types-tests.spec.ts @@ -43,31 +43,4 @@ describe("Callback invocation", function () { expect(outcomes).to.deep.equal(["error"]); expect(sig.getSignedXml()).to.equal(""); }); - - it("gives the result of checkSignature() to its callback", function () { - 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: "/*", - 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.computeSignature(xml); - const verifier = new SignedXml({ - publicCert: fs.readFileSync("./test/static/client_public.pem"), - }); - verifier.loadSignature(sig.getSignatureXml()); - - const results: (boolean | undefined)[] = []; - /* eslint-disable-next-line deprecation/deprecation */ - verifier.checkSignature(sig.getSignedXml(), (err, isValid) => results.push(isValid)); - - expect(results).to.deep.equal([true]); - }); }); From 0f6cf84eacb1fc9ab9f2880d50d7fff6bf6e7535 Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Tue, 13 Oct 2026 19:25:46 -0400 Subject: [PATCH 4/4] feat: add computeSignatureAsync() and checkSignatureAsync(), and give checkSignature()'s callback to the algorithm again The deprecation of the callback form had nothing to send an asynchronous signature algorithm to before 7.0. The promise entry points and the optional getSignatureAsync() and verifySignatureAsync() are that replacement, and the warning, the @deprecated tags and the README now name them. checkSignature(xml, callback) has called verifySignature() without the callback since 6.1.0, so an algorithm that answers only through it had every signature rejected. Co-Authored-By: Claude Opus 5.5 --- README.md | 129 ++++++++++++++---- src/index.ts | 8 +- src/signed-xml.ts | 235 +++++++++++++++++++++++++------- src/types.ts | 24 +++- test/async-entry-points.spec.ts | 218 +++++++++++++++++++++++++++++ 5 files changed, 527 insertions(+), 87 deletions(-) create mode 100644 test/async-entry-points.spec.ts diff --git a/README.md b/README.md index 5102507d..d1c30213 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,9 @@ deprecated and will be private in 7.0; call `process()` instead. Calling either deprecated and stay exported. The callback form of `computeSignature()` and `checkSignature()` is deprecated, and so are -`createOptionalCallbackFunction` and `ErrorFirstCallback`; see -[Asynchronous signing](#asynchronous-signing). +`createOptionalCallbackFunction` and `ErrorFirstCallback`. `computeSignatureAsync()` and +`checkSignatureAsync()` replace them; see +[Moving from the callback form](#moving-from-the-callback-form). `getReferences()` and `references` are deprecated. Do not use them to obtain signed XML; use `getSignedReferences()` instead, as shown in [Verifying Xml documents](#verifying-xml-documents). @@ -396,6 +397,7 @@ To sign xml documents: - `attrs` - a hash of attributes and values `attrName: value` to add to the signature root node - `location` - customize the location of the signature, pass an object with a `reference` key which should contain a XPath expression to a reference node, an `action` key which should contain one of the following values: `append`, `prepend`, `before`, `after` - `existingPrefixes` - A hash of prefixes and namespaces `prefix: namespace` that shouldn't be in the signature because they already exist in the xml +- `computeSignatureAsync(xml, [options])` - `computeSignature` for a signature algorithm that signs asynchronously; returns a promise for the `SignedXml` instance. See [Asynchronous signing and verification](#asynchronous-signing-and-verification) - `getSignedXml()` - returns the original xml document with the signature in it, **must be called only after `computeSignature`** - `getSignatureXml()` - returns just the signature part, **must be called only after `computeSignature`** - `getOriginalXmlWithIds()` - **[deprecated]** returns the original xml with Id attributes added on relevant elements, **must be called only after `computeSignature`**. Use the `location` option of `computeSignature()` to place the signature, then `getSignedXml()`. See [how to specify the location of the signature](#how-to-specify-the-location-of-the-signature). For a detached signature, put an ID attribute the signer recognizes on each referenced element (`wsu:Id` for WS-Security), sign that document, and send it alongside `getSignatureXml()`. Make sure each reference XPath still selects its intended element once those IDs are present, for example by selecting on the ID itself. @@ -416,6 +418,7 @@ To verify xml documents: - `loadSignature(signatureXml)` - loads the signature where: - `signatureXml` - a string or node object (like an [xmldom](https://github.com/xmldom/xmldom) node) containing the xml representation of the signature - `checkSignature(xml)` - validates the given xml document and returns `true` if the validation was successful +- `checkSignatureAsync(xml)` - `checkSignature` for a signature algorithm that verifies asynchronously; returns a promise for `true` if the validation was successful - `getSignedReferences()` - returns the canonical XML of each reference, only after `checkSignature` succeeds - `validateElementAgainstReferences(elemOrXpath, doc)` - **[deprecated]** after `checkSignature` succeeds, use the XML that `getSignedReferences()` returns instead of nodes from the original document @@ -543,15 +546,9 @@ signXml(xml, "//*[local-name(.)='book']", "client.pem", "result.xml"); You can always look at the actual code as a sample. -## Asynchronous signing +## Asynchronous signing and verification -If the private key is not stored locally, and you wish to use a signing server or Hardware Security Module (HSM) to sign documents, you can create a custom signing algorithm that uses an asynchronous callback. Register it under the URI of the algorithm it implements, which is the `SignatureMethod` a verifier reads. - -The callback form of `computeSignature()` and `checkSignature()` is deprecated and will be removed -in 7.0, which signs and verifies asynchronously through methods that return a promise. Until then -an asynchronous algorithm such as this one needs the callback, and passing one prints a -`DeprecationWarning`. `checkSignature()` verifies synchronously whether or not it is given a -callback, so call it without one. +If the private key is not stored locally, and you wish to use a signing server or Hardware Security Module (HSM) to sign documents, the signature algorithm has to wait for its answer. Give a custom signature algorithm a `getSignatureAsync()` method that returns a promise, and sign with `computeSignatureAsync()`. Register the algorithm under the URI of the algorithm it implements, which is the `SignatureMethod` a verifier reads. ```javascript const { SignedXml } = require("xml-crypto"); @@ -559,11 +556,18 @@ const crypto = require("crypto"); const fs = require("fs"); class AsyncRsaSha256 { - getSignature(signedInfo, privateKey, callback) { + async getSignatureAsync(signedInfo, privateKey) { // Do some asynchronous things here, such as calling a signing server. const signer = crypto.createSign("RSA-SHA256"); signer.update(signedInfo); - callback(null, signer.sign(privateKey, "base64")); + return signer.sign(privateKey, "base64"); + } + + async verifySignatureAsync(material, key, signatureValue) { + // Do some asynchronous things here, too. + const verifier = crypto.createVerify("RSA-SHA256"); + verifier.update(material); + return verifier.verify(key, signatureValue, "base64"); } getAlgorithmName() { @@ -571,28 +575,103 @@ class AsyncRsaSha256 { } } -const xml = "Harry Potter"; +async function sign(xml) { + 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", + }); + sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = AsyncRsaSha256; + sig.addReference({ + xpath: "//*[local-name(.)='book']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); -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", -}); -sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = AsyncRsaSha256; -sig.addReference({ - xpath: "//*[local-name(.)='book']", - digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", - transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + await sig.computeSignatureAsync(xml); + return sig; +} + +sign("Harry Potter").then((sig) => + fs.writeFileSync("signed.xml", sig.getSignedXml()), +); +``` + +`computeSignatureAsync()` takes what `computeSignature()` takes and resolves with the `SignedXml` instance. `checkSignatureAsync()` does the same for `checkSignature()`: it waits for the algorithm's `verifySignatureAsync()` and resolves with `true` for a valid signature. + +```javascript +async function verify(xml, signature) { + const sig = new SignedXml({ publicCert: fs.readFileSync("client_public.pem") }); + sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = AsyncRsaSha256; + sig.loadSignature(signature); + + if (!(await sig.checkSignatureAsync(xml))) { + throw new Error("Invalid signature"); + } + return sig.getSignedReferences(); +} +``` + +Both call `getSignature()` or `verifySignature()` on an algorithm that has no `Async` method, so they work with the built-in algorithms too. The `SignatureAlgorithm` type requires `getSignature()` and `verifySignature()`; an algorithm that only works asynchronously can have them throw. + +### Moving from the callback form + +The callback form of `computeSignature()` and `checkSignature()` is deprecated and will be removed in 7.0. Passing a callback prints a `DeprecationWarning`. + +With a built-in algorithm, or any other that returns its answer, call them without the callback: + +```javascript +// Before +sig.computeSignature(xml, (err) => { + if (err) { + throw err; + } + fs.writeFileSync("signed.xml", sig.getSignedXml()); }); + +// After +sig.computeSignature(xml); +fs.writeFileSync("signed.xml", sig.getSignedXml()); +``` + +With an algorithm that answers through a callback, return a promise from an `Async` method instead and call the `Async` entry point: + +```javascript +// Before +class AsyncRsaSha256 { + getSignature(signedInfo, privateKey, callback) { + signingServer.sign(signedInfo, callback); + } + + verifySignature(material, key, signatureValue, callback) { + signingServer.verify(material, signatureValue, callback); + } +} + sig.computeSignature(xml, (err) => { if (err) { - console.error(err); - return; + throw err; } fs.writeFileSync("signed.xml", sig.getSignedXml()); }); + +// After +class AsyncRsaSha256 { + getSignatureAsync(signedInfo, privateKey) { + return signingServer.sign(signedInfo); + } + + verifySignatureAsync(material, key, signatureValue) { + return signingServer.verify(material, signatureValue); + } +} + +await sig.computeSignatureAsync(xml); +fs.writeFileSync("signed.xml", sig.getSignedXml()); ``` +`createOptionalCallbackFunction` and `ErrorFirstCallback` serve only the callback form. An algorithm needs neither once its methods return their answer or a promise for it. + ## X.509 / Key formats Xml-Crypto internally relies on node's crypto module. This means pem encoded certificates are supported. So to sign an xml use key.pem that looks like this (only the beginning of the key content is shown): diff --git a/src/index.ts b/src/index.ts index 1a17f932..df5d0d30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,18 +24,18 @@ export { findAncestorNs, normalizePem, pemCertificates, pemToDer, toPem } from " */ /** - * @deprecated Will be removed in 7.0, with the callback form of `computeSignature()` and - * `checkSignature()` that it adds to a signature algorithm's methods. + * @deprecated Will be removed in 7.0. Write a signature algorithm's methods without a callback, + * and give it `getSignatureAsync()` and `verifySignatureAsync()` if it works asynchronously. */ export const createOptionalCallbackFunction = deprecate( types.createOptionalCallbackFunction, - "`createOptionalCallbackFunction()` is deprecated and will be removed in version 7.0, with the callback form of `computeSignature()` and `checkSignature()`.", + "`createOptionalCallbackFunction()` is deprecated and will be removed in version 7.0. Write a signature algorithm's methods without a callback, and give it `getSignatureAsync()` and `verifySignatureAsync()` if it works asynchronously.", "XML_CRYPTO_CREATE_OPTIONAL_CALLBACK_FUNCTION", ); /** * @deprecated Will be removed in 7.0, with the callback form of `computeSignature()` and - * `checkSignature()`. + * `checkSignature()`. `computeSignatureAsync()` and `checkSignatureAsync()` return a promise. */ export type ErrorFirstCallback = types.ErrorFirstCallback; diff --git a/src/signed-xml.ts b/src/signed-xml.ts index a65d6709..58cdbfb9 100644 --- a/src/signed-xml.ts +++ b/src/signed-xml.ts @@ -28,6 +28,7 @@ import * as signatureAlgorithms from "./signature-algorithms"; import * as utils from "./utils"; type SigningReferenceTarget = { node: Element; digestValue?: string }; +type UnsignedSignature = { doc: Document; insertSignatureValue: () => void }; type C14nOptionsWithXmlAttributes = CanonicalizationOrTransformationAlgorithmProcessOptions & { ancestorXmlAttributes?: utils.InheritedXmlAttribute[]; }; @@ -80,7 +81,7 @@ function certificatesToPublish(publicCert: string): string[] { const warnCallbackForm = deprecate( () => {}, - "The callback form of `computeSignature()` and `checkSignature()` is deprecated and will be removed in version 7.0. Call them without a callback. An asynchronous signature algorithm needs the callback until 7.0, which signs and verifies through methods that return a promise.", + "The callback form of `computeSignature()` and `checkSignature()` is deprecated and will be removed in version 7.0. Call them without a callback. For a signature algorithm that works asynchronously, implement `getSignatureAsync()` and `verifySignatureAsync()` on it and call `computeSignatureAsync()` and `checkSignatureAsync()`, which return a promise.", "XML_CRYPTO_CALLBACK_FORM", ); @@ -324,12 +325,14 @@ export class SignedXml { */ checkSignature(xml: string): boolean; /** - * Validates the signature of the provided XML document synchronously using the configured key info provider. + * Validates the signature of the provided XML document using the configured key info provider. + * The signature algorithm is given a callback to answer through. * * @param xml The XML document containing the signature to be validated. - * @param callback Called with the validation result before `checkSignature` returns. + * @param callback Called with the validation result. * @throws Error if the last parameter is provided and is not a function, or if no key info resolver is provided. - * @deprecated Will be removed in 7.0. Call `checkSignature(xml)` and use what it returns. + * @deprecated Will be removed in 7.0. Call `checkSignature(xml)`, or `checkSignatureAsync(xml)` + * for a signature algorithm that verifies asynchronously. */ checkSignature(xml: string, callback: (error: Error | null, isValid?: boolean) => void): void; checkSignature( @@ -343,6 +346,89 @@ export class SignedXml { warnCallbackForm(); } + const pending = this.validateReferences(xml, callback); + if (!pending) { + return pending; + } + const { signer, key, unverifiedSignedInfoCanon, accept } = pending; + + if (!callback) { + if (signer.verifySignature(unverifiedSignedInfoCanon, key, this.signatureValue) !== true) { + throw this.invalidSignature(); + } + accept(); + return true; + } + + let answered = false; + const answer = (error: Error | null, isValid?: boolean) => { + if (answered) { + return; + } + answered = true; + if (error) { + callback(error); + } else if (isValid !== true) { + callback(this.invalidSignature()); + } else { + accept(); + callback(null, true); + } + }; + /* eslint-disable-next-line deprecation/deprecation */ + const returned: unknown = signer.verifySignature( + unverifiedSignedInfoCanon, + key, + this.signatureValue, + answer, + ); + // An algorithm that verifies synchronously can ignore the callback and return its verdict. + if (typeof returned === "boolean") { + answer(null, returned); + } + } + + /** + * Validates the signature of the provided XML document using the configured key info provider, + * waiting for a signature algorithm that implements `verifySignatureAsync()`. An algorithm + * without it is called as `checkSignature(xml)` calls it. + * + * @param xml The XML document containing the signature to be validated. + * @returns a promise for `true` if the signature is valid, and for `false` if a reference does + * not validate + * @throws Error rejects with it if the signature value is incorrect, or if no key info resolver + * is provided. + */ + async checkSignatureAsync(xml: string): Promise { + const pending = this.validateReferences(xml); + if (!pending) { + return false; + } + const { signer, key, unverifiedSignedInfoCanon, accept } = pending; + + const isValid = + typeof signer.verifySignatureAsync === "function" + ? await signer.verifySignatureAsync(unverifiedSignedInfoCanon, key, this.signatureValue) + : signer.verifySignature(unverifiedSignedInfoCanon, key, this.signatureValue); + if (isValid !== true) { + throw this.invalidSignature(); + } + accept(); + return true; + } + + private invalidSignature(): Error { + // TODO: add this breaking change here later on for even more security: `this.references = [];` + return new Error(`invalid signature: the signature value ${this.signatureValue} is incorrect`); + } + + // Core validation up to the signature over `SignedInfo`, which is left to the caller because the + // signature algorithm may answer later. Returns `false`, or nothing once the callback has the + // reason, when a reference does not validate. + private validateReferences( + xml: string, + callback?: (error: Error | null, isValid?: boolean) => void, + ) { // Nothing this check has not authenticated may be visible, even if it throws partway. Success // adds to what earlier successful checks on this instance published. const earlierSignedReferences = this.signedReferences; @@ -427,33 +513,16 @@ export class SignedXml { throw new Error("KeyInfo or publicCert or privateKey is required to validate signature"); } - // Check the signature verification to know whether to reset signature value or not. - const sigRes = signer.verifySignature(unverifiedSignedInfoCanon, key, this.signatureValue); - if (sigRes === true) { + // An asynchronous algorithm may answer after another check has replaced `this.references`. + const loadedReferences = this.references; + const accept = () => { this.signedReferences = [...earlierSignedReferences, ...canonReferences]; - this.references.forEach((ref, index) => { + loadedReferences.forEach((ref, index) => { ref.signedReference = canonReferences[index]; }); + }; - if (callback) { - callback(null, true); - } else { - return true; - } - } else { - // TODO: add this breaking change here later on for even more security: `this.references = [];` - - if (callback) { - callback( - new Error(`invalid signature: the signature value ${this.signatureValue} is incorrect`), - ); - return; // return early - } else { - throw new Error( - `invalid signature: the signature value ${this.signatureValue} is incorrect`, - ); - } - } + return { signer, key, unverifiedSignedInfoCanon, accept }; } private getCanonSignedInfoXml(doc: Document) { @@ -541,18 +610,38 @@ export class SignedXml { ); } - private calculateSignatureValue(doc: Document, callback?: ErrorFirstCallback) { + private signingInput(doc: Document) { const signedInfoCanon = this.getCanonSignedInfoXml(doc); const signer = this.findSignatureAlgorithm(this.signatureAlgorithm); if (this.privateKey == null) { throw new Error("Private key is required to compute signature"); } + return { signedInfoCanon, signer, privateKey: this.privateKey }; + } + + private calculateSignatureValue(doc: Document, callback?: ErrorFirstCallback) { + const { signedInfoCanon, signer, privateKey } = this.signingInput(doc); if (typeof callback === "function") { /* eslint-disable-next-line deprecation/deprecation */ - signer.getSignature(signedInfoCanon, this.privateKey, callback); + signer.getSignature(signedInfoCanon, privateKey, callback); } else { - this.signatureValue = signer.getSignature(signedInfoCanon, this.privateKey); + this.signatureValue = signer.getSignature(signedInfoCanon, privateKey); + } + } + + private async calculateSignatureValueAsync(doc: Document) { + const { signedInfoCanon, signer, privateKey } = this.signingInput(doc); + const signatureValue: unknown = + typeof signer.getSignatureAsync === "function" + ? await signer.getSignatureAsync(signedInfoCanon, privateKey) + : signer.getSignature(signedInfoCanon, privateKey); + // An algorithm written for the callback form returns nothing when it is called without one. + if (typeof signatureValue !== "string") { + throw new Error( + "the signature algorithm returned no signature; an algorithm that signs asynchronously implements getSignatureAsync()", + ); } + this.signatureValue = signatureValue; } private findSignatureAlgorithm(name?: SignatureAlgorithmType) { @@ -969,8 +1058,8 @@ export class SignedXml { * @param callback A callback function to handle the signature computation asynchronously. * @returns void * @throws TypeError If the xml can not be parsed. - * @deprecated Will be removed in 7.0. Call `computeSignature(xml)`. An asynchronous signature - * algorithm needs the callback until 7.0, which signs through a method that returns a promise. + * @deprecated Will be removed in 7.0. Call `computeSignature(xml)`, or `computeSignatureAsync(xml)` + * for a signature algorithm that signs asynchronously. */ computeSignature(xml: string, callback: ErrorFirstCallback): void; @@ -992,9 +1081,8 @@ export class SignedXml { * @param callback A callback function to handle the signature computation asynchronously. * @returns void * @throws TypeError If the xml can not be parsed, or Error if there were invalid options passed. - * @deprecated Will be removed in 7.0. Call `computeSignature(xml, options)`. An asynchronous - * signature algorithm needs the callback until 7.0, which signs through a method that returns - * a promise. + * @deprecated Will be removed in 7.0. Call `computeSignature(xml, options)`, or + * `computeSignatureAsync(xml, options)` for a signature algorithm that signs asynchronously. */ computeSignature( xml: string, @@ -1019,6 +1107,62 @@ export class SignedXml { warnCallbackForm(); } + const unsigned = this.prepareSignature(xml, options, callback); + if (!unsigned) { + return; + } + + if (typeof callback === "function") { + // Asynchronous flow + this.calculateSignatureValue(unsigned.doc, (err, signature) => { + if (err) { + callback(err); + } else { + this.signatureValue = signature || ""; + unsigned.insertSignatureValue(); + callback(null, this); + } + }); + } else { + // Synchronous flow + this.calculateSignatureValue(unsigned.doc); + unsigned.insertSignatureValue(); + } + } + + /** + * Compute the signature of the given XML (using the already defined settings), waiting for a + * signature algorithm that implements `getSignatureAsync()`. An algorithm without it is called + * as `computeSignature(xml)` calls it. + * + * @param xml The XML to compute the signature for. + * @param options An object containing options for the signature computation. + * @returns a promise for this instance + * @throws TypeError rejects with it if the xml can not be parsed, or with an Error if there were + * invalid options passed. + */ + async computeSignatureAsync( + xml: string, + options: ComputeSignatureOptions = {}, + ): Promise { + const unsigned = this.prepareSignature(xml, options); + await this.calculateSignatureValueAsync(unsigned.doc); + unsigned.insertSignatureValue(); + return this; + } + + // Everything but the signature value. Returns nothing once the callback has the reason it stopped. + private prepareSignature(xml: string, options: ComputeSignatureOptions): UnsignedSignature; + private prepareSignature( + xml: string, + options: ComputeSignatureOptions, + callback: ErrorFirstCallback, + ): UnsignedSignature | undefined; + private prepareSignature( + xml: string, + options: ComputeSignatureOptions, + callback?: ErrorFirstCallback, + ) { const doc = new xmldom.DOMParser().parseFromString(xml); let xmlNsAttr = "xmlns"; const signatureAttrs: string[] = []; @@ -1168,26 +1312,13 @@ export class SignedXml { } const signedInfoNode = signedInfoNodes[0]; - if (typeof callback === "function") { - // Asynchronous flow - this.calculateSignatureValue(doc, (err, signature) => { - if (err) { - callback(err); - } else { - this.signatureValue = signature || ""; - signatureElem.insertBefore(this.createSignature(prefix), signedInfoNode.nextSibling); - this.signatureXml = this.serialize(signatureElem); - this.signedXml = this.serialize(doc); - callback(null, this); - } - }); - } else { - // Synchronous flow - this.calculateSignatureValue(doc); + const insertSignatureValue = () => { signatureElem.insertBefore(this.createSignature(prefix), signedInfoNode.nextSibling); this.signatureXml = this.serialize(signatureElem); this.signedXml = this.serialize(doc); - } + }; + + return { doc, insertSignatureValue }; } // The signer parses its input, so a carriage return reaches the DOM only through a character diff --git a/src/types.ts b/src/types.ts index 3ca775ca..87e622f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -201,29 +201,41 @@ export interface SignatureAlgorithm { * Sign the given string using the given key */ getSignature(signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string; - /** @deprecated Will be removed in 7.0, with the callback form of `computeSignature()`. */ + /** @deprecated Will be removed in 7.0. Implement `getSignatureAsync()` instead. */ getSignature( signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike, callback?: ErrorFirstCallback, ): void; + /** + * Sign the given string using the given key, for an algorithm that has to wait for the + * signature. `SignedXml.computeSignatureAsync` calls it in place of `getSignature`. + */ + getSignatureAsync?(signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): Promise; /** * Verify the given signature of the given string using key * * @param key a public cert, public key, or private key can be passed here */ verifySignature(material: string, key: crypto.KeyLike, signatureValue: string): boolean; - /** - * `SignedXml.checkSignature` calls only the synchronous form. - * - * @deprecated Will be removed in 7.0. - */ + /** @deprecated Will be removed in 7.0. Implement `verifySignatureAsync()` instead. */ verifySignature( material: string, key: crypto.KeyLike, signatureValue: string, callback?: ErrorFirstCallback, ): void; + /** + * Verify the given signature of the given string using key, for an algorithm that has to wait + * for the verdict. `SignedXml.checkSignatureAsync` calls it in place of `verifySignature`. + * + * @param key a public cert, public key, or private key can be passed here + */ + verifySignatureAsync?( + material: string, + key: crypto.KeyLike, + signatureValue: string, + ): Promise; getAlgorithmName(): SignatureAlgorithmType; } diff --git a/test/async-entry-points.spec.ts b/test/async-entry-points.spec.ts new file mode 100644 index 00000000..486a0876 --- /dev/null +++ b/test/async-entry-points.spec.ts @@ -0,0 +1,218 @@ +import { SignedXml, type SignatureAlgorithm } from "../src/index"; +import * as assert from "assert"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import { expect } from "chai"; + +const RSA_SHA256 = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; +const EXCLUSIVE_C14N = "http://www.w3.org/2001/10/xml-exc-c14n#"; +const xml = "trusted"; + +const elsewhere = () => new Promise((resolve) => setImmediate(resolve)); + +class RemoteRsaSha256 implements SignatureAlgorithm { + getAlgorithmName = () => RSA_SHA256; + + getSignature(): never { + throw new Error("this algorithm signs only asynchronously"); + } + + verifySignature(): never { + throw new Error("this algorithm verifies only asynchronously"); + } + + async getSignatureAsync(signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike) { + await elsewhere(); + return crypto.createSign("RSA-SHA256").update(signedInfo).sign(privateKey, "base64"); + } + + async verifySignatureAsync(material: string, key: crypto.KeyLike, signatureValue: string) { + await elsewhere(); + return crypto.createVerify("RSA-SHA256").update(material).verify(key, signatureValue, "base64"); + } +} + +function createSigner(algorithm?: new () => SignatureAlgorithm): SignedXml { + const sig = new SignedXml({ + privateKey: fs.readFileSync("./test/static/client.pem"), + canonicalizationAlgorithm: EXCLUSIVE_C14N, + signatureAlgorithm: RSA_SHA256, + }); + if (algorithm) { + sig.SignatureAlgorithms[RSA_SHA256] = algorithm; + } + sig.addReference({ + xpath: "/*", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2000/09/xmldsig#enveloped-signature", EXCLUSIVE_C14N], + }); + return sig; +} + +function createVerifier(signatureXml: string, algorithm?: new () => SignatureAlgorithm): SignedXml { + const sig = new SignedXml({ publicCert: fs.readFileSync("./test/static/client_public.pem") }); + if (algorithm) { + sig.SignatureAlgorithms[RSA_SHA256] = algorithm; + } + sig.loadSignature(signatureXml); + return sig; +} + +describe("computeSignatureAsync()", function () { + function signedSynchronously(): string { + const sig = createSigner(); + sig.computeSignature(xml); + return sig.getSignedXml(); + } + + it("signs through the algorithm's getSignatureAsync()", async function () { + const sig = createSigner(RemoteRsaSha256); + + await sig.computeSignatureAsync(xml); + + expect(sig.getSignedXml()).to.equal(signedSynchronously()); + }); + + it("signs through getSignature() when the algorithm has no getSignatureAsync()", async function () { + const sig = createSigner(); + + await sig.computeSignatureAsync(xml); + + expect(sig.getSignedXml()).to.equal(signedSynchronously()); + }); + + it("resolves with the instance", async function () { + const sig = createSigner(RemoteRsaSha256); + + expect(await sig.computeSignatureAsync(xml)).to.equal(sig); + }); + + it("takes the options computeSignature() takes", async function () { + const sig = createSigner(RemoteRsaSha256); + + await sig.computeSignatureAsync(xml, { prefix: "ds" }); + + expect(sig.getSignatureXml()).to.match(/^ { + throw new Error("signing server unavailable"); + } + } + const sig = createSigner(Unavailable); + + await assert.rejects(sig.computeSignatureAsync(xml), { message: "signing server unavailable" }); + expect(sig.getSignedXml()).to.equal(""); + }); + + it("rejects an algorithm that answers only through a callback", async function () { + class AnswersThroughCallback implements SignatureAlgorithm { + getAlgorithmName = () => RSA_SHA256; + + verifySignature(): never { + throw new Error("this algorithm only signs"); + } + + getSignature = function ( + signedInfo: crypto.BinaryLike, + privateKey: crypto.KeyLike, + callback?: (error: Error | null, signature?: string) => void, + ) { + const signature = crypto.createSign("RSA-SHA256").update(signedInfo); + callback?.(null, signature.sign(privateKey, "base64")); + } as unknown as SignatureAlgorithm["getSignature"]; + } + const sig = createSigner(AnswersThroughCallback); + + await assert.rejects(sig.computeSignatureAsync(xml), { + message: + "the signature algorithm returned no signature; an algorithm that signs asynchronously implements getSignatureAsync()", + }); + expect(sig.getSignedXml()).to.equal(""); + }); +}); + +describe("checkSignatureAsync()", function () { + const signer = createSigner(); + signer.computeSignature(xml); + const signedXml = signer.getSignedXml(); + const signatureXml = signer.getSignatureXml(); + const signedContent = 'trusted'; + + // The references still match their digests; only the signature over SignedInfo does not verify. + const withAnotherSignatureValue = (signed: string) => + signed.replace( + /()(.)/, + (match, open: string, first: string) => open + (first === "A" ? "B" : "A"), + ); + + it("accepts a valid signature, publishing what it signs only once the algorithm answers", async function () { + const sig = createVerifier(signatureXml, RemoteRsaSha256); + + const verdict = sig.checkSignatureAsync(signedXml); + + expect(sig.getSignedReferences()).to.deep.equal([]); + expect(await verdict).to.be.true; + expect(sig.getSignedReferences()).to.deep.equal([signedContent]); + }); + + it("verifies through verifySignature() when the algorithm has no verifySignatureAsync()", async function () { + const sig = createVerifier(signatureXml); + + expect(await sig.checkSignatureAsync(signedXml)).to.be.true; + expect(sig.getSignedReferences()).to.deep.equal([signedContent]); + }); + + it("rejects a signature value that does not verify, publishing nothing", async function () { + const sig = createVerifier(withAnotherSignatureValue(signatureXml), RemoteRsaSha256); + + await assert.rejects(sig.checkSignatureAsync(withAnotherSignatureValue(signedXml)), { + message: /^invalid signature: the signature value /, + }); + expect(sig.getSignedReferences()).to.deep.equal([]); + }); + + it("rejects a verdict other than true, publishing nothing", async function () { + class AnswersWithText extends RemoteRsaSha256 { + async verifySignatureAsync() { + return "true" as unknown as boolean; + } + } + const sig = createVerifier(signatureXml, AnswersWithText); + + await assert.rejects(sig.checkSignatureAsync(signedXml), { + message: /^invalid signature: the signature value /, + }); + expect(sig.getSignedReferences()).to.deep.equal([]); + }); + + it("rejects when the algorithm does, publishing nothing", async function () { + class Unreachable extends RemoteRsaSha256 { + async verifySignatureAsync(): Promise { + throw new Error("HSM unreachable"); + } + } + const sig = createVerifier(signatureXml, Unreachable); + + await assert.rejects(sig.checkSignatureAsync(signedXml), { message: "HSM unreachable" }); + expect(sig.getSignedReferences()).to.deep.equal([]); + }); + + it("resolves false, as checkSignature() returns false, for content that does not match its digest", async function () { + const sig = createVerifier(signatureXml, RemoteRsaSha256); + + expect(await sig.checkSignatureAsync(signedXml.replace("trusted", "tampered"))).to.be.false; + expect(sig.getSignedReferences()).to.deep.equal([]); + }); +});