diff --git a/README.md b/README.md index 15a71f3..8efaba1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,34 @@ creates, signs, serializes, and verifies OWIDs. - The well known end point helpers return paths and bodies. They do not bind to any web framework. +## Payload size and application limits + +The OWID wire format stores the payload length as an unsigned 32 bit value, +so a payload from zero through 4,294,967,295 bytes is structurally valid. The +format defines no smaller payload limit. The null-terminated domain has no +separate encoded maximum, so the protocol alone is not an application input +limit for the complete envelope. + +This library validates that the declared payload length agrees with the bytes +present before it sizes or copies the payload. A large declaration without +the corresponding bytes is malformed and is rejected without allocating the +declared size. A matching large payload is not malformed merely because it is +large, and parsing work and memory use scale with the bytes actually present. + +The in-memory APIs remain subject to Java's signed `int` array indexing, +address-space and available-memory limits, so a single Java byte array cannot +represent every value allowed by the unsigned wire field. Applications +accepting untrusted OWIDs must choose limits suitable for their use case and +enforce them before buffering the binary form or decoding Base64. An +implementation capacity failure or an application policy rejection is +distinct from an invalid OWID. + +For transport input, limit the complete HTTP body or encoded envelope; allow +for the domain and other OWID fields as well as the payload. After parsing, +`owid.getPayloadLength()` reports the actual payload size without copying it +and can be used for downstream policy. The parser cannot choose either limit +on behalf of the application. + ## Installation Build and install with Maven. The project targets Java 21. diff --git a/src/main/java/com/swancommunity/owid/Creator.java b/src/main/java/com/swancommunity/owid/Creator.java index 63b8b16..2d42cc4 100644 --- a/src/main/java/com/swancommunity/owid/Creator.java +++ b/src/main/java/com/swancommunity/owid/Creator.java @@ -44,17 +44,28 @@ private Creator(String domain, Crypto crypto) { * Creates a new creator for the domain using the crypto instance for * signing. * + *

The domain is refused here if it is longer than the maximum + * published for a domain name, being the same bound parsing applies, so + * a creator can never be built that would produce an OWID this library + * would refuse to read. The refusal arrives when the caller supplies the + * domain rather than later when an OWID is signed.

+ * * @param domain the domain associated with the creator * @param crypto the crypto instance that can sign * @return the creator - * @throws OwidException if the domain is empty or whitespace, or the - * crypto instance cannot sign + * @throws OwidException if the domain is empty or whitespace, is longer + * than the maximum published for a domain name, or + * the crypto instance cannot sign */ public static Creator create(String domain, Crypto crypto) throws OwidException { if (domain == null || domain.trim().isEmpty()) { throw new OwidException("domain '" + domain + "' is not valid"); } + if (domain.getBytes(StandardCharsets.UTF_8).length + > Io.MAXIMUM_DOMAIN_LENGTH) { + throw Io.domainTooLong(); + } if (!crypto.canSign()) { throw new OwidException( "instance of Crypto cannot be used to generate a signature"); @@ -68,8 +79,9 @@ public static Creator create(String domain, Crypto crypto) * @param domain the domain associated with the creator * @param privatePem the private key in PKCS#8 PEM form * @return the creator - * @throws OwidException if the domain is empty, or the PEM is not a valid - * private key + * @throws OwidException if the domain is empty or longer than the + * maximum published for a domain name, or the PEM + * is not a valid private key */ public static Creator fromPrivatePem(String domain, String privatePem) throws OwidException { diff --git a/src/main/java/com/swancommunity/owid/Io.java b/src/main/java/com/swancommunity/owid/Io.java index 17de59d..74616ba 100644 --- a/src/main/java/com/swancommunity/owid/Io.java +++ b/src/main/java/com/swancommunity/owid/Io.java @@ -39,6 +39,18 @@ final class Io { */ static final long BASE_DATE_EPOCH_SECONDS = 1_577_836_800L; + /** + * The longest domain an OWID can hold, in characters. RFC 1035 section + * 2.3.4, "Size limits", restricts the total length of a domain name to + * 255 octets or less, and that limit counts the wire format, which + * spends one length octet on every label and one zero octet on the + * root. An OWID stores the presentation form instead, being the text + * "example.com", where the dots stand in for the label length octets + * and the root has no text at all, so the same published limit is two + * characters shorter here. + */ + static final int MAXIMUM_DOMAIN_LENGTH = 253; + private Io() { } @@ -73,6 +85,11 @@ int readByte() throws OwidException { return buffer[position++] & 0xFF; } + /** + * Copies the next count bytes. The end of the buffer is checked + * before the copy is sized, so a count beyond the bytes present is + * refused without allocating. + */ private byte[] readBytes(int count) throws OwidException { if (count < 0) { throw new OwidException("payload length is negative"); @@ -88,17 +105,27 @@ private byte[] readBytes(int count) throws OwidException { } /** - * Reads bytes until the null terminator and returns them as a string. + * Reads the domain, being the bytes up to the null terminator. The + * search stops at {@link Io#MAXIMUM_DOMAIN_LENGTH} rather than at + * the end of the buffer, so a buffer whose terminator is missing or + * corrupted costs no more than the published maximum however long + * that buffer is, and a domain longer than the maximum is refused + * without reading past it. */ String readString() throws OwidException { + long lastTerminator = (long) position + MAXIMUM_DOMAIN_LENGTH; + int limit = (int) Math.min(buffer.length - 1L, lastTerminator); int terminator = -1; - for (int i = position; i < buffer.length; i++) { + for (int i = position; i <= limit; i++) { if (buffer[i] == 0) { terminator = i; break; } } if (terminator < 0) { + if (lastTerminator < buffer.length) { + throw domainTooLong(); + } throw endOfBuffer(); } String value = new String(buffer, position, terminator - position, @@ -109,22 +136,31 @@ String readString() throws OwidException { /** Reads an unsigned 32 bit integer in little endian byte order. */ long readUInt32() throws OwidException { - byte[] bytes = readBytes(4); - return ((long) (bytes[0] & 0xFF)) - | ((long) (bytes[1] & 0xFF) << 8) - | ((long) (bytes[2] & 0xFF) << 16) - | ((long) (bytes[3] & 0xFF) << 24); + return ((long) readByte()) + | ((long) readByte() << 8) + | ((long) readByte() << 16) + | ((long) readByte() << 24); } /** - * Reads a byte array prefixed with its length as an unsigned 32 bit - * integer. + * Reads the length prefixed payload. The count is whatever the sender + * declared, so it is checked against the bytes actually present + * before anything is sized by it. A valid OWID is the declared + * payload followed by the signature and nothing else, so the count + * must equal the bytes remaining less the signature length, and any + * other count, short or long, is refused here. The same check refuses + * an envelope with bytes after the signature, which was previously + * accepted and ignored, and one whose signature is short. */ byte[] readByteArray() throws OwidException { long count = readUInt32(); - if (count > Integer.MAX_VALUE) { - throw new OwidException("payload length '" + count - + "' exceeds the maximum supported length"); + long remaining = (long) buffer.length - position; + long expected = count + Owid.SIGNATURE_LENGTH; + if (remaining != expected) { + throw new OwidException("OWID payload length '" + count + + "' does not match the '" + remaining + + "' bytes present, of which the final '" + + Owid.SIGNATURE_LENGTH + "' must be the signature"); } return readBytes((int) count); } @@ -165,11 +201,18 @@ static void writeByte(ByteArrayOutputStream buffer, byte value) { /** * Writes the string followed by the null terminator. The string must not - * contain a null character as that would conflict with the terminator. + * contain a null character as that would conflict with the terminator, + * and must be no longer than {@link #MAXIMUM_DOMAIN_LENGTH} bytes, being + * the bound the read applies, so the library cannot write a domain it + * would then refuse to read back. The count is of the UTF-8 bytes + * because those are what the read counts as it walks to the terminator. */ static void writeString(ByteArrayOutputStream buffer, String value) throws OwidException { byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAXIMUM_DOMAIN_LENGTH) { + throw domainTooLong(); + } for (byte b : bytes) { if (b == 0) { throw new OwidException("domain '" + value + "' is not valid"); @@ -238,6 +281,16 @@ static void writeDate(ByteArrayOutputStream buffer, Instant date, } } + /** + * The refusal used by both halves of the library when a domain is longer + * than the published maximum, so the read and the write report the one + * condition in the same words. + */ + static OwidException domainTooLong() { + return new OwidException("domain is longer than the '" + + MAXIMUM_DOMAIN_LENGTH + "' character maximum"); + } + static OwidException invalidSignatureLength(int length) { return new OwidException("signature length '" + length + "' not compatible with '" + Owid.SIGNATURE_LENGTH diff --git a/src/main/java/com/swancommunity/owid/Owid.java b/src/main/java/com/swancommunity/owid/Owid.java index 6d69221..b9cd6ba 100644 --- a/src/main/java/com/swancommunity/owid/Owid.java +++ b/src/main/java/com/swancommunity/owid/Owid.java @@ -38,11 +38,13 @@ * * */ public final class Owid { @@ -106,8 +108,12 @@ public static Owid fromBase64(String value) throws OwidException { * * @param buffer the serialized OWID bytes * @return the parsed OWID - * @throws OwidException if the first byte is not a known version, or the - * buffer is too short for the remaining fields + * @throws OwidException if the first byte is not a known version, the + * buffer is too short for the remaining fields, the + * domain is unterminated or longer than the maximum + * published for a domain name, or the declared + * payload length does not leave exactly the 64 byte + * signature at the end */ public static Owid fromByteArray(byte[] buffer) throws OwidException { return fromReader(new Io.Reader(buffer)); @@ -139,9 +145,10 @@ static Owid fromReader(Io.Reader reader) throws OwidException { * be encoded */ public byte[] asByteArray() throws OwidException { - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + ExactByteArrayOutputStream buffer = + new ExactByteArrayOutputStream(byteCount(true)); toBuffer(buffer); - return buffer.toByteArray(); + return buffer.toExactByteArray(); } /** @@ -187,12 +194,82 @@ void toBufferNoSignature(ByteArrayOutputStream buffer) throws OwidException { * form of each of the others in the order provided. */ byte[] dataForCrypto(List others) throws OwidException { - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int length = byteCount(false); + for (Owid other : others) { + length = addLength(length, other.byteCount(true)); + } + ExactByteArrayOutputStream buffer = + new ExactByteArrayOutputStream(length); toBufferNoSignature(buffer); for (Owid other : others) { other.toBuffer(buffer); } - return buffer.toByteArray(); + return buffer.toExactByteArray(); + } + + /** + * The exact number of bytes serialization will write. + */ + private int byteCount(boolean includeSignature) throws OwidException { + int dateLength; + switch (version) { + case VERSION1: + dateLength = 2; + break; + case VERSION2: + case VERSION3: + dateLength = 4; + break; + default: + throw new OwidException( + "OWID version '" + version + "' not supported"); + } + int length = 1; + length = addLength(length, + domain.getBytes(StandardCharsets.UTF_8).length); + length = addLength(length, 1); + length = addLength(length, dateLength); + length = addLength(length, 4); + length = addLength(length, payload.length); + if (includeSignature) { + if (signature.length != SIGNATURE_LENGTH) { + throw Io.invalidSignatureLength(signature.length); + } + length = addLength(length, SIGNATURE_LENGTH); + } + return length; + } + + /** + * Adds serialized lengths without allowing signed int overflow to turn + * an implementation capacity failure into a malformed OWID. + */ + private static int addLength(int left, int right) throws OwidException { + if (right < 0 || left > Integer.MAX_VALUE - right) { + throw new OwidException( + "OWID byte length exceeds Java array capacity"); + } + return left + right; + } + + /** + * A byte stream whose backing array is already the exact final size. + * Returning that array avoids ByteArrayOutputStream's final full copy. + */ + private static final class ExactByteArrayOutputStream + extends ByteArrayOutputStream { + + ExactByteArrayOutputStream(int size) { + super(size); + } + + byte[] toExactByteArray() throws OwidException { + if (count != buf.length) { + throw new OwidException( + "serialized OWID length did not match its fields"); + } + return buf; + } } /** @@ -337,6 +414,16 @@ public byte[] getPayload() { return payload.clone(); } + /** + * Returns the payload length without copying the payload. This is useful + * when applying a use-case-specific size policy after parsing. + * + * @return the payload length in bytes + */ + public int getPayloadLength() { + return payload.length; + } + /** * Sets the payload bytes. * diff --git a/src/test/java/com/swancommunity/owid/DomainLengthTest.java b/src/test/java/com/swancommunity/owid/DomainLengthTest.java new file mode 100644 index 0000000..5a8054a --- /dev/null +++ b/src/test/java/com/swancommunity/owid/DomainLengthTest.java @@ -0,0 +1,319 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.swancommunity.owid; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadMXBean; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +/** + * An OWID stores the creator domain as text followed by a zero terminator, + * and parsing finds the end of the domain by walking forward to that + * terminator. A missing or corrupted terminator once made the walk run to the + * end of the buffer, which is work whose size an attacker chooses. RFC 1035 + * section 2.3.4 publishes a maximum for a domain name, so these tests prove + * that a domain at that maximum still parses, that a longer one is refused, + * that a buffer with no terminator at all is refused, and that refusing a + * hostile buffer costs no more than the maximum however long the buffer is. + * + *

The same maximum binds the write, because a library that can emit an + * OWID it cannot read leaves the fault to surface at the consumer rather + * than at the creator. The later tests prove a creator refuses a domain over + * the maximum when the caller supplies it, before the private key is used at + * all, and that serialising refuses a domain that arrived by any other + * route.

+ */ +class DomainLengthTest { + + private static final int MAXIMUM = Io.MAXIMUM_DOMAIN_LENGTH; + + private static final byte[] PAYLOAD = filled(37, (byte) 0x5A); + + private static final byte[] SIGNATURE = + filled(Owid.SIGNATURE_LENGTH, (byte) 0x99); + + /** The refusal of any domain field must allocate under this. */ + private static final long ALLOCATION_BOUND = 64L * 1024; + + /** + * A domain field long enough that walking it would show plainly in the + * allocation figure, being far more than the published maximum. + */ + private static final int HOSTILE_DOMAIN_LENGTH = 16 * 1024 * 1024; + + /** + * A version 3 envelope carrying the domain bytes given, being the version + * byte, those bytes with a terminator after them, four minute bytes, the + * payload with its declared length, and the signature. The bytes are + * written by hand rather than through the library so the test does not + * depend on the writer it is checking the reader against. + */ + private static byte[] envelope(byte[] domain) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + stream.write(Version.VERSION3.asByte()); + stream.write(domain, 0, domain.length); + stream.write(0); + writeLittleEndian(stream, 1000L); + writeLittleEndian(stream, PAYLOAD.length); + stream.write(PAYLOAD, 0, PAYLOAD.length); + stream.write(SIGNATURE, 0, SIGNATURE.length); + return stream.toByteArray(); + } + + private static void writeLittleEndian( + ByteArrayOutputStream stream, long value) { + stream.write((int) (value & 0xFF)); + stream.write((int) ((value >> 8) & 0xFF)); + stream.write((int) ((value >> 16) & 0xFF)); + stream.write((int) ((value >> 24) & 0xFF)); + } + + private static byte[] filled(int length, byte value) { + byte[] bytes = new byte[length]; + Arrays.fill(bytes, value); + return bytes; + } + + /** + * A domain of the length given, written as labels separated by dots so it + * has the shape of a real name rather than one long run of letters. + */ + private static String domainOfLength(int length) { + StringBuilder builder = new StringBuilder(length); + while (builder.length() < length) { + builder.append(builder.length() % 64 == 63 ? '.' : 'a'); + } + return builder.toString(); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + /** + * Bytes allocated so far on the current thread, from the HotSpot thread + * bean. The test fails rather than passing silently if the runtime cannot + * measure allocation, because the allocation bound is the point of the + * test that calls this. + */ + private static long allocatedBytes() { + ThreadMXBean bean = ManagementFactory.getThreadMXBean(); + assertTrue(bean instanceof com.sun.management.ThreadMXBean, + "the runtime must be able to measure thread allocation"); + com.sun.management.ThreadMXBean sun = + (com.sun.management.ThreadMXBean) bean; + assertTrue(sun.isThreadAllocatedMemoryEnabled(), + "thread allocation measurement must be enabled"); + return sun.getThreadAllocatedBytes(Thread.currentThread().getId()); + } + + /** + * A domain of exactly the published maximum parses, keeps its value, and + * survives being written back out and read again. + */ + @Test + void maximumLengthDomainParses() throws OwidException { + String domain = domainOfLength(MAXIMUM); + byte[] bytes = envelope(ascii(domain)); + + Owid owid = Owid.fromByteArray(bytes); + + assertEquals(MAXIMUM, owid.getDomain().length(), + "should read a domain of the published maximum length"); + assertEquals(domain, owid.getDomain(), "should read the domain"); + assertArrayEquals(PAYLOAD, owid.getPayload(), + "should read the payload that follows the domain"); + assertArrayEquals(bytes, owid.asByteArray(), + "should write the same bytes back out"); + assertEquals(owid, Owid.fromByteArray(owid.asByteArray()), + "should parse its own output to an equal OWID"); + } + + /** + * One character more than the published maximum is refused, even though + * the terminator and every field after it are present and correct. + */ + @Test + void overMaximumLengthDomainRefused() { + byte[] bytes = envelope(ascii(domainOfLength(MAXIMUM + 1))); + + assertThrows(OwidException.class, () -> Owid.fromByteArray(bytes), + "should refuse a domain one character over the maximum"); + } + + /** + * A buffer whose domain field has no terminator anywhere is refused. The + * buffer holds nothing but the version byte and letters, so there is no + * zero for the walk to stop at. + */ + @Test + void missingTerminatorRefused() { + byte[] bytes = filled(64 * 1024, (byte) 'a'); + bytes[0] = Version.VERSION3.asByte(); + + assertThrows(OwidException.class, () -> Owid.fromByteArray(bytes), + "should refuse a buffer with no terminator"); + } + + /** + * A domain field of sixteen mebibytes, terminated far past the maximum, + * is refused without an allocation sized by the field. Measuring the + * bytes allocated on this thread proves the cost of the refusal is set by + * the published maximum and not by the length of the buffer, because + * building the string the field describes could not fit under the bound. + */ + @Test + void hostileDomainRefusedWithoutAllocating() { + byte[] bytes = envelope(filled(HOSTILE_DOMAIN_LENGTH, (byte) 'a')); + + long before = allocatedBytes(); + assertThrows(OwidException.class, () -> Owid.fromByteArray(bytes), + "should refuse a domain field of " + + HOSTILE_DOMAIN_LENGTH + " bytes"); + long allocated = allocatedBytes() - before; + + assertTrue(allocated < ALLOCATION_BOUND, "refusing a " + + HOSTILE_DOMAIN_LENGTH + " byte domain field allocated " + + allocated + " bytes"); + } + + /** + * A creator holding a domain of exactly the published maximum signs an + * OWID that serialises, parses back to the same domain, and verifies, so + * the write bound refuses nothing the library accepted before. + */ + @Test + void maximumLengthDomainWritten() throws OwidException { + String domain = domainOfLength(MAXIMUM); + Crypto crypto = Crypto.generate(); + Creator creator = Creator.create(domain, crypto); + + Owid signed = creator.signBytes(PAYLOAD); + Owid parsed = Owid.fromByteArray(signed.asByteArray()); + + assertEquals(MAXIMUM, parsed.getDomain().length(), + "should write and read a domain of the published maximum"); + assertEquals(domain, parsed.getDomain(), + "should round trip the domain the creator holds"); + assertEquals(signed, parsed, "should parse to an equal OWID"); + assertTrue(parsed.verifyWithCrypto(crypto, Collections.emptyList()), + "the parsed OWID should still verify"); + } + + /** + * A creator is refused one character over the maximum, at the point the + * caller supplies the domain, and the message names the maximum so the + * caller can see what the domain has to fit. + */ + @Test + void overMaximumLengthDomainRefusedByCreator() throws OwidException { + Crypto crypto = Crypto.generate(); + + OwidException thrown = assertThrows(OwidException.class, + () -> Creator.create(domainOfLength(MAXIMUM + 1), crypto), + "should refuse a domain one character over the maximum"); + + assertNamesMaximum(thrown); + } + + /** + * The refusal comes before anything is signed. A creator cannot be built + * with an over long domain even when the crypto instance holds no private + * key at all, and a domain that reaches an OWID by another route is + * refused as the bytes to sign are assembled, which is the step before + * the private key is used, leaving the OWID with no signature. + */ + @Test + void overMaximumLengthDomainRefusedBeforeSigning() throws OwidException { + String domain = domainOfLength(MAXIMUM + 1); + Crypto verifyOnly = + Crypto.newVerifyOnly(Crypto.generate().publicKeyPem()); + assertFalse(verifyOnly.canSign(), + "the crypto instance should not be able to sign"); + + OwidException fromCreator = assertThrows(OwidException.class, + () -> Creator.create(domain, verifyOnly), + "should refuse the domain without reaching the crypto"); + assertNamesMaximum(fromCreator); + + Owid owid = new Owid(); + owid.setDomain(domain); + owid.setPayload(PAYLOAD); + OwidException fromData = assertThrows(OwidException.class, + () -> owid.dataForCrypto(Collections.emptyList()), + "should refuse to assemble the bytes that would be signed"); + assertNamesMaximum(fromData); + assertEquals(0, owid.getSignature().length, + "nothing should have been signed"); + } + + /** + * Serialising refuses a domain over the maximum however it arrived. The + * OWID here carries a signature of the right length, so the refusal is + * the domain and not a missing signature. + */ + @Test + void overMaximumLengthDomainRefusedWhenSerialising() { + Owid owid = new Owid(); + owid.setDomain(domainOfLength(MAXIMUM + 1)); + owid.setPayload(PAYLOAD); + owid.setSignature(SIGNATURE); + + OwidException thrown = assertThrows(OwidException.class, + owid::asByteArray, + "should refuse to serialise a domain over the maximum"); + + assertNamesMaximum(thrown); + } + + /** The refusal has to name the maximum the caller must fit within. */ + private static void assertNamesMaximum(OwidException thrown) { + assertTrue(thrown.getMessage().contains(String.valueOf(MAXIMUM)), + "the message '" + thrown.getMessage() + + "' should name the '" + MAXIMUM + "' maximum"); + } + + /** + * An OWID signed through the library's own creator still parses from its + * own serialised bytes and still verifies, so the bound is not + * retrospective on anything the library produces. + */ + @Test + void libraryOutputParses() throws OwidException { + Crypto crypto = Crypto.generate(); + Creator creator = Creator.create("51d.es", crypto); + Owid original = creator.signBytes(PAYLOAD); + + Owid parsed = Owid.fromByteArray(original.asByteArray()); + + assertEquals("51d.es", parsed.getDomain(), + "should read the domain the library wrote"); + assertEquals(original, parsed, "should parse to an equal OWID"); + assertTrue(parsed.verifyWithCrypto(crypto, Collections.emptyList()), + "the parsed OWID should still verify"); + } +} diff --git a/src/test/java/com/swancommunity/owid/PayloadLengthTest.java b/src/test/java/com/swancommunity/owid/PayloadLengthTest.java new file mode 100644 index 0000000..7630d8d --- /dev/null +++ b/src/test/java/com/swancommunity/owid/PayloadLengthTest.java @@ -0,0 +1,235 @@ +/* **************************************************************************** + * Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * ***************************************************************************/ + +package com.swancommunity.owid; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadMXBean; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +/** + * The payload length field of an OWID is whatever the sender declared, so + * parsing must check it against the bytes present before sizing anything by + * it. These tests prove that a declared length that does not leave exactly + * the signature after the payload is refused, that refusing it costs no + * allocation sized by the declared number, and that a correctly sized + * envelope still parses. The 64 byte signature is the fixed tail every valid + * OWID ends with. + */ +class PayloadLengthTest { + + private static final int SIGNATURE_LENGTH = Owid.SIGNATURE_LENGTH; + + private static final String DOMAIN = "51d.es"; + + private static final byte[] PAYLOAD = filled(37, (byte) 0x5A); + + private static final byte[] SIGNATURE = + filled(SIGNATURE_LENGTH, (byte) 0x99); + + /** The refusal of any declared length must allocate under this. */ + private static final long ALLOCATION_BOUND = 64L * 1024; + + /** + * A version 3 envelope, being the version byte, the domain with its + * terminator, four minute bytes, the declared payload length, the + * payload bytes given and the signature bytes given, so a test can make + * the declared length and the bytes present disagree. The bytes are + * written by hand rather than through the library so the test does not + * depend on the writer it is checking the reader against. + */ + private static byte[] envelope( + long declaredLength, byte[] payload, byte[] signature) { + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + stream.write(Version.VERSION3.asByte()); + byte[] domain = DOMAIN.getBytes(StandardCharsets.US_ASCII); + stream.write(domain, 0, domain.length); + stream.write(0); + writeLittleEndian(stream, 1000L); + writeLittleEndian(stream, declaredLength); + stream.write(payload, 0, payload.length); + stream.write(signature, 0, signature.length); + return stream.toByteArray(); + } + + private static void writeLittleEndian( + ByteArrayOutputStream stream, long value) { + stream.write((int) (value & 0xFF)); + stream.write((int) ((value >> 8) & 0xFF)); + stream.write((int) ((value >> 16) & 0xFF)); + stream.write((int) ((value >> 24) & 0xFF)); + } + + private static byte[] filled(int length, byte value) { + byte[] bytes = new byte[length]; + Arrays.fill(bytes, value); + return bytes; + } + + /** + * Bytes allocated so far on the current thread, from the HotSpot thread + * bean. The test fails rather than passing silently if the runtime + * cannot measure allocation, because the allocation bound is the point + * of the test that calls this. + */ + private static long allocatedBytes() { + ThreadMXBean bean = ManagementFactory.getThreadMXBean(); + assertTrue(bean instanceof com.sun.management.ThreadMXBean, + "the runtime must be able to measure thread allocation"); + com.sun.management.ThreadMXBean sun = + (com.sun.management.ThreadMXBean) bean; + assertTrue(sun.isThreadAllocatedMemoryEnabled(), + "thread allocation measurement must be enabled"); + return sun.getThreadAllocatedBytes(Thread.currentThread().getId()); + } + + /** + * The declared length matches the bytes present, the signature is the + * last 64 bytes, and the envelope parses to the same payload. + */ + @Test + void declaredLengthMatchesParses() throws OwidException { + Owid owid = Owid.fromByteArray( + envelope(PAYLOAD.length, PAYLOAD, SIGNATURE)); + assertArrayEquals(PAYLOAD, owid.getPayload(), + "should read the payload back unchanged"); + assertArrayEquals(SIGNATURE, owid.getSignature(), + "should read the signature back unchanged"); + assertEquals(DOMAIN, owid.getDomain(), "should read the domain"); + } + + /** + * A payload materially larger than an ordinary identifier remains valid + * when its declaration and bytes agree. Size policy belongs to the + * application rather than format parsing. + */ + @Test + void matchingOneMebibytePayloadParses() throws OwidException { + byte[] payload = filled(1024 * 1024, (byte) 0x5A); + + Owid owid = Owid.fromByteArray( + envelope(payload.length, payload, SIGNATURE)); + + assertEquals(payload.length, owid.getPayloadLength()); + assertArrayEquals(payload, owid.getPayload()); + } + + /** + * An OWID signed through the library's own creator still parses from + * its own serialised bytes, so the check agrees with what the library + * itself produces. + */ + @Test + void libraryOutputParses() throws OwidException { + Crypto crypto = Crypto.generate(); + Creator creator = Creator.create(DOMAIN, crypto); + Owid original = creator.signBytes(PAYLOAD); + Owid parsed = Owid.fromByteArray(original.asByteArray()); + assertArrayEquals(PAYLOAD, parsed.getPayload(), + "should read the payload the library wrote"); + assertEquals(original, parsed, "should parse to an equal OWID"); + assertTrue(parsed.verifyWithCrypto(crypto, Collections.emptyList()), + "the parsed OWID should still verify"); + } + + /** + * One more or one fewer than the bytes present is refused, because + * either leaves something other than exactly the signature at the end. + */ + @Test + void declaredLengthOffByOneRefused() { + int[] declaredLengths = {PAYLOAD.length - 1, PAYLOAD.length + 1}; + for (int declared : declaredLengths) { + byte[] bytes = envelope(declared, PAYLOAD, SIGNATURE); + assertThrows(OwidException.class, () -> Owid.fromByteArray(bytes), + "should refuse a declared length of " + declared); + } + } + + /** + * A byte after the signature is refused, because the signature must be + * the end of the envelope. Before the check this byte was ignored. + */ + @Test + void trailingByteAfterSignatureRefused() { + byte[] bytes = envelope(PAYLOAD.length, PAYLOAD, SIGNATURE); + byte[] longer = Arrays.copyOf(bytes, bytes.length + 1); + assertThrows(OwidException.class, () -> Owid.fromByteArray(longer), + "should refuse a byte after the signature"); + } + + /** + * A short signature is refused. The declared payload length is right + * for the payload, but the bytes after it are fewer than a signature. + */ + @Test + void shortSignatureRefused() { + byte[] bytes = envelope(PAYLOAD.length, PAYLOAD, + filled(SIGNATURE_LENGTH - 1, (byte) 0x99)); + assertThrows(OwidException.class, () -> Owid.fromByteArray(bytes), + "should refuse a 63 byte signature"); + } + + /** + * A large declaration whose payload bytes are absent is refused without + * an allocation sized by the declared number. The envelope is a few + * dozen bytes while declaring 64 MiB, then 2 GiB, then the largest + * unsigned 32 bit value, and each parse allocates under 64 KiB. The + * numeric values remain valid when the matching payload is present. + * Measuring the bytes allocated on this thread proves the refusal + * happened before any array was sized from the declaration. + */ + @Test + void mismatchedLargeDeclarationRefusedWithoutAllocating() { + long[] declaredLengths = { + 64L * 1024 * 1024, + 0x7FFFFFFFL, + 0xFFFFFFFFL, + }; + for (long declared : declaredLengths) { + byte[] bytes = envelope(declared, new byte[0], new byte[0]); + long before = allocatedBytes(); + assertThrows(OwidException.class, () -> Owid.fromByteArray(bytes), + "should refuse a declared length of " + declared); + long allocated = allocatedBytes() - before; + assertTrue(allocated < ALLOCATION_BOUND, "declared " + declared + + " allocated " + allocated + " bytes"); + } + } + + /** + * A declared length of zero with nothing but the signature after it + * parses to an empty payload, so the check does not refuse the smallest + * valid envelope. + */ + @Test + void emptyPayloadParses() throws OwidException { + Owid owid = Owid.fromByteArray(envelope(0, new byte[0], SIGNATURE)); + assertEquals(0, owid.getPayload().length, + "should read an empty payload"); + assertArrayEquals(SIGNATURE, owid.getSignature(), + "should read the signature after the empty payload"); + } +}