diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a67b55..e4794d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,16 +8,21 @@ on: jobs: test: - name: test + name: test (Java ${{ matrix.java }}, ${{ matrix.os }}) strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] + # The library ships at Java 8 (maven.compiler.release=8), so run the + # suite on the Java 8 runtime as well as later LTS/current releases. + # This guards against Java 9+ APIs and runtime-only regressions such as + # crypto algorithms that only exist on newer JDKs. + java: ['8', '11', '17', '21'] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin - java-version: '21' + java-version: ${{ matrix.java }} - run: mvn --batch-mode --update-snapshots test diff --git a/pom.xml b/pom.xml index 518498b..a74a56b 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,11 @@ - 21 + + 8 UTF-8 5.10.2 @@ -41,6 +45,9 @@ org.apache.maven.plugins maven-compiler-plugin 3.13.0 + org.apache.maven.plugins diff --git a/src/main/java/com/swancommunity/owid/Crypto.java b/src/main/java/com/swancommunity/owid/Crypto.java index 01730e2..ff767f7 100644 --- a/src/main/java/com/swancommunity/owid/Crypto.java +++ b/src/main/java/com/swancommunity/owid/Crypto.java @@ -16,6 +16,7 @@ package com.swancommunity.owid; +import java.io.ByteArrayOutputStream; import java.security.GeneralSecurityException; import java.security.KeyFactory; import java.security.KeyPair; @@ -26,6 +27,7 @@ import java.security.spec.ECGenParameterSpec; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; import java.util.Base64; /** @@ -35,9 +37,12 @@ *

OWID uses ECDSA with the NIST P-256 curve (also known as secp256r1 or * prime256v1) and the SHA-256 hash, as required by the specification. The * signature is the 64 byte concatenation of the big endian r and s values - * (IEEE P1363 format). The JDK signature algorithm - * {@code SHA256withECDSAinP1363Format} produces and consumes this raw 64 byte - * form directly, so no manual conversion to or from ASN.1 DER is needed.

+ * (IEEE P1363 format). This class signs and verifies with the standard + * {@code SHA256withECDSA} algorithm, which produces and consumes ASN.1 DER, and + * converts between DER and the raw 64 byte form here. (The JDK + * {@code SHA256withECDSAinP1363Format} algorithm would do that conversion for + * us, but it is only available from Java 15 onwards; doing it manually keeps the + * library usable on Java 8.)

* *

An instance can hold both keys, or only one of them when created with * {@link #newSignOnly(String)} or {@link #newVerifyOnly(String)}.

@@ -57,10 +62,15 @@ public final class Crypto { private static final String CURVE = "secp256r1"; /** - * The signature algorithm that produces and consumes the raw 64 byte - * r concatenated with s form rather than ASN.1 DER. + * The signature algorithm. {@code SHA256withECDSA} produces and consumes + * ASN.1 DER and is available on Java 8; the raw 64 byte r||s form used on + * the wire is converted to and from DER by {@link #derToRaw(byte[])} and + * {@link #rawToDer(byte[])}. */ - private static final String SIGNATURE_ALGORITHM = "SHA256withECDSAinP1363Format"; + private static final String SIGNATURE_ALGORITHM = "SHA256withECDSA"; + + /** Byte length of each of the r and s values for the P-256 curve. */ + private static final int COORDINATE_LENGTH = Owid.SIGNATURE_LENGTH / 2; private final PrivateKey privateKey; private final PublicKey publicKey; @@ -159,11 +169,11 @@ public byte[] signByteArray(byte[] data) throws OwidException { Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM); signature.initSign(privateKey); signature.update(data); - byte[] bytes = signature.sign(); - if (bytes.length != Owid.SIGNATURE_LENGTH) { - throw Io.invalidSignatureLength(bytes.length); + byte[] raw = derToRaw(signature.sign()); + if (raw.length != Owid.SIGNATURE_LENGTH) { + throw Io.invalidSignatureLength(raw.length); } - return bytes; + return raw; } catch (GeneralSecurityException e) { throw new OwidException("key operation failed because " + e.getMessage(), e); @@ -196,13 +206,166 @@ public boolean verifyByteArray(byte[] data, byte[] signature) Signature verifier = Signature.getInstance(SIGNATURE_ALGORITHM); verifier.initVerify(publicKey); verifier.update(data); - return verifier.verify(signature); - } catch (GeneralSecurityException e) { + return verifier.verify(rawToDer(signature)); + } catch (GeneralSecurityException | OwidException e) { // Bytes that can not form a valid signature can never verify. return false; } } + /** + * Converts an ASN.1 DER encoded ECDSA signature into the raw 64 byte form, + * the 32 byte big endian r value followed by the 32 byte big endian s + * value. The DER form is a SEQUENCE holding two INTEGERs. + * + * @throws OwidException when the DER structure is malformed. + */ + private static byte[] derToRaw(byte[] der) throws OwidException { + int[] position = {0}; + if (der.length == 0 || (der[position[0]] & 0xFF) != 0x30) { + throw new OwidException("signature is not a DER sequence"); + } + position[0] += 1; + readDerLength(der, position); // skip the SEQUENCE length + byte[] r = readDerInteger(der, position); + byte[] s = readDerInteger(der, position); + byte[] raw = new byte[Owid.SIGNATURE_LENGTH]; + System.arraycopy(padCoordinate(r), 0, raw, 0, COORDINATE_LENGTH); + System.arraycopy(padCoordinate(s), 0, raw, COORDINATE_LENGTH, + COORDINATE_LENGTH); + return raw; + } + + /** + * Converts a raw 64 byte signature (32 byte big endian r followed by 32 + * byte big endian s) into an ASN.1 DER encoded ECDSA signature. + * + * @throws OwidException when the signature is not 64 bytes. + */ + private static byte[] rawToDer(byte[] signature) throws OwidException { + if (signature.length != Owid.SIGNATURE_LENGTH) { + throw Io.invalidSignatureLength(signature.length); + } + byte[] rInteger = encodeDerInteger( + Arrays.copyOfRange(signature, 0, COORDINATE_LENGTH)); + byte[] sInteger = encodeDerInteger( + Arrays.copyOfRange(signature, COORDINATE_LENGTH, + Owid.SIGNATURE_LENGTH)); + byte[] length = encodeDerLength(rInteger.length + sInteger.length); + ByteArrayOutputStream der = new ByteArrayOutputStream(); + der.write(0x30); + der.write(length, 0, length.length); + der.write(rInteger, 0, rInteger.length); + der.write(sInteger, 0, sInteger.length); + return der.toByteArray(); + } + + /** + * Reads an ASN.1 length at {@code position[0]}, advances past the length + * bytes, and returns the length value. + */ + private static int readDerLength(byte[] der, int[] position) + throws OwidException { + if (position[0] >= der.length) { + throw new OwidException("signature length is missing"); + } + int first = der[position[0]++] & 0xFF; + if (first < 0x80) { + return first; + } + int byteCount = first & 0x7F; + if (byteCount == 0 || position[0] + byteCount > der.length) { + throw new OwidException("signature length is malformed"); + } + int value = 0; + for (int index = 0; index < byteCount; index++) { + value = (value << 8) | (der[position[0]++] & 0xFF); + } + return value; + } + + /** + * Reads a DER INTEGER at {@code position[0]}, advances past it, and returns + * the big endian value with any leading 0x00 sign byte removed. + */ + private static byte[] readDerInteger(byte[] der, int[] position) + throws OwidException { + if (position[0] >= der.length || (der[position[0]] & 0xFF) != 0x02) { + throw new OwidException("signature value is not a DER integer"); + } + position[0] += 1; + int length = readDerLength(der, position); + if (length <= 0 || position[0] + length > der.length) { + throw new OwidException("signature value length is malformed"); + } + byte[] value = Arrays.copyOfRange(der, position[0], position[0] + length); + position[0] += length; + return trimLeadingZeros(value); + } + + /** Left pads the big endian value with zeros to the coordinate length. */ + private static byte[] padCoordinate(byte[] value) throws OwidException { + byte[] trimmed = trimLeadingZeros(value); + if (trimmed.length > COORDINATE_LENGTH) { + throw new OwidException("signature value is too large"); + } + byte[] padded = new byte[COORDINATE_LENGTH]; + System.arraycopy(trimmed, 0, padded, + COORDINATE_LENGTH - trimmed.length, trimmed.length); + return padded; + } + + /** + * Encodes a big endian value as a DER INTEGER, prepending a 0x00 sign byte + * when the high bit of the first content byte is set so the value reads as + * positive. + */ + private static byte[] encodeDerInteger(byte[] value) { + byte[] trimmed = trimLeadingZeros(value); + boolean prependZero = (trimmed[0] & 0x80) != 0; + int contentLength = trimmed.length + (prependZero ? 1 : 0); + byte[] length = encodeDerLength(contentLength); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x02); + out.write(length, 0, length.length); + if (prependZero) { + out.write(0x00); + } + out.write(trimmed, 0, trimmed.length); + return out.toByteArray(); + } + + /** Encodes a length as ASN.1 DER (short form below 128, else long form). */ + private static byte[] encodeDerLength(int length) { + if (length < 0x80) { + return new byte[] {(byte) length}; + } + byte[] buffer = new byte[4]; + int index = buffer.length; + int remaining = length; + while (remaining > 0) { + buffer[--index] = (byte) (remaining & 0xFF); + remaining >>= 8; + } + int count = buffer.length - index; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x80 | count); + out.write(buffer, index, count); + return out.toByteArray(); + } + + /** + * Removes leading 0x00 bytes, leaving the meaningful big endian value (a + * single 0x00 for an all-zero input). + */ + private static byte[] trimLeadingZeros(byte[] value) { + int start = 0; + while (start < value.length - 1 && value[start] == 0x00) { + start++; + } + return start == 0 ? value : Arrays.copyOfRange(value, start, value.length); + } + /** * Returns the public key in Subject Public Key Info (SPKI) PEM form for * use with the well known end points or other implementations. diff --git a/src/main/java/com/swancommunity/owid/Io.java b/src/main/java/com/swancommunity/owid/Io.java index 9d0af1d..17de59d 100644 --- a/src/main/java/com/swancommunity/owid/Io.java +++ b/src/main/java/com/swancommunity/owid/Io.java @@ -175,7 +175,7 @@ static void writeString(ByteArrayOutputStream buffer, String value) throw new OwidException("domain '" + value + "' is not valid"); } } - buffer.writeBytes(bytes); + buffer.write(bytes, 0, bytes.length); buffer.write(0); } @@ -198,7 +198,7 @@ static void writeByteArray(ByteArrayOutputStream buffer, byte[] value) + "' exceeds the unsigned 32 bit limit"); } writeUInt32(buffer, value.length); - buffer.writeBytes(value); + buffer.write(value, 0, value.length); } /** Writes the fixed length signature, validating the length. */ @@ -207,7 +207,7 @@ static void writeSignature(ByteArrayOutputStream buffer, byte[] value) if (value.length != Owid.SIGNATURE_LENGTH) { throw invalidSignatureLength(value.length); } - buffer.writeBytes(value); + buffer.write(value, 0, value.length); } /** Writes the date using the encoding associated with the version. */ diff --git a/src/test/java/com/swancommunity/owid/CreatorTest.java b/src/test/java/com/swancommunity/owid/CreatorTest.java index 2dc446a..bae69d6 100644 --- a/src/test/java/com/swancommunity/owid/CreatorTest.java +++ b/src/test/java/com/swancommunity/owid/CreatorTest.java @@ -22,7 +22,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Collections; -import java.util.List; import org.junit.jupiter.api.Test; /** Unit tests for the creator signing behaviour. */ @@ -90,8 +89,8 @@ void signWithOthersRoundTrips() throws OwidException { Owid root = creator.signString("root"); Owid party = new Owid(); party.setPayload("party".getBytes()); - creator.signWithOthers(party, List.of(root)); - assertTrue(party.verifyWithCrypto(crypto, List.of(root)), + creator.signWithOthers(party, Collections.singletonList(root)); + assertTrue(party.verifyWithCrypto(crypto, Collections.singletonList(root)), "should verify with the same others"); assertFalse(party.verifyWithCrypto(crypto, Collections.emptyList()), "should fail to verify without the others"); diff --git a/src/test/java/com/swancommunity/owid/FixturesTest.java b/src/test/java/com/swancommunity/owid/FixturesTest.java index 0e1d77f..8134910 100644 --- a/src/test/java/com/swancommunity/owid/FixturesTest.java +++ b/src/test/java/com/swancommunity/owid/FixturesTest.java @@ -33,8 +33,42 @@ class FixturesTest { /** A set of fixtures produced by one implementation with one key. */ - private record Fixtures(String spki, String simple, String utf8, - String chainParty, String chainRoot) { + private static final class Fixtures { + + private final String spki; + private final String simple; + private final String utf8; + private final String chainParty; + private final String chainRoot; + + Fixtures(String spki, String simple, String utf8, + String chainParty, String chainRoot) { + this.spki = spki; + this.simple = simple; + this.utf8 = utf8; + this.chainParty = chainParty; + this.chainRoot = chainRoot; + } + + String spki() { + return spki; + } + + String simple() { + return simple; + } + + String utf8() { + return utf8; + } + + String chainParty() { + return chainParty; + } + + String chainRoot() { + return chainRoot; + } } private static final String UTF8_TEXT = "Zürich ❤ OWID £€"; @@ -115,7 +149,7 @@ private void runFixtures(Fixtures fixtures) throws OwidException { "chain root should verify alone"); Owid party = Owid.fromBase64(fixtures.chainParty()); - assertTrue(party.verifyWithCrypto(crypto, List.of(root)), + assertTrue(party.verifyWithCrypto(crypto, Collections.singletonList(root)), "chain party should verify with the root as the other"); assertFalse(party.verifyWithCrypto(crypto, none), "chain party should fail with no others"); @@ -131,7 +165,7 @@ private void runFixtures(Fixtures fixtures) throws OwidException { byte[] tamperedParty = flipLastByte(Base64.getMimeDecoder().decode(fixtures.chainParty())); Owid party2 = Owid.fromByteArray(tamperedParty); - assertFalse(party2.verifyWithCrypto(crypto, List.of(root)), + assertFalse(party2.verifyWithCrypto(crypto, Collections.singletonList(root)), "a flipped party signature byte should break verification"); }