Summary
toPem() keeps every message in a value, and the verification options in the README describe publicCert as "your certificate as a string, a string of multiple certs in PEM format, or a Buffer" (README). privateKey when signing, and publicCert when verifying, are then passed to Node's crypto, which uses one key from the value and ignores the rest. The README doesn't say this, and nothing reports what was ignored.
A privateKey holding two private keys signs with the first.
A verification publicCert holding two keys trusts only one of them:
publicCert |
Accepts a signature by |
| public key A, then public key B |
A only |
| certificate A, then certificate B |
A only |
| certificate A, then public key B |
B only |
| public key B, then certificate A |
B only |
So "a string of multiple certs" verifies against only its first certificate. When a value mixes the two forms, Node takes the public key whatever the order. Node 16 and Node 26 behave the same way.
This fails closed: a signature by an ignored key is rejected, not accepted. The cost is confusion. A caller who puts an old and a new certificate in one value for a key rollover will have the new one rejected on the day of the switch, and the README told them that would work.
Reproduction
Run from the repository root, with xml-crypto resolving to 6.3.0:
const crypto = require("crypto");
const xmldom = require("@xmldom/xmldom");
const xpath = require("xpath");
const { SignedXml } = require("xml-crypto");
function keyPair() {
const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
return {
privateKey: privateKey.export({ type: "pkcs8", format: "pem" }),
publicKey: publicKey.export({ type: "spki", format: "pem" }),
};
}
function sign(privateKey) {
const sig = new SignedXml({
privateKey,
canonicalizationAlgorithm: "http://www.w3.org/2001/10/xml-exc-c14n#",
signatureAlgorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
});
sig.addReference({
xpath: "//*[local-name(.)='book']",
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("<library><book/></library>");
return sig.getSignedXml();
}
function verifies(xml, publicCert) {
const doc = new xmldom.DOMParser().parseFromString(xml, "text/xml");
const sig = new SignedXml({ publicCert });
sig.loadSignature(xpath.select1("//*[local-name(.)='Signature']", doc));
try {
return sig.checkSignature(xml);
} catch {
return false;
}
}
const a = keyPair();
const b = keyPair();
const signedByA = sign(a.privateKey);
const signedByB = sign(b.privateKey);
// Signing with two private keys uses the first.
console.log(verifies(sign(a.privateKey + b.privateKey), a.publicKey)); // true
console.log(verifies(sign(a.privateKey + b.privateKey), b.publicKey)); // false
// Verifying with two public keys trusts the first.
console.log(verifies(signedByA, a.publicKey + b.publicKey)); // true
console.log(verifies(signedByB, a.publicKey + b.publicKey)); // false
Scope for 6.4
Nothing here may fail a configuration that signs or verifies today.
- Fix the README.
privateKey holds one private key, and a file that also holds its certificate is fine. Verification takes one key from publicCert, and from several certificates it takes the first, which is how a chain given leaf first works. To trust several independent keys, verify with each in turn, as node-saml does for its idpCert array. Replace "a string of multiple certs in PEM format" with that.
- Test what the README then promises: a value holding two certificates verifies a signature made with the first certificate's key, and does not verify one made with the second's. The second half is the important one. It stops a later change from "fixing" this by trying each key in turn, which would make a chain trust its issuer's key.
Warnings for the ambiguous cases were considered and rejected. Detecting them means re-parsing key material that Node already parses on every sign and verify call, to second-guess Node's own key parser, for a mistake that already fails closed. That is duplicate machinery with no security benefit, so this issue asks for no warning and no new error.
Not in this issue
Summary
toPem()keeps every message in a value, and the verification options in the README describepublicCertas "your certificate as a string, a string of multiple certs in PEM format, or a Buffer" (README).privateKeywhen signing, andpublicCertwhen verifying, are then passed to Node'scrypto, which uses one key from the value and ignores the rest. The README doesn't say this, and nothing reports what was ignored.A
privateKeyholding two private keys signs with the first.A verification
publicCertholding two keys trusts only one of them:publicCertSo "a string of multiple certs" verifies against only its first certificate. When a value mixes the two forms, Node takes the public key whatever the order. Node 16 and Node 26 behave the same way.
This fails closed: a signature by an ignored key is rejected, not accepted. The cost is confusion. A caller who puts an old and a new certificate in one value for a key rollover will have the new one rejected on the day of the switch, and the README told them that would work.
Reproduction
Run from the repository root, with
xml-cryptoresolving to 6.3.0:Scope for 6.4
Nothing here may fail a configuration that signs or verifies today.
privateKeyholds one private key, and a file that also holds its certificate is fine. Verification takes one key frompublicCert, and from several certificates it takes the first, which is how a chain given leaf first works. To trust several independent keys, verify with each in turn, as node-saml does for itsidpCertarray. Replace "a string of multiple certs in PEM format" with that.Warnings for the ambiguous cases were considered and rejected. Detecting them means re-parsing key material that Node already parses on every sign and verify call, to second-guess Node's own key parser, for a mistake that already fails closed. That is duplicate machinery with no security benefit, so this issue asks for no warning and no new error.
Not in this issue