Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 8 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
</licenses>

<properties>
<maven.compiler.release>21</maven.compiler.release>
<!-- The shipped library targets Java 8 so it can be consumed by SDKs
on that floor (e.g. 51Degrees pipeline-java). Test sources use
newer language features and are compiled at a modern release
below; tests are not shipped. -->
<maven.compiler.release>8</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.10.2</junit.version>
</properties>
Expand All @@ -41,6 +45,9 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<!-- Main and test sources both compile at Java 8
(maven.compiler.release=8) so the whole suite can run on a
Java 8 JVM in CI, guarding the Java 8 runtime contract. -->
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
Expand Down
187 changes: 175 additions & 12 deletions src/main/java/com/swancommunity/owid/Crypto.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.swancommunity.owid;

import java.io.ByteArrayOutputStream;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.KeyPair;
Expand All @@ -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;

/**
Expand All @@ -35,9 +37,12 @@
* <p>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.</p>
* (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.)</p>
*
* <p>An instance can hold both keys, or only one of them when created with
* {@link #newSignOnly(String)} or {@link #newVerifyOnly(String)}.</p>
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions src/main/java/com/swancommunity/owid/Io.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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. */
Expand All @@ -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. */
Expand Down
5 changes: 2 additions & 3 deletions src/test/java/com/swancommunity/owid/CreatorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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");
Expand Down
42 changes: 38 additions & 4 deletions src/test/java/com/swancommunity/owid/FixturesTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 £€";
Expand Down Expand Up @@ -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");
Expand All @@ -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");
}

Expand Down