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
247 changes: 215 additions & 32 deletions README.md

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions src/main/java/com/swancommunity/owid/CapacityException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* ****************************************************************************
* 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;

/**
* Raised when an OWID is well formed but larger than this runtime can hold,
* being a Java array limited to {@link Integer#MAX_VALUE} bytes.
*
* <p>Kept apart from the other failures so that a verification result can
* report {@link OwidSignatureStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}
* without reading the text of a message. Running out of room is not the same
* as the data being wrong, because the same OWID may be readable on a
* runtime with more of it.</p>
*
* <p>Not public, because callers catch {@link OwidException} and the
* distinction is only needed inside the library.</p>
*/
final class CapacityException extends OwidException {

private static final long serialVersionUID = 1L;

CapacityException(String message) {
super(message);
}
}
108 changes: 66 additions & 42 deletions src/main/java/com/swancommunity/owid/Creator.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@
* Needed to create new OWIDs.
*
* <p>A creator binds the domain that hosts the well known end points to the
* crypto instance holding the signing key. Signing an OWID sets its domain to
* the creator domain, its date to the current time, and its version to the
* current version, then produces the signature.</p>
* crypto instance holding the signing key. Creating an OWID sets its domain
* to the creator domain, its date to the current time and its version to the
* current version, signs it, and hands back the finished thing.</p>
*
* <p>There is no way to sign an OWID that already exists, because there is no
* way to obtain an unsigned one, so nothing outside the library is available
* to be signed. Signing a parsed OWID again would replace the signature its
* fields were read with, which is why the library does not offer it.</p>
*/
public final class Creator {

Expand Down Expand Up @@ -109,63 +114,82 @@ public Crypto crypto() {
}

/**
* Signs the OWID provided, setting the domain to the creator domain, the
* date to the current time, and the version to the current version.
* Creates a new signed OWID for this creator carrying the string as the
* UTF-8 payload.
*
* @param owid the OWID to sign
* @throws OwidException if a field cannot be encoded or the signing
* operation fails
* @param value the payload string
* @return the signed OWID
* @throws OwidException if the payload is null, a field cannot be
* encoded, or the signing operation fails
*/
public void sign(Owid owid) throws OwidException {
signWithOthers(owid, Collections.emptyList());
public Owid createString(String value) throws OwidException {
return createString(value, Collections.<Owid>emptyList());
}

/**
* Signs the OWID provided together with the other OWIDs provided. The same
* others, in the same order, must be passed when verifying.
* Creates a new signed OWID for this creator carrying the bytes as the
* payload.
*
* @param owid the OWID to sign
* @param others the other OWIDs to cover with the signature
* @throws OwidException if a field cannot be encoded or the signing
* operation fails
* @param value the payload bytes
* @return the signed OWID
* @throws OwidException if the payload is null, a field cannot be
* encoded, or the signing operation fails
*/
public void signWithOthers(Owid owid, List<Owid> others)
throws OwidException {
owid.setVersion(Version.current());
owid.setDomain(domain);
owid.setDate(Instant.now().truncatedTo(ChronoUnit.MINUTES));
byte[] data = owid.dataForCrypto(others);
byte[] signature = crypto.signByteArray(data);
if (signature.length != Owid.SIGNATURE_LENGTH) {
throw Io.invalidSignatureLength(signature.length);
}
owid.setSignature(signature);
public Owid createBytes(byte[] value) throws OwidException {
return createBytes(value, Collections.<Owid>emptyList());
}

/**
* Creates a new signed OWID for the creator containing the string as the
* UTF-8 payload.
* Creates a new signed OWID carrying the string as the UTF-8 payload,
* with the other OWIDs covered by the same signature so that a tree can
* be verified as a whole. The same others, in the same order, must be
* passed when verifying.
*
* @param value the payload string
* @param value the payload string
* @param others the other OWIDs to cover with the signature
* @return the signed OWID
* @throws OwidException see {@link #sign(Owid)}
* @throws OwidException see {@link #createString(String)}
*/
public Owid signString(String value) throws OwidException {
return signBytes(value.getBytes(StandardCharsets.UTF_8));
public Owid createString(String value, List<Owid> others)
throws OwidException {
if (value == null) {
throw new OwidException("payload is null");
}
return createBytes(value.getBytes(StandardCharsets.UTF_8), others);
}

/**
* Creates a new signed OWID for the creator containing the bytes as the
* payload.
* Creates a new signed OWID carrying the bytes as the payload, with the
* other OWIDs covered by the same signature.
*
* @param value the payload bytes
* <p>This is one of only two ways an OWID reaches calling code, the other
* being a successful read of a complete serialized one. The creator owns
* the version, the domain, the date and the signature, and a caller
* supplies the payload and nothing else, so there is no moment at which a
* partly built OWID exists for anyone to hold or pass on.</p>
*
* @param value the payload bytes
* @param others the other OWIDs to cover with the signature
* @return the signed OWID
* @throws OwidException see {@link #sign(Owid)}
* @throws OwidException see {@link #createBytes(byte[])}
*/
public Owid signBytes(byte[] value) throws OwidException {
Owid owid = new Owid();
owid.setPayload(value);
sign(owid);
return owid;
public Owid createBytes(byte[] value, List<Owid> others)
throws OwidException {
if (value == null) {
throw new OwidException("payload is null");
}
if (others == null) {
throw new OwidException("others is null");
}
Version version = Version.current();
Instant date = Instant.now().truncatedTo(ChronoUnit.MINUTES);
byte[] payload = value.clone();
byte[] data = Owid.dataForCrypto(
version, domain, date, payload, others);
byte[] signature = crypto.signByteArray(data);
if (signature.length != Owid.SIGNATURE_LENGTH) {
throw Io.invalidSignatureLength(signature.length);
}
return new Owid(version, domain, date, payload, signature);
}
}
7 changes: 5 additions & 2 deletions src/main/java/com/swancommunity/owid/Endpoints.java
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,11 @@ public static String publicKeyResponse(Creator creator, String format)
if ("spki".equals(format) || "pkcs".equals(format)) {
return creator.crypto().subjectPublicKeyInfo();
}
throw new OwidException("format parameter 'spki' or 'pkcs' must be "
+ "provided, received '" + format + "'");
// The value is not repeated back, because it arrives on a query
// string from whoever called the end point and a refusal is often
// logged.
throw new OwidException(
"format parameter 'spki' or 'pkcs' must be provided");
}

private static void appendField(StringBuilder json, String name,
Expand Down
144 changes: 8 additions & 136 deletions src/main/java/com/swancommunity/owid/Io.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@
import java.time.Instant;

/**
* Low level read and write helpers for the OWID binary format. The format
* uses little endian unsigned 32 bit integers, null terminated strings, and a
* fixed 64 byte signature. Version 1 stores the date as a two byte big endian
* count of hours.
* Low level write helpers, and the constants both halves of the library
* share, for the OWID binary format. The format uses little endian unsigned
* 32 bit integers, null terminated strings, and a fixed 64 byte signature.
* Version 1 stores the date as a two byte big endian count of hours.
*
* <p>Reading lives in {@link OwidReader}, which walks a buffer by index and
* reports why rather than throwing, because the bytes it reads come from
* outside.</p>
*
* <p>The class is not part of the public API. The methods are package private
* so that the unit tests can exercise them directly.</p>
Expand Down Expand Up @@ -63,138 +67,6 @@ static Instant baseDate() {
return Instant.ofEpochSecond(BASE_DATE_EPOCH_SECONDS);
}

/** Sequential reader over a byte buffer. */
static final class Reader {

private final byte[] buffer;
private int position;

Reader(byte[] buffer) {
this.buffer = buffer;
this.position = 0;
}

int position() {
return position;
}

int readByte() throws OwidException {
if (position >= buffer.length) {
throw endOfBuffer();
}
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");
}
long end = (long) position + count;
if (end > buffer.length) {
throw endOfBuffer();
}
byte[] value = new byte[count];
System.arraycopy(buffer, position, value, 0, count);
position += count;
return value;
}

/**
* 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 <= 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,
StandardCharsets.UTF_8);
position = terminator + 1;
return value;
}

/** Reads an unsigned 32 bit integer in little endian byte order. */
long readUInt32() throws OwidException {
return ((long) readByte())
| ((long) readByte() << 8)
| ((long) readByte() << 16)
| ((long) readByte() << 24);
}

/**
* 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();
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);
}

/** Reads the fixed length signature. */
byte[] readSignature() throws OwidException {
return readBytes(Owid.SIGNATURE_LENGTH);
}

/** Reads the date using the encoding associated with the version. */
Instant readDate(Version version) throws OwidException {
switch (version) {
case VERSION1: {
int high = readByte();
int low = readByte();
long hours = ((long) high << 8) | low;
return baseDate().plus(Duration.ofHours(hours));
}
case VERSION2:
case VERSION3: {
long minutes = readUInt32();
return baseDate().plus(Duration.ofMinutes(minutes));
}
default:
throw new OwidException("OWID version '"
+ (version.asByte() & 0xFF) + "' not supported");
}
}

private static OwidException endOfBuffer() {
return new OwidException("buffer ended before the OWID was complete");
}
}

static void writeByte(ByteArrayOutputStream buffer, byte value) {
buffer.write(value);
}
Expand Down
Loading
Loading