Skip to content
Merged
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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions src/main/java/com/swancommunity/owid/Creator.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,28 @@ private Creator(String domain, Crypto crypto) {
* Creates a new creator for the domain using the crypto instance for
* signing.
*
* <p>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.</p>
*
* @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");
Expand All @@ -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 {
Expand Down
79 changes: 66 additions & 13 deletions src/main/java/com/swancommunity/owid/Io.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}

Expand Down Expand Up @@ -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");
Expand All @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down
103 changes: 95 additions & 8 deletions src/main/java/com/swancommunity/owid/Owid.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@
*
* <ul>
* <li>version: a single byte.</li>
* <li>domain: the UTF-8 bytes of the domain, null terminated.</li>
* <li>domain: the UTF-8 bytes of the domain, null terminated, no longer
* than the maximum published for a domain name.</li>
* <li>date: four little endian bytes counting minutes since
* 2020-01-01 UTC (two big endian bytes counting hours for version 1).</li>
* <li>payload: a four byte little endian length followed by the bytes.</li>
* <li>signature: 64 bytes, the r and s values concatenated.</li>
* <li>signature: 64 bytes, the r and s values concatenated. Nothing
* follows the signature.</li>
* </ul>
*/
public final class Owid {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -187,12 +194,82 @@ void toBufferNoSignature(ByteArrayOutputStream buffer) throws OwidException {
* form of each of the others in the order provided.
*/
byte[] dataForCrypto(List<Owid> 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;
}
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading