diff --git a/README.md b/README.md index baba6079..497943c3 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,11 @@ deprecated and will be private in 7.0; call `process()` instead. Calling either `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`. `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). @@ -425,6 +430,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. @@ -445,6 +451,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 @@ -572,9 +579,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. +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"); @@ -582,11 +589,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() { @@ -594,28 +608,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 2260806c..df5d0d30 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. 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. 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()`. `computeSignatureAsync()` and `checkSignatureAsync()` return a promise. + */ +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 2a7fee3b..a6735089 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[]; }; @@ -102,6 +103,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. 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", +); + 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()`.", @@ -348,11 +355,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)`, or `checkSignatureAsync(xml)` + * for a signature algorithm that verifies asynchronously. */ checkSignature(xml: string, callback: (error: Error | null, isValid?: boolean) => void): void; checkSignature( @@ -362,7 +372,93 @@ export class SignedXml { if (callback != null && typeof callback !== "function") { throw new Error("Last parameter must be a callback function"); } + if (callback) { + 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; @@ -450,33 +546,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) { @@ -564,19 +643,40 @@ 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") { - signer.getSignature(signedInfoCanon, this.privateKey, callback); + /* eslint-disable-next-line deprecation/deprecation */ + 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) { if (name == null) { throw new Error("signatureAlgorithm is required"); @@ -1000,6 +1100,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)`, or `computeSignatureAsync(xml)` + * for a signature algorithm that signs asynchronously. */ computeSignature(xml: string, callback: ErrorFirstCallback): void; @@ -1021,6 +1123,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)`, or + * `computeSignatureAsync(xml, options)` for a signature algorithm that signs asynchronously. */ computeSignature( xml: string, @@ -1041,7 +1145,66 @@ export class SignedXml { callback = callbackParam as ErrorFirstCallback; options = (options ?? {}) as ComputeSignatureOptions; } + if (typeof callback === "function") { + 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); if (doc.doctype != null) { warnDocumentTypeDeclaration(); @@ -1198,26 +1361,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 05ce63c9..87e622f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -201,26 +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. 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. 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([]); + }); +}); 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..15a2d4e6 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,6 +37,7 @@ 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"]);