diff --git a/README.md b/README.md index dfe164f2..0d64f0f7 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ This will help prevent future XML signature wrapping attacks. - RSA-SHA256 - RSA-SHA256 with MGF1 - RSA-SHA512 +- ECDSA-SHA256 +- ECDSA-SHA512 HMAC-SHA1 is also available but it is disabled by default diff --git a/example/example.js b/example/example.js index 32c9e8b6..d5c24ac5 100644 --- a/example/example.js +++ b/example/example.js @@ -5,10 +5,17 @@ const dom = require("@xmldom/xmldom").DOMParser; const SignedXml = require("xml-crypto").SignedXml; const fs = require("fs"); -function signXml(xml, xpath, key, dest) { +function signXml(xml, xpath, key, dest, cert) { const sig = new SignedXml(); + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#" + sig.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" sig.privateKey = fs.readFileSync(key); - sig.addReference(xpath); + sig.publicCert = fs.readFileSync(cert); // To populate KeyInfo, as an example + sig.addReference({ + xpath, + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); sig.computeSignature(xml); fs.writeFileSync(dest, sig.getSignedXml()); } @@ -20,7 +27,8 @@ function validateXml(xml, key) { doc, )[0]; const sig = new SignedXml(); - sig.publicCert = key; + sig.publicCert = fs.readFileSync(key); // Note since the XML has a KeyInfo, this cert is NOT doing anything! + // Validate the cert in `KeyInfo` on your own if that is your security model. See: sig.loadSignature(signature.toString()); const res = sig.checkSignature(xml); if (!res) { @@ -32,7 +40,7 @@ function validateXml(xml, key) { const xml = "" + "" + "Harry Potter" + "" + ""; //sign an xml document -signXml(xml, "//*[local-name(.)='book']", "client.pem", "result.xml"); +signXml(xml, "//*[local-name(.)='book']", "client.pem", "result.xml", "client_public.pem"); console.log("xml signed successfully"); diff --git a/example/local_example.js b/example/local_example.js new file mode 100644 index 00000000..4065a7cf --- /dev/null +++ b/example/local_example.js @@ -0,0 +1,56 @@ +/* eslint-disable no-console */ +// Run with `npm run example`, requires one-time `npm run build` to generate `/lib` code (and re-run if you update `/src`) + +const select = require("xpath").select +const dom = require("@xmldom/xmldom").DOMParser; +const SignedXml = require("../").SignedXml; +const fs = require("fs"); + +function signXml(xml, xpath, key, dest, cert) { + const sig = new SignedXml(); + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#" + sig.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" + sig.privateKey = fs.readFileSync(__dirname + "/" + key); + sig.publicCert = fs.readFileSync(__dirname + "/" + cert); // To populate KeyInfo, as an example + sig.addReference({ + xpath, + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + sig.computeSignature(xml); + fs.writeFileSync(__dirname + "/" + dest, sig.getSignedXml()); +} + +function validateXml(xml, key) { + const doc = new dom().parseFromString(xml); + const signature = select( + "/*/*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']", + doc, + )[0]; + const sig = new SignedXml(); + sig.publicCert = fs.readFileSync(__dirname + "/" + key); // Note since the XML has a KeyInfo, this cert is NOT doing anything! + // Validate the cert in `KeyInfo` on your own if that is your security model. See: + sig.loadSignature(signature.toString()); + const res = sig.checkSignature(xml); + if (!res) { + console.log(sig.validationErrors); + } + return res; +} + +const xml = "" + "" + "Harry Potter" + "" + ""; + +//sign an xml document +signXml(xml, "//*[local-name(.)='book']", "client.pem", "result.xml", "client_public.pem"); + +console.log("xml signed successfully"); + +const signedXml = fs.readFileSync(__dirname + "/" + "result.xml").toString(); +console.log("validating signature..."); + +//validate an xml document +if (validateXml(signedXml, "client_public.pem")) { + console.log("signature is valid"); +} else { + console.log("signature not valid"); +} \ No newline at end of file diff --git a/example/result.xml b/example/result.xml new file mode 100644 index 00000000..35eb3086 --- /dev/null +++ b/example/result.xml @@ -0,0 +1 @@ +Harry Potter9d/ciWlVZkaJnJ3KBB5WY1H2Y8WRXPB2DquM0goT8jY=uxmxGw2O3B6ylkhEXOaZd1Iupgy3sHtCoBTgbmSMSnHYOitiHXRdHjJGJdMG4EMSgItsB6k5gxrKeyQ/5LkwvMqSc0VRPXd9vavt0pYatqwWDO/r6WITLb0jzymJfNDJ4lr4OcqH4zBKX8Deb6EpS9L7S6OXNqd1vOZ0STMSSaM=MIIBxDCCAW6gAwIBAgIQxUSXFzWJYYtOZnmmuOMKkjANBgkqhkiG9w0BAQQFADAWMRQwEgYDVQQDEwtSb290IEFnZW5jeTAeFw0wMzA3MDgxODQ3NTlaFw0zOTEyMzEyMzU5NTlaMB8xHTAbBgNVBAMTFFdTRTJRdWlja1N0YXJ0Q2xpZW50MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC+L6aB9x928noY4+0QBsXnxkQE4quJl7c3PUPdVu7k9A02hRG481XIfWhrDY5i7OEB7KGW7qFJotLLeMec/UkKUwCgv3VvJrs2nE9xO3SSWIdNzADukYh+Cxt+FUU6tUkDeqg7dqwivOXhuOTRyOI3HqbWTbumaLdc8jufz2LhaQIDAQABo0swSTBHBgNVHQEEQDA+gBAS5AktBh0dTwCNYSHcFmRjoRgwFjEUMBIGA1UEAxMLUm9vdCBBZ2VuY3mCEAY3bACqAGSKEc+41KpcNfQwDQYJKoZIhvcNAQEEBQADQQAfIbnMPVYkNNfX1tG1F+qfLhHwJdfDUZuPyRPucWF5qkh6sSdWVBY5sT/txBnVJGziyO8DPYdu2fPMER8ajJfl \ No newline at end of file diff --git a/package.json b/package.json index 8bd4caa0..5d96b443 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "prettier-format": "prettier --config .prettierrc.json --write .", "prerelease": "git clean -xfd && npm ci && npm test", "release": "release-it", - "test": "nyc mocha" + "test": "nyc mocha", + "example": "node ./example/local_example.js" }, "dependencies": { "@xmldom/is-dom-node": "^1.0.1", diff --git a/src/signature-algorithms.ts b/src/signature-algorithms.ts index 52e09280..7faff756 100644 --- a/src/signature-algorithms.ts +++ b/src/signature-algorithms.ts @@ -53,6 +53,36 @@ export class RsaSha256 implements SignatureAlgorithm { }; } +export class EcdsaSha256 implements SignatureAlgorithm { + getSignature = createOptionalCallbackFunction( + (signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string => { + // Maybe the fix for ts-ignore below? + // const parsedPrivateKey = crypto.createPrivateKey(privateKey); + const signer = crypto.createSign("SHA256"); + signer.update(signedInfo); + // @ts-ignore + const res = signer.sign({ key: privateKey, dsaEncoding: 'ieee-p1363' }, "base64"); + + return res; + }, + ); + + verifySignature = createOptionalCallbackFunction( + (material: string, key: crypto.KeyLike, signatureValue: string): boolean => { + const publicKey = crypto.createPublicKey(key); + const verifier = crypto.createVerify("SHA256"); + verifier.update(material); + const res = verifier.verify({ key: publicKey, dsaEncoding: 'ieee-p1363' }, signatureValue, "base64"); + + return res; + }, + ); + + getAlgorithmName = () => { + return "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256"; + }; +} + export class RsaSha256Mgf1 implements SignatureAlgorithm { getSignature = createOptionalCallbackFunction( (signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string => { @@ -126,6 +156,36 @@ export class RsaSha512 implements SignatureAlgorithm { }; } +export class EcdsaSha512 implements SignatureAlgorithm { + getSignature = createOptionalCallbackFunction( + (signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string => { + // Maybe the fix for ts-ignore below? + // const parsedPrivateKey = crypto.createPrivateKey(privateKey); + const signer = crypto.createSign("SHA512"); + signer.update(signedInfo); + // @ts-ignore + const res = signer.sign({ key: privateKey, dsaEncoding: 'ieee-p1363' }, "base64"); + + return res; + }, + ); + + verifySignature = createOptionalCallbackFunction( + (material: string, key: crypto.KeyLike, signatureValue: string): boolean => { + const publicKey = crypto.createPublicKey(key); + const verifier = crypto.createVerify("SHA512"); + verifier.update(material); + const res = verifier.verify({ key: publicKey, dsaEncoding: 'ieee-p1363' }, signatureValue, "base64"); + + return res; + }, + ); + + getAlgorithmName = () => { + return "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"; + }; +} + export class HmacSha1 implements SignatureAlgorithm { getSignature = createOptionalCallbackFunction( (signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string => { diff --git a/src/signed-xml.ts b/src/signed-xml.ts index 663d3d0e..2626778a 100644 --- a/src/signed-xml.ts +++ b/src/signed-xml.ts @@ -117,8 +117,10 @@ export class SignedXml { SignatureAlgorithms: Record SignatureAlgorithm> = { "http://www.w3.org/2000/09/xmldsig#rsa-sha1": signatureAlgorithms.RsaSha1, "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256": signatureAlgorithms.RsaSha256, + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256": signatureAlgorithms.EcdsaSha256, "http://www.w3.org/2007/05/xmldsig-more#sha256-rsa-MGF1": signatureAlgorithms.RsaSha256Mgf1, "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512": signatureAlgorithms.RsaSha512, + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512": signatureAlgorithms.EcdsaSha512, // Disabled by default due to key confusion concerns. // 'http://www.w3.org/2000/09/xmldsig#hmac-sha1': SignatureAlgorithms.HmacSha1 }; diff --git a/src/types.ts b/src/types.ts index 89c0b304..af92e203 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,8 +30,10 @@ export type HashAlgorithmType = export type SignatureAlgorithmType = | "http://www.w3.org/2000/09/xmldsig#rsa-sha1" | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256" | "http://www.w3.org/2007/05/xmldsig-more#sha256-rsa-MGF1" | "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" + | "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512" | "http://www.w3.org/2000/09/xmldsig#hmac-sha1" | string; diff --git a/test/ecdsa-signatures.spec.ts b/test/ecdsa-signatures.spec.ts new file mode 100644 index 00000000..c5929f1c --- /dev/null +++ b/test/ecdsa-signatures.spec.ts @@ -0,0 +1,153 @@ +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as xmldom from "@xmldom/xmldom"; +import * as xpath from "xpath"; +import * as isDomNode from "@xmldom/is-dom-node"; +import { expect } from "chai"; +import { SignedXml } from "../src/index"; + +const signatureNamespace = "http://www.w3.org/2000/09/xmldsig#"; +const canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; +const digestAlgorithm = "http://www.w3.org/2001/04/xmlenc#sha256"; +const payload = 'attacker-controlled'; + +function createSignedXml(signatureAlgorithm: string, sign: (signedInfo: string) => string): string { + const digestValue = crypto.createHash("sha256").update(payload).digest("base64"); + const signedInfo = + `` + + `` + + `` + + '' + + `` + + `` + + `${digestValue}`; + + return ( + `${payload}${signedInfo}` + + `${sign(signedInfo)}` + ); +} + +function loadSignature(xml: string, publicCert: crypto.KeyLike): SignedXml { + const doc = new xmldom.DOMParser().parseFromString(xml); + const signature = xpath.select1( + `//*[local-name(.)='Signature' and namespace-uri(.)='${signatureNamespace}']`, + doc, + ); + isDomNode.assertIsNodeLike(signature); + const verifier = new SignedXml({ publicCert }); + verifier.loadSignature(signature); + return verifier; +} + +function signWith(signatureAlgorithm: string): string { + const signer = new SignedXml({ + privateKey: fs.readFileSync("./test/static/client_ecdsa.pem"), + signatureAlgorithm, + canonicalizationAlgorithm, + }); + signer.addReference({ + xpath: "//*[local-name(.)='x']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: [canonicalizationAlgorithm], + }); + signer.computeSignature(''); + return signer.getSignedXml(); +} + +describe("ECDSA signatures", function () { + it("verifies the external ECDSA signature fixture", function () { + const xml = fs.readFileSync("./test/static/valid_signature_ecdsa.xml", "utf8"); + const verifier = loadSignature(xml, fs.readFileSync("./test/static/ecdsa_external.pem")); + + expect(verifier.checkSignature(xml)).to.be.true; + expect(verifier.getSignedReferences()).to.have.length(1); + }); + + for (const hash of ["sha256", "sha512"]) { + const signatureAlgorithm = `http://www.w3.org/2001/04/xmldsig-more#ecdsa-${hash}`; + + describe(signatureAlgorithm, function () { + it("verifies a document signed by the library", function () { + const xml = signWith(signatureAlgorithm); + const verifier = loadSignature( + xml, + fs.readFileSync("./test/static/client_public_ecdsa.pem"), + ); + + expect(verifier.checkSignature(xml)).to.be.true; + }); + + it("rejects a signed document after its referenced content is modified", function () { + const xml = signWith(signatureAlgorithm); + const doc = new xmldom.DOMParser().parseFromString(xml); + const node = xpath.select1("//*[local-name(.)='x']", doc); + isDomNode.assertIsElementNode(node); + node.setAttribute("attr", "manipulatedValue"); + const manipulatedXml = new xmldom.XMLSerializer().serializeToString(doc); + const verifier = loadSignature( + manipulatedXml, + fs.readFileSync("./test/static/client_public_ecdsa.pem"), + ); + + expect(verifier.checkSignature(manipulatedXml)).to.be.false; + }); + + it("does not expose signed references after rejecting a malformed signature", function () { + const xml = createSignedXml(signatureAlgorithm, () => "AA=="); + const verifier = loadSignature( + xml, + fs.readFileSync("./test/static/client_public_ecdsa.pem"), + ); + + expect(() => verifier.checkSignature(xml)).to.throw(); + expect(verifier.getSignedReferences()).to.deep.equal([]); + }); + + // XMLDSig 1.1 ยง6.4.3: https://www.w3.org/TR/xmldsig-core1/#sec-ECDSA + it("rejects an RSA private key instead of emitting an ECDSA-labeled RSA signature", function () { + const signer = new SignedXml({ + privateKey: fs.readFileSync("./test/static/client.pem"), + signatureAlgorithm, + canonicalizationAlgorithm, + }); + signer.addReference({ + xpath: "//*[@Id='payload']", + digestAlgorithm, + transforms: [canonicalizationAlgorithm], + }); + + expect(() => signer.computeSignature(`${payload}`)).to.throw(); + }); + + it("rejects an RSA signature whose SignatureMethod declares ECDSA", function () { + const privateKey = fs.readFileSync("./test/static/client.pem"); + const publicCert = fs.readFileSync("./test/static/client_public.pem"); + const sign = (signedInfo: string) => + crypto.sign(hash, Buffer.from(signedInfo), privateKey).toString("base64"); + const rsaXml = createSignedXml(`http://www.w3.org/2001/04/xmldsig-more#rsa-${hash}`, sign); + expect(loadSignature(rsaXml, publicCert).checkSignature(rsaXml)).to.be.true; + + const xml = createSignedXml(signatureAlgorithm, sign); + const verifier = loadSignature(xml, publicCert); + + expect(() => verifier.checkSignature(xml)).to.throw(); + }); + + it("verifies an externally signed document using a public KeyObject", function () { + const privateKey = fs.readFileSync("./test/static/client_ecdsa.pem"); + const publicCert = fs.readFileSync("./test/static/client_public_ecdsa.pem"); + const xml = createSignedXml(signatureAlgorithm, (signedInfo) => + crypto + .sign(hash, Buffer.from(signedInfo), { key: privateKey, dsaEncoding: "ieee-p1363" }) + .toString("base64"), + ); + expect(loadSignature(xml, publicCert).checkSignature(xml)).to.be.true; + const verifier = loadSignature(xml, crypto.createPublicKey(publicCert)); + + expect(verifier.checkSignature(xml)).to.be.true; + expect(verifier.getSignedReferences()).to.deep.equal([payload]); + }); + }); + } +}); diff --git a/test/static/client_ecdsa.pem b/test/static/client_ecdsa.pem new file mode 100644 index 00000000..ab921e3c --- /dev/null +++ b/test/static/client_ecdsa.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIOg1FiE/iu8uoRXX3UvBs53JIEsjkcf9IbMpJsfkvG30oAoGCCqGSM49 +AwEHoUQDQgAE69ImJGeiClnYW20zXK3L+w5q463+PN302fpmEDE/6xTEbG/KIxcA +d77nrzo5Iq4ve2SqL0Bk1Yxk2V/1f8t52g== +-----END EC PRIVATE KEY----- \ No newline at end of file diff --git a/test/static/client_public_ecdsa.pem b/test/static/client_public_ecdsa.pem new file mode 100644 index 00000000..eb492ca0 --- /dev/null +++ b/test/static/client_public_ecdsa.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB3zCCAYWgAwIBAgIUYjTFVRq9oJ9JsEdzs9GEp+Ro2nYwCgYIKoZIzj0EAwIw +RTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGElu +dGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNjAxMjcyMDA0MjhaFw0yNzAxMjIy +MDA0MjhaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYD +VQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAATr0iYkZ6IKWdhbbTNcrcv7Dmrjrf483fTZ+mYQMT/rFMRsb8ojFwB3 +vuevOjkiri97ZKovQGTVjGTZX/V/y3nao1MwUTAdBgNVHQ4EFgQUKdQQ4ogzLU06 +Gypz35quxaLJr50wHwYDVR0jBBgwFoAUKdQQ4ogzLU06Gypz35quxaLJr50wDwYD +VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiADI3VXNdnYMIIFlVLS6Ss2 +E+tamOigyNvruaKT+0YiGQIhALYU9Dyu2fRRvULX7sBpv7Dxk/4ynUCcCTJ1L9SK +O9bJ +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/static/ecdsa_external.pem b/test/static/ecdsa_external.pem new file mode 100644 index 00000000..4be53cb4 --- /dev/null +++ b/test/static/ecdsa_external.pem @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE----- +MIIBEDCBuKADAgECAggVM8YfT8EdfTAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwR0 +ZXN0MB4XDTIxMDczMDA3NTU1NVoXDTIxMDczMDA3NTU1NVowDzENMAsGA1UEAxME +dGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHpb/uly4GB+9G2BKgToi57/ +XzqapEdo2Pys48RMtj8tc6WE2BO0TJoR1KJZ1Bu05OQ0aOwyFGo1QY65V6sgONIw +CgYIKoZIzj0EAwIDRwAwRAIgV50ULGELC8aTSxOmTPptqHjOgKlbKLlQ+CuErOUB +CucCIBvn/IWSLPVqwoQNzP7VnRgk9mZvUuTW0MaIf/4lhOc7 +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/static/valid_signature_ecdsa.xml b/test/static/valid_signature_ecdsa.xml new file mode 100644 index 00000000..24321a33 --- /dev/null +++ b/test/static/valid_signature_ecdsa.xml @@ -0,0 +1 @@ +Just remember ALL CAPS when you spell the man name9A4u9FDDvQS0fcqS76EbS5Ir95wh3JOu2QldyyfWrHs=A1Vz+93PgSq3auxwqW087exDtOYgSazYTSgYlXZgWVZI6tKXwrZZ9O4SdQiHHI4Y2Ro8Ho5zgf+HjpN/ushvPw==MIIBEDCBuKADAgECAggVM8YfT8EdfTAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwR0ZXN0MB4XDTIxMDczMDA3NTU1NVoXDTIxMDczMDA3NTU1NVowDzENMAsGA1UEAxMEdGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHpb/uly4GB+9G2BKgToi57/XzqapEdo2Pys48RMtj8tc6WE2BO0TJoR1KJZ1Bu05OQ0aOwyFGo1QY65V6sgONIwCgYIKoZIzj0EAwIDRwAwRAIgV50ULGELC8aTSxOmTPptqHjOgKlbKLlQ+CuErOUBCucCIBvn/IWSLPVqwoQNzP7VnRgk9mZvUuTW0MaIf/4lhOc7 \ No newline at end of file