diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
index fdc392fe8..a0bcd997d 100644
--- a/.idea/jarRepositories.xml
+++ b/.idea/jarRepositories.xml
@@ -16,5 +16,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 447eeae40..a210236d8 100644
--- a/build.gradle
+++ b/build.gradle
@@ -116,7 +116,8 @@ jlink {
'--strip-debug', '--compress', '2',
'--no-header-files', '--no-man-pages'
mergedModule {
- requires "java.xml"
+ //requires "java.xml"
+
}
launcher {
// TODO Sync version number with release.yml and Main.java (github.com/BrightSpots/rcv/issues/662)
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index fb11057e7..89db23f8c 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -12,7 +12,8 @@
requires org.apache.commons.csv;
requires org.apache.poi.ooxml;
requires commons.cli;
- // enable reflexive calls from network.brightspots.rcv into javafx.fxml
+ requires java.xml.crypto;
+ // enable reflexive calls from network.brightspots.rcv into javafx.fxml
opens network.brightspots.rcv;
// our main module
exports network.brightspots.rcv;
diff --git a/src/main/java/network/brightspots/rcv/CryptographyXMLParsers.java b/src/main/java/network/brightspots/rcv/CryptographyXMLParsers.java
index 06463cfe5..aa3e42099 100644
--- a/src/main/java/network/brightspots/rcv/CryptographyXMLParsers.java
+++ b/src/main/java/network/brightspots/rcv/CryptographyXMLParsers.java
@@ -25,7 +25,7 @@ public static class HartSignature {
@JacksonXmlProperty(localName = "SignedInfo")
SignedInfo signedInfo;
- @JacksonXmlProperty(localName = "SignatureValue")
+ @JacksonXmlProperty(isAttribute = true, localName = "SignatureValue")
String signatureValue;
@JacksonXmlProperty(localName = "KeyInfo")
@@ -41,15 +41,19 @@ static class SignedInfo {
@JacksonXmlProperty(localName = "Reference")
Reference reference;
+
+ // Required to match Hart's implementation of canonicalization
+ @JacksonXmlProperty(isAttribute = true, localName = "xmlns")
+ String xmlns = "http://www.w3.org/2000/09/xmldsig#";
}
static class CanonicalizationMethod {
- @JacksonXmlProperty(isAttribute = true)
+ @JacksonXmlProperty(isAttribute = true, localName = "Algorithm")
String algorithm;
}
static class SignatureMethod {
- @JacksonXmlProperty(isAttribute = true)
+ @JacksonXmlProperty(isAttribute = true, localName = "Algorithm")
String algorithm;
}
@@ -67,7 +71,7 @@ static class Reference {
}
static class DigestMethod {
- @JacksonXmlProperty(isAttribute = true)
+ @JacksonXmlProperty(isAttribute = true, localName = "Algorithm")
String algorithm;
}
@@ -82,10 +86,10 @@ static class KeyValue {
}
static class RSAKeyValue {
- @JacksonXmlProperty(localName = "Modulus")
+ @JacksonXmlProperty(isAttribute = true, localName = "Modulus")
String modulus;
- @JacksonXmlProperty(localName = "Exponent")
+ @JacksonXmlProperty(isAttribute = true, localName = "Exponent")
String exponent;
}
}
\ No newline at end of file
diff --git a/src/main/java/network/brightspots/rcv/FileUtils.java b/src/main/java/network/brightspots/rcv/FileUtils.java
index 7704c16c7..c0a3f706b 100644
--- a/src/main/java/network/brightspots/rcv/FileUtils.java
+++ b/src/main/java/network/brightspots/rcv/FileUtils.java
@@ -18,20 +18,52 @@
package network.brightspots.rcv;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
+import org.w3c.dom.Document;
+import org.xml.sax.SAXException;
+
+import javax.xml.crypto.Data;
+import javax.xml.crypto.NodeSetData;
+import javax.xml.crypto.OctetStreamData;
+import javax.xml.crypto.XMLCryptoContext;
+import javax.xml.crypto.dom.DOMCryptoContext;
+import javax.xml.crypto.dsig.CanonicalizationMethod;
+import javax.xml.crypto.dsig.TransformException;
+import javax.xml.crypto.dsig.XMLSignatureFactory;
+import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Result;
+import javax.xml.transform.Source;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerConfigurationException;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
import static network.brightspots.rcv.CryptographyXMLParsers.HartSignature;
import static network.brightspots.rcv.CryptographyXMLParsers.RSAKeyValue;
import static network.brightspots.rcv.Utils.isNullOrBlank;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
import java.math.BigInteger;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.security.DigestInputStream;
+import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
+import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
@@ -80,8 +112,37 @@ static T readFromXML(File xmlFile, Class classType) throws IOException {
}
}
+ static String tempMergeConflictGetHash(File file, String algorithm) {
+ MessageDigest digest;
+ try {
+ digest = MessageDigest.getInstance(algorithm);
+ } catch (NoSuchAlgorithmException e) {
+ Logger.severe("Failed to get algorithm " + algorithm);
+ return "[hash not available]";
+ }
+
+ try (InputStream is = Files.newInputStream(file.toPath())) {
+ try (DigestInputStream hashingStream = new DigestInputStream(is, digest)) {
+ while (hashingStream.readNBytes(1024).length > 0) {
+ // Read in 1kb chunks -- don't need to do anything in the body here
+ }
+ }
+ } catch (IOException e) {
+ Logger.severe("Failed to read file: %s", file.getAbsolutePath());
+ return "[hash not available]";
+ }
+
+ return Base64.getEncoder().encodeToString(digest.digest());
+ }
+
+ @SuppressWarnings("checkstyle:LeftCurly")
static boolean verifyPublicKeySignature(File publicKeyFile, File signatureKeyFile, File dataFile)
throws CouldNotVerifySignatureException {
+ // try {
+ // return XmlSignature.validate(signatureKeyFile);
+ // } catch (Exception e) {
+ // return false;
+ // }
// Load the public key and signature from their corresponding files
HartSignature hartSignature;
RSAKeyValue rsaKeyValue;
@@ -92,19 +153,53 @@ static boolean verifyPublicKeySignature(File publicKeyFile, File signatureKeyFil
throw new CouldNotVerifySignatureException("Failed to read files: " + e.getMessage());
}
+ EnsurePublicKeyMatchesExpectedValue(hartSignature, rsaKeyValue, signatureKeyFile, publicKeyFile);
+ EnsureDigestMatchesExpectedValue(dataFile, hartSignature);
+ EnsureSignatureMatchesExpectedValue(hartSignature, rsaKeyValue, dataFile);
+
+ return true;
+ }
+
+ static void EnsureDigestMatchesExpectedValue(File dataFile, HartSignature hartSignature) throws CouldNotVerifySignatureException
+ {
+ // Check if the filenames match
+ String actualFilename = dataFile.getName();
+ String expectedFilename = new File(hartSignature.signedInfo.reference.URI).getName();
+ if (!actualFilename.equals(expectedFilename))
+ {
+ throw new CouldNotVerifySignatureException(
+ "Signed file was %s but you provided %s".formatted(actualFilename, expectedFilename));
+ }
+
+ String actualDigest = FileUtils.tempMergeConflictGetHash(dataFile, "SHA-256");
+ String expectedDigest = hartSignature.signedInfo.reference.digestValue;
+
+ if (!actualDigest.equals(expectedDigest))
+ {
+ throw new CouldNotVerifySignatureException(
+ "Signed file had digest %s but the file you provided had %s".formatted(expectedDigest, actualDigest));
+ }
+ }
+
+ static void EnsurePublicKeyMatchesExpectedValue(HartSignature hartSignature, RSAKeyValue rsaKeyValue, File signatureKeyFile, File publicKeyFile) throws CouldNotVerifySignatureException {
// Sanity check: does the signature file match the known public key file?
// If not, the file may have been signed with a newer or older version than we support.
RSAKeyValue rsaFromFile = hartSignature.keyInfo.keyValue.rsaKeyValue;
- if (!rsaFromFile.exponent.equals(rsaKeyValue.exponent)
- || !rsaFromFile.modulus.equals(rsaKeyValue.modulus)) {
+ rsaFromFile.exponent = rsaFromFile.exponent.trim();
+ rsaFromFile.modulus = rsaFromFile.modulus.trim();
+ if (!rsaFromFile.exponent.equals(rsaKeyValue.exponent) || !rsaFromFile.modulus.equals(rsaKeyValue.modulus))
+ {
throw new CouldNotVerifySignatureException("%s was signed with a different public key than %s"
.formatted(signatureKeyFile.getAbsolutePath(), publicKeyFile.getAbsolutePath()));
}
+ }
+ static void EnsureSignatureMatchesExpectedValue(HartSignature hartSignature, RSAKeyValue rsaKeyValue, File dataFile) throws CouldNotVerifySignatureException
+ {
// Decode Base64
byte[] modulusBytes = Base64.getDecoder().decode(rsaKeyValue.modulus);
byte[] exponentBytes = Base64.getDecoder().decode(rsaKeyValue.exponent);
- byte[] signatureBytes = Base64.getDecoder().decode(hartSignature.signatureValue);
+ byte[] signatureBytes = Base64.getDecoder().decode(hartSignature.signatureValue.trim());
// Convert byte arrays to BigIntegers
// Use 1 as the signum to treat the bytes as positive
@@ -122,37 +217,78 @@ static boolean verifyPublicKeySignature(File publicKeyFile, File signatureKeyFil
throw new CouldNotVerifySignatureException("Failed to load signing algorithms: " + e.getMessage());
}
+ // Initialize the signature with the public key
try {
signature.initVerify(publicKey);
} catch (InvalidKeyException e) {
throw new CouldNotVerifySignatureException("Invalid Key: %s" + e.getMessage());
}
+ // Canonicalize the XML
+ byte[] canonicalizedBytes = CanonicalizeXml(hartSignature.signedInfo);
+
+ // Verify the signature
boolean verified;
- byte[] data;
try {
- // TODO read 1024 bytes at a time
- data = Files.readAllBytes(dataFile.toPath());
- signature.update(data);
+ signature.update(canonicalizedBytes);
verified = signature.verify(signatureBytes);
} catch (SignatureException e) {
throw new CouldNotVerifySignatureException("Signature failure: %s" + e.getMessage());
- } catch (IOException e) {
- throw new CouldNotVerifySignatureException("Failed to read data file: " + e.getMessage());
}
- return verified;
+ if (!verified) {
+ throw new CouldNotVerifySignatureException("Signature verification failed");
+ }
}
- static class UnableToCreateDirectoryException extends Exception {
+ static byte[] CanonicalizeXml(CryptographyXMLParsers.SignedInfo signedInfo) throws CouldNotVerifySignatureException {
+ // Canonicalize -- sort of. We need one change in addition to canonicalization to mirror what
+ // .NET Framework 4.8.1 does, which is what Hart uses.
+
+ // Build the XML mapper to serialize from the SignedInfo object
+ XmlMapper xmlMapper = new XmlMapper();
+ String xmlSignedInfo = null;
+ try {
+ xmlSignedInfo = xmlMapper.writeValueAsString(signedInfo);
+ } catch (JsonProcessingException e) {
+ throw new CouldNotVerifySignatureException("Failed to parse the signature XML file");
+ }
+
+ // Set up the canonicalization transform
+ XMLSignatureFactory sigFactory = XMLSignatureFactory.getInstance("DOM");
+ CanonicalizationMethod c14n;
+ try {
+ c14n = sigFactory.newCanonicalizationMethod(signedInfo.canonicalizationMethod.algorithm,
+ (C14NMethodParameterSpec) null);
+ } catch (NoSuchAlgorithmException e) {
+ throw new CouldNotVerifySignatureException(".sig.xml file uses an unsupported canonicalization algorithm");
+ } catch (InvalidAlgorithmParameterException e) {
+ throw new CouldNotVerifySignatureException(".sig.xml file uses an invalid canonicalization algorithm parameter");
+ }
+ // Read the serialized data into a stream and canonicalize it
+ InputStream xmlSignedInfoStream = new ByteArrayInputStream(xmlSignedInfo.getBytes(StandardCharsets.UTF_8));
+ OctetStreamData canonicalizedData;
+ try {
+ canonicalizedData = (OctetStreamData)c14n.transform(new OctetStreamData(xmlSignedInfoStream), null);
+ } catch (TransformException e) {
+ throw new CouldNotVerifySignatureException("Canonicalization failed: " + e.getMessage());
+ }
+
+ try {
+ return canonicalizedData.getOctetStream().readAllBytes();
+ } catch (IOException e) {
+ throw new CouldNotVerifySignatureException("Canonicalization returned an invalid result");
+ }
+ }
+
+ static class UnableToCreateDirectoryException extends Exception {
UnableToCreateDirectoryException(String message) {
super(message);
}
}
static class CouldNotVerifySignatureException extends Exception {
-
CouldNotVerifySignatureException(String message) {
super(message);
}
diff --git a/src/test/java/network/brightspots/rcv/TabulatorTests.java b/src/test/java/network/brightspots/rcv/TabulatorTests.java
index d9bf6aa7c..4d341eacc 100644
--- a/src/test/java/network/brightspots/rcv/TabulatorTests.java
+++ b/src/test/java/network/brightspots/rcv/TabulatorTests.java
@@ -18,6 +18,7 @@
package network.brightspots.rcv;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -321,9 +322,10 @@ static void setup() {
@DisplayName("Test basic RSA signature validation")
void testRSAValidation() throws FileUtils.CouldNotVerifySignatureException {
File publicKeyTxt = new File("/Users/arminsamii/Downloads/Public Key.txt");
- File signatureFile = new File("/Users/arminsamii/Downloads/CVRExport-7-27-2026 01-19-44 PM.zip.sig.xml");
- File dataFile = new File("/Users/arminsamii/Downloads/CVRExport-7-27-2026 01-19-44 PM.zip");
+ File signatureFile = new File("/Users/arminsamii/Downloads/cvrexport/1_ddc9fb5f-2762-462b-a5da-20c083751901.xml.sig.xml");
+ File dataFile = new File("/Users/arminsamii/Downloads/cvrexport/1_ddc9fb5f-2762-462b-a5da-20c083751901.xml");
assertTrue(FileUtils.verifyPublicKeySignature(publicKeyTxt, signatureFile, dataFile));
+
}
@Test