diff --git a/README.md b/README.md index ef2fb0a..713cbaf 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,9 @@ pure Java with no external runtime dependencies. ## Overview An OWID records that the entity operating a domain captured or generated a -payload at a date and time, with an ECDSA signature over the OWID and any -other OWIDs it was signed together with. OWIDs chain together to form -verifiable trees. The cryptography is ECDSA on the NIST P-256 curve (also -known as secp256r1 or prime256v1) with the SHA-256 hash. +payload at a date and time, with an ECDSA signature over the OWID. The +cryptography is ECDSA on the NIST P-256 curve (also known as secp256r1 or +prime256v1) with the SHA-256 hash. Read the [OWID project](https://github.com/SWAN-community/owid) to learn more about the concepts before looking into this implementation. This library @@ -33,7 +32,10 @@ creates, signs, serializes, and verifies OWIDs. to any web framework. - Fetching the public key of another creator uses `HttpURLConnection` from the JDK, so verifying over the network adds no dependency and still runs on - Java 8. + Java 8. Every method that reaches the network answers with a + `CompletableFuture`, and the blocking connection runs on a background + thread. A transport over `java.net.http.HttpClient.sendAsync` can be + supplied on Java 11 and later. ## Payload size and application limits @@ -103,8 +105,6 @@ import com.swancommunity.owid.Crypto; import com.swancommunity.owid.Owid; import com.swancommunity.owid.OwidParseResult; -import java.util.Collections; - // The creator operates a domain and holds the signing keys. Crypto crypto = Crypto.generate(); Creator creator = Creator.create("example.com", crypto); @@ -122,26 +122,13 @@ OwidParseResult result = Owid.parse(encoded); if (result.isSuccess()) { Owid copy = result.getValue(); String publicPem = crypto.publicKeyPem(); - boolean valid = copy.verifyWithPublicKey( - publicPem, Collections.emptyList()); + boolean valid = copy.verifyWithPublicKey(publicPem); } else { // result.getStatus() names which of the expected problems it was, and // result.getValue() is null. } ``` -Chaining covers other OWIDs with the same signature. The same others, in the -same order, must be supplied when verifying as were supplied when signing. - -```java -Owid root = creator.createString("root"); -Owid party = creator.createString("party", Collections.singletonList(root)); - -// Verifies with the root as the single other, fails without it. -party.verifyWithCrypto(crypto, Collections.singletonList(root)); // true -party.verifyWithCrypto(crypto, Collections.emptyList()); // false -``` - ## Verifying an identifier signed in an earlier week Creators rotate their signing key, weekly in the case of the 51Degrees cloud, @@ -151,23 +138,70 @@ anything older than a few days means asking for the key that was in force on the date the identifier carries. `PublicKeyFetch` asks the creator for that key. The request is -`/owid/api/v{n}/public-key?date={minutes}&format=pkcs`, where the version in +`/owid/api/v{n}/public-key?date={minutes}&format=spki`, where the version in the path is the version byte of the identifier being checked and the minutes are counted from 2020-01-01 in the same way the identifier stores its date. A creator that ignores the parameter returns its current key, so every identifier it signed under an earlier key reads as not matching, which is why -a creator that rotates its key has to honour the date. Keys already fetched -are held against the URL they came from, which names the domain, the version -and the minute, up to 1024 of them before the store is emptied, and -`clearCache` empties it on demand. +a creator that rotates its key has to honour the date. + +Keys fetched from a creator are held in memory. The request names the minute +the identifier was created, so a creator that rotates its key answers with the +key in force then, and the answer is the JSON form, which carries the moments +the key is valid from and to as well as the key. A creator built on this +library states both, so the whole span is held from one answer and an +identifier dated anywhere in it is verified without a request whatever the +clock drift. An answer that states the start alone is held from the start up +to fifteen minutes behind now, because no later key can have started before +then. An answer that states no span comes from a creator with one key and no +schedule, and is held against the minute asked about and every minute between +two such answers for the same key, but never for a minute within fifteen +minutes of now, because a creator whose clock differs from this one's may have +read that minute as its present rather than as the minute named. The PEM alone +as text is not a valid answer and is refused. A signature that does not verify +under the key selected, where the identifier is dated within fifteen minutes +of an edge of the span the creator stated for that key, is checked against the +key for the minute just beyond that edge before it is reported as not +matching, because a creator's signing machines may not agree with its schedule +to the minute. Where the creator's own statement puts the identifier's date +outside the span of the key it answered with and nothing verifies, the key is +reported as unavailable rather than the signature as not matching, because a +key that was not in force proves nothing about the identifier. Live +identifiers from a creator that states its spans cost one request per key, +and older ones cost none. At most 1024 keys are held across every creator +before the store is emptied and filled again, and `clearCache` empties it on +demand, which is how a long running process drops a key it has learned it +should no longer trust. Two requests for the same key made while the first is +still on its way share one request, and a fetch that fails is not held, so the +next request asks again. + +Every method that reaches the network answers with a `CompletableFuture` and +returns at once. There is no form that waits, so a request thread or an event +loop is never held while a creator answers, and a caller that wants to wait +joins the future itself. The request is made by a `PublicKeyTransport`, and +where none is named `HttpUrlConnectionTransport` is used, which runs the +JDK's blocking `HttpURLConnection` on a background thread. The pool it uses +has daemon threads, never more of them than twice the processors available, +and requests beyond that wait in a queue. An `Executor` of your own can be +given to its constructor instead. On Java 11 and later supply a transport of +your own over `java.net.http.HttpClient.sendAsync`, which blocks no thread at +all. Any transport must never follow a redirect and must request the URL +exactly as given, for the reasons the interface comment sets out. ```java import com.swancommunity.owid.OwidSignatureStatus; import com.swancommunity.owid.OwidVerificationResult; import com.swancommunity.owid.PublicKeyFetch; -OwidVerificationResult result = PublicKeyFetch.verify( - owid, "https", Collections.emptyList()); +import java.util.concurrent.CompletableFuture; + +CompletableFuture pending = + PublicKeyFetch.verify( + owid, "https", Collections.emptyList()); +// The call returns at once and the request runs on a background +// thread. Continue from the future, or join it where waiting is +// acceptable, as it is here. +OwidVerificationResult result = pending.join(); if (result.getStatus() == OwidSignatureStatus.KEY_UNAVAILABLE) { // The key could not be obtained, so the signature was never examined. // Only SIGNATURE_INVALID means the identifier should be distrusted. @@ -255,7 +289,7 @@ as the outage it is. | `INVALID_SIGNATURE_LENGTH` | A signature field of the wrong length reached the check. A consumer cannot produce one, because reading and creation both settle the signature at 64 bytes. | | `KEY_UNAVAILABLE` | No key was supplied, or the one supplied cannot verify. | | `INVALID_KEY` | Key material arrived and cannot be decoded or used. | -| `IMPLEMENTATION_CAPACITY_EXCEEDED` | More work than this runtime can hold, which needs an OWID and its chain to approach the two gigabyte limit of a Java array. | +| `IMPLEMENTATION_CAPACITY_EXCEEDED` | More work than this runtime can hold, which needs an OWID whose payload approaches the two gigabyte limit of a Java array. | | `VERIFICATION_ERROR` | The check could not be completed for a reason that is not the identifier's fault. | ## Reading one OWID out of something longer @@ -336,7 +370,7 @@ copies, because a Java byte array is mutable. | `new Owid()`, then `setPayload`, then `creator.sign(owid)` | `creator.createBytes(payload)` | | `creator.signString(value)` | `creator.createString(value)` | | `creator.signBytes(value)` | `creator.createBytes(value)` | -| `new Owid()`, then `creator.signWithOthers(owid, others)` | `creator.createBytes(payload, others)` | +| `new Owid()`, then `creator.signWithOthers(owid, others)` | no replacement, a signature covers the OWID alone | | `owid.setVersion`, `setDomain`, `setDate`, `setPayload` | no replacement, the state is read only | | `Version.fromByte(b)` | no replacement, an unknown version byte is `UNSUPPORTED_VERSION` from a read, and version zero is `ABSENT_NODE` | @@ -362,8 +396,8 @@ domain, a null payload, or a field that cannot be serialized. returns zero padded lower case hexadecimal with no separator. `payloadAsBase64` returns the payload as base 64. `getPayloadLength` reports the payload size without copying it. - - `verifyWithCrypto` and `verifyWithPublicKey` return whether the signature, - covering this OWID and any others provided, is valid. + - `verifyWithCrypto` and `verifyWithPublicKey` return whether the signature + is valid. - `verify`, taking either the `Crypto` or the public key PEM, answers the same question with a status, keeping a key that could not be used apart from a signature that does not match. @@ -381,18 +415,40 @@ domain, a null payload, or a field that cannot be serialized. - `Creator` binds a domain to a signing `Crypto`. - `createString` and `createBytes` create a complete signed OWID, setting the domain to the creator domain, the date to the current time and the - version to the current version. Both take an optional list of other OWIDs - to cover with the same signature. + version to the current version. - `PublicKeyFetch` obtains the key of another creator from the well known end point on the domain the OWID carries. - `publicKeyUrl` builds the request, naming the version of the OWID and the minute the OWID was signed. - - `publicKeyPem` returns the key, raising `PublicKeyFetchException`, which - carries the status to report, the domain and the response code. - - `verify` answers with the status, so a key that could not be fetched is - `KEY_UNAVAILABLE`, one that could not be read is `INVALID_KEY`, and - neither is mistaken for a signature that does not match. + - `publicKeyPem` returns a `CompletableFuture` of the key. The future fails + with `PublicKeyFetchException`, which carries the status to report, the + domain and the response code, where the key could not be obtained. + - `verify` returns a `CompletableFuture` of the status, so a key that + could not be fetched is `KEY_UNAVAILABLE`, one that could not be read is + `INVALID_KEY`, and neither is mistaken for a signature that does not + match. The future never fails. + - Both take an optional `PublicKeyTransport`, and use + `HttpUrlConnectionTransport` on its shared pool where none is given. - `clearCache` empties the keys already fetched. + - `Endpoints.publicKeyResponse` and `Endpoints.publicKeyResponseAt` return the + JSON body of the public key end point, the key as `publicKey`, the encoding + it is in as `format`, and `validFrom` and `validTo`, the UTC moments the key + came into force and the next key starts. The one format defined is `spki`, + a Subject Public Key Info PEM. It is what a request without a `format` + receives, and a request for any other value is answered 400 rather than in + an encoding the caller did not ask for. `Endpoints.publicKeyAnswer` builds + and checks any such answer so a key that cannot be read or a schedule that + contradicts itself is refused before it is sent. `PublicKeyResponse` reads + and writes the body, and refuses an answer that states another format. The + PEM alone as text is not a valid answer. +- `PublicKeyTransport` makes the request and answers with a + `CompletableFuture` of the body, so a transport over any HTTP client can + be supplied. It must never follow a redirect and must request the URL + exactly as given. +- `HttpUrlConnectionTransport` is the transport used where none is named, + running `HttpURLConnection` on an `Executor`, either one given to its + constructor or a shared pool of daemon threads bounded at twice the + processors available. - `PublicKeySchedule` holds the keys a creator has published and chooses between them. - `PublicKeySchedule.of` takes the keys in any order. @@ -407,10 +463,10 @@ domain, a null payload, or a field that cannot be serialized. no generation moment, so nothing can select by one. - `Endpoints` provides framework agnostic helpers for the well known end points. - - `creatorResponse` returns JSON with the fields `domain`, `name`, - `publicKeySPKI`, and `contractURL`. The path is `/owid/api/v{n}/creator`. - - `publicKeyResponse` returns the PEM. The path is - `/owid/api/v{n}/public-key` with a `format` parameter of `spki` or `pkcs`. + - `publicKeyResponse` returns the JSON body of the public key end point + for a creator with one key and no schedule. The path is + `/owid/api/v{n}/public-key` with an optional `format` parameter whose one + defined value is `spki`. ## Data structure notes @@ -461,8 +517,8 @@ mvn test ``` The tests round trip the canonical wire format vectors byte for byte, verify -cross language signed fixtures including the chained case, confirm that a -flipped signature byte fails verification, and cover the binary write +cross language signed fixtures, confirm that a flipped signature byte fails +verification, and cover the binary write helpers, the crypto, the creator, and the end point helpers. They also cover the parse contract, being every status the reading surfaces report together with a run of malformed buffers that must never throw, the framed read and diff --git a/src/main/java/com/swancommunity/owid/Creator.java b/src/main/java/com/swancommunity/owid/Creator.java index 6295796..4a1bbe5 100644 --- a/src/main/java/com/swancommunity/owid/Creator.java +++ b/src/main/java/com/swancommunity/owid/Creator.java @@ -19,8 +19,6 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Collections; -import java.util.List; /** * Needed to create new OWIDs. @@ -123,44 +121,15 @@ public Crypto crypto() { * encoded, or the signing operation fails */ public Owid createString(String value) throws OwidException { - return createString(value, Collections.emptyList()); - } - - /** - * Creates a new signed OWID for this creator carrying the bytes as the - * payload. - * - * @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 Owid createBytes(byte[] value) throws OwidException { - return createBytes(value, Collections.emptyList()); - } - - /** - * 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 others the other OWIDs to cover with the signature - * @return the signed OWID - * @throws OwidException see {@link #createString(String)} - */ - public Owid createString(String value, List others) - throws OwidException { if (value == null) { throw new OwidException("payload is null"); } - return createBytes(value.getBytes(StandardCharsets.UTF_8), others); + return createBytes(value.getBytes(StandardCharsets.UTF_8)); } /** - * Creates a new signed OWID carrying the bytes as the payload, with the - * other OWIDs covered by the same signature. + * Creates a new signed OWID for this creator carrying the bytes as the + * payload. The signature covers the fields of the OWID and nothing else. * *

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 @@ -168,24 +137,19 @@ public Owid createString(String value, List others) * 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.

* - * @param value the payload bytes - * @param others the other OWIDs to cover with the signature + * @param value the payload bytes * @return the signed OWID - * @throws OwidException see {@link #createBytes(byte[])} + * @throws OwidException if the payload is null, a field cannot be + * encoded, or the signing operation fails */ - public Owid createBytes(byte[] value, List others) - throws OwidException { + public Owid createBytes(byte[] value) 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[] data = Owid.dataForCrypto(version, domain, date, payload); byte[] signature = crypto.signByteArray(data); if (signature.length != Owid.SIGNATURE_LENGTH) { throw Io.invalidSignatureLength(signature.length); diff --git a/src/main/java/com/swancommunity/owid/Endpoints.java b/src/main/java/com/swancommunity/owid/Endpoints.java index 8ccc1ed..8ead5fc 100644 --- a/src/main/java/com/swancommunity/owid/Endpoints.java +++ b/src/main/java/com/swancommunity/owid/Endpoints.java @@ -16,37 +16,25 @@ package com.swancommunity.owid; +import java.time.Duration; +import java.time.Instant; /** * Helpers for hosting the well known end points required by the OWID * specification. These are framework agnostic. They return the path and body * so that any HTTP server can serve them. * - *

The mandatory end points are:

- * - *
    - *
  • {@code /owid/api/v{version}/creator} returning JSON with the domain, - * common name, and public key of the creator.
  • - *
  • {@code /owid/api/v{version}/public-key} returning the public key as - * PEM text. The {@code format} query parameter must be {@code spki} or - * {@code pkcs}.
  • - *
+ *

The mandatory end point is {@code /owid/api/v{version}/public-key}, + * returning a JSON object carrying the public key as {@code publicKey}, the + * encoding it is in as {@code format}, and the moments it is valid from and + * to. The one format defined is {@code spki}, which a request without the + * parameter receives, and a request for any other value is answered + * 400.

*/ public final class Endpoints { private Endpoints() { } - /** - * Returns the path of the creator end point for the version provided. For - * example {@code /owid/api/v3/creator}. - * - * @param version the OWID version - * @return the creator path - */ - public static String creatorPath(Version version) { - return "/owid/api/v" + (version.asByte() & 0xFF) + "/creator"; - } - /** * Returns the path of the public key end point for the version provided. * For example {@code /owid/api/v3/public-key}. @@ -59,91 +47,137 @@ public static String publicKeyPath(Version version) { } /** - * Returns the JSON body for the creator end point. The JSON has the - * fields domain, name, publicKeySPKI, and contractURL named exactly as - * required by the specification. + * Returns the JSON body for the public key end point of a creator with + * one key and no schedule. The key is stated as {@code publicKey} in the + * {@code spki} format and both {@code validFrom} and {@code validTo} are + * null, because the creator knows nothing about when the key started or + * will stop. * - * @param creator the creator - * @param name the common name of the creator - * @param contractUrl the URL with the terms associated with the data + * @param creator the creator + * @param format the format parameter, {@code spki} or null where the + * request has none * @return the JSON body - * @throws OwidException if the public key cannot be exported + * @throws OwidException if the format is one this library does not + * serve, which a creator answers 400, or the public + * key cannot be exported or read back + */ + public static String publicKeyResponse(Creator creator, String format) + throws OwidException { + if (served(format) == false) { + // 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("the only format served is " + + PublicKeyResponse.SPKI_FORMAT); + } + return publicKeyAnswer(creator.crypto().subjectPublicKeyInfo(), null, + null, null); + } + + /** + * Whether the format parameter asks for the one encoding this library + * serves, which a request without the parameter is taken to ask for. */ - public static String creatorResponse(Creator creator, String name, - String contractUrl) throws OwidException { - String spki = creator.crypto().subjectPublicKeyInfo(); - StringBuilder json = new StringBuilder(); - json.append('{'); - appendField(json, "domain", creator.domain()); - json.append(','); - appendField(json, "name", name); - json.append(','); - appendField(json, "publicKeySPKI", spki); - json.append(','); - appendField(json, "contractURL", contractUrl); - json.append('}'); - return json.toString(); + private static boolean served(String format) { + return format == null || format.isEmpty() + || PublicKeyResponse.SPKI_FORMAT.equals(format); } /** - * Returns the text body for the public key end point. The specification - * allows the key to be requested in SPKI or PKCS form. This - * implementation returns the SPKI PEM for both values because the - * importers accept it. + * Returns the JSON body of the public key end point for the key and the + * span it covers, checked with {@link PublicKeyResponse#validate(Instant)} + * first so that a creator never sends an answer it would itself refuse. * - * @param creator the creator - * @param format the format parameter, {@code spki} or {@code pkcs} - * @return the public key PEM - * @throws OwidException if the format is not valid, or the public key - * cannot be exported + * @param publicKeyPem the key in PEM form + * @param validFrom the UTC moment the key came into force, or null + * @param validTo the UTC moment the next key starts, or null + * @param asked the moment the request asks about, or null + * @return the JSON body + * @throws OwidException if the answer would not be valid */ - public static String publicKeyResponse(Creator creator, String format) - throws OwidException { - if ("spki".equals(format) || "pkcs".equals(format)) { - return creator.crypto().subjectPublicKeyInfo(); - } - // 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"); + public static String publicKeyAnswer(String publicKeyPem, Instant validFrom, + Instant validTo, Instant asked) throws OwidException { + PublicKeyResponse answer = PublicKeyResponse.of(publicKeyPem, validFrom, + validTo); + answer.validate(asked); + return answer.toJson(); } - private static void appendField(StringBuilder json, String name, - String value) { - json.append('"').append(name).append("\":\"") - .append(escape(value)).append('"'); + /** + * The status code and body a public key end point answers a request + * with. + */ + public static final class Response { + private final int status; + private final String body; + + Response(int status, String body) { + this.status = status; + this.body = body; + } + + /** The HTTP status code. */ + public int getStatus() { + return status; + } + + /** The body, empty where the status is not 200. */ + public String getBody() { + return body; + } } - /** Escapes a string for inclusion in a JSON string literal. */ - private static String escape(String value) { - StringBuilder builder = new StringBuilder(value.length()); - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - switch (c) { - case '"': - builder.append("\\\""); - break; - case '\\': - builder.append("\\\\"); - break; - case '\n': - builder.append("\\n"); - break; - case '\r': - builder.append("\\r"); - break; - case '\t': - builder.append("\\t"); - break; - default: - if (c < 0x20) { - builder.append(String.format("\\u%04x", (int) c)); - } else { - builder.append(c); - } + /** + * Returns the status code and JSON body for the public key end point of + * a creator that rotates its key, chosen from the schedule the way the + * specification requires. + * + *

The date parameter is the OWID's own date, counted in whole minutes + * since 2020-01-01, and the key served is the one in force then, being + * the latest key whose start is at or before it. A request without a + * date, or with a date later than the moment of the request, is served + * the key in force at that moment, so a caller cannot ask for a key whose + * period has not begun. The answer is 200 with the body from + * {@link #publicKeyAnswer}, stating the key and the moments it is valid + * from and to, 404 with an empty body where no key is in force at the + * date, and 400 with an empty body where the date is not a count of + * minutes or the format is one this creator does not serve.

+ * + * @param schedule the published schedule + * @param format the format parameter, {@code spki} or null where the + * request has none + * @param date the date parameter, or null where the request has none + * @param now the moment of the request + * @return the status and body + * @throws OwidException if the answer would fail its check, which is a + * fault in the schedule + */ + public static Response publicKeyResponseAt(PublicKeySchedule schedule, + String format, String date, Instant now) throws OwidException { + if (served(format) == false) { + return new Response(400, ""); + } + Instant asked = now; + if (date != null && date.isEmpty() == false) { + long minutes; + try { + minutes = Long.parseLong(date); + } catch (NumberFormatException e) { + return new Response(400, ""); + } + if (minutes < 0 || minutes > 0xFFFFFFFFL) { + return new Response(400, ""); } + asked = Io.baseDate().plus(Duration.ofMinutes(minutes)); + if (asked.isAfter(now)) { + asked = now; + } + } + DatedPublicKey key = schedule.keyInForce(asked); + if (key == null) { + return new Response(404, ""); } - return builder.toString(); + return new Response(200, publicKeyAnswer(key.getPublicKeyPem(), + key.getStartsAt(), schedule.nextStartAfter(key), asked)); } } diff --git a/src/main/java/com/swancommunity/owid/HttpUrlConnectionTransport.java b/src/main/java/com/swancommunity/owid/HttpUrlConnectionTransport.java new file mode 100644 index 0000000..296ed7e --- /dev/null +++ b/src/main/java/com/swancommunity/owid/HttpUrlConnectionTransport.java @@ -0,0 +1,290 @@ +/* **************************************************************************** + * 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 java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The transport used where a caller names none, making the request with + * {@link HttpURLConnection} from the JDK. + * + *

HttpURLConnection blocks the thread that calls it, so the request is + * handed to an {@link Executor} and the future returned completes when the + * thread the executor gave it has the response. The thread that asked is + * never held. That is blocking I/O on a background thread, which is the + * best Java 8 offers without a dependency. On Java 11 and later supply a + * {@link PublicKeyTransport} of your own over + * {@code java.net.http.HttpClient.sendAsync} instead, which blocks no + * thread at all, and keep the two rules that interface describes.

+ * + *

Where no executor is given a pool shared by every instance is used. Its + * threads are daemon threads, so a process that is otherwise finished is not + * kept alive by a fetch still in flight, and there are never more of them + * than twice the processors available, with at least two. Requests beyond + * that wait in a queue rather than being refused, and a thread that has had + * nothing to do for a minute ends.

+ * + *

Only the JDK is used, so the library keeps its promise of no runtime + * dependencies.

+ */ +public final class HttpUrlConnectionTransport implements PublicKeyTransport { + + /** How long to wait for the connection to be made, in milliseconds. */ + private static final int CONNECT_TIMEOUT_MILLISECONDS = 5000; + + /** How long to wait for the response, in milliseconds. */ + private static final int READ_TIMEOUT_MILLISECONDS = 10000; + + /** How long an idle thread of the shared pool lives, in seconds. */ + private static final long IDLE_THREAD_SECONDS = 60; + + /** The executor the blocking request is handed to. */ + private final Executor executor; + + /** + * Creates a transport that runs each request on the shared pool of + * daemon threads described in the class comment. + */ + public HttpUrlConnectionTransport() { + this(SharedPool.INSTANCE); + } + + /** + * Creates a transport that runs each request on the executor given. + * + *

The executor is the caller's own, so its threads, their number and + * whether they are daemon threads are the caller's choice, and ending + * it when the process ends is the caller's job too.

+ * + * @param executor the executor to run each request on + * @throws IllegalArgumentException if the executor is missing + */ + public HttpUrlConnectionTransport(Executor executor) { + if (executor == null) { + throw new IllegalArgumentException("the executor is missing"); + } + this.executor = executor; + } + + @Override + public CompletableFuture fetch(final String url, + final String domain) { + final CompletableFuture future = + new CompletableFuture(); + try { + executor.execute(() -> { + try { + future.complete(read(url, domain)); + } catch (PublicKeyFetchException e) { + future.completeExceptionally(e); + } catch (Throwable e) { + // Nothing in read is expected to throw anything else, + // but a future that is never completed would hold a + // caller for ever, so whatever escaped is carried out + // as the key being unavailable. An Error is then + // rethrown, because the thread it happened on has to + // know as well. + future.completeExceptionally(unexpected(domain, e)); + if (e instanceof Error) { + throw (Error) e; + } + } + }); + } catch (RejectedExecutionException e) { + // An executor that has been shut down, or one with a bounded + // queue that is full, refuses the task at once. The refusal + // arrives through the future like every other failure, so a + // caller has one place to look. + future.completeExceptionally(new PublicKeyFetchException( + "the request for the public key of domain " + + quoted(domain) + + " was refused by the executor", + OwidSignatureStatus.KEY_UNAVAILABLE, + domain, + 0, + e)); + } + return future; + } + + /** Performs the request and returns the body as text. */ + private static String read(String url, String domain) + throws PublicKeyFetchException { + HttpURLConnection connection = null; + try { + // URI then toURL rather than the URL(String) constructor, + // which is deprecated from Java 20 and would fail a consumer + // compiling this source with warnings as errors. + URLConnection opened = new URI(url).toURL().openConnection(); + if ((opened instanceof HttpURLConnection) == false) { + // A scheme the caller chose that does not make an HTTP + // request, such as file. Reported as a key that could not be + // obtained rather than allowed to escape as a cast failure, + // because every route into this class promises a status. + throw new PublicKeyFetchException( + "the scheme used for domain " + quoted(domain) + + " does not make an HTTP request", + OwidSignatureStatus.KEY_UNAVAILABLE, + domain, + 0, + null); + } + connection = (HttpURLConnection) opened; + // Never follow a redirect. HttpURLConnection follows one to + // any other host by default, so a creator whose domain + // answered 302 to some other place would have that other + // place's key trusted as its own, and a network attacker able + // to bend the creator's DNS, or a creator that was simply + // misconfigured, could put a key there and have forgeries + // verify. Left alone, the 3xx is the response code, and the + // check below reads it as the key being unavailable, which it + // is. + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(CONNECT_TIMEOUT_MILLISECONDS); + connection.setReadTimeout(READ_TIMEOUT_MILLISECONDS); + connection.setRequestProperty("Accept", "application/json"); + int code = connection.getResponseCode(); + if (code != HttpURLConnection.HTTP_OK) { + drain(connection.getErrorStream()); + throw new PublicKeyFetchException( + "domain " + quoted(domain) + " returned code '" + code + + "' for the public key", + OwidSignatureStatus.KEY_UNAVAILABLE, + domain, + code, + null); + } + InputStream body = connection.getInputStream(); + try { + return new String(readAll(body), StandardCharsets.UTF_8); + } finally { + body.close(); + } + } catch (IOException | URISyntaxException e) { + // A refused connection, a name that does not resolve, a + // timeout and a url that will not parse all arrive here, and + // all of them mean the signature was never examined. + throw new PublicKeyFetchException( + "the public key could not be fetched from domain " + + quoted(domain), + OwidSignatureStatus.KEY_UNAVAILABLE, + domain, + 0, + e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + /** The failure to carry for something read was not expected to throw. */ + private static PublicKeyFetchException unexpected(String domain, + Throwable cause) { + return new PublicKeyFetchException( + "the request for the public key of domain " + quoted(domain) + + " failed unexpectedly", + OwidSignatureStatus.KEY_UNAVAILABLE, + domain, + 0, + cause); + } + + /** The value in single quotes, for a message. */ + private static String quoted(String value) { + return "'" + value + "'"; + } + + /** Reads a stream to its end. */ + private static byte[] readAll(InputStream stream) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] block = new byte[4096]; + int read = stream.read(block); + while (read > 0) { + buffer.write(block, 0, read); + read = stream.read(block); + } + return buffer.toByteArray(); + } + + /** Closes the error body of a refused request, where there is one. */ + private static void drain(InputStream stream) { + if (stream == null) { + return; + } + try { + stream.close(); + } catch (IOException e) { + // Nothing useful can be done about a body that will not close, + // and the refusal itself is what the caller is told about. + } + } + + /** + * The pool shared by every transport created without an executor. The + * pool starts no thread until a request is handed to it, so a process + * that only ever verifies with keys it already holds starts none. + */ + private static final class SharedPool { + + static final Executor INSTANCE = create(); + + private SharedPool() { + } + + private static Executor create() { + int threads = Math.max(2, + Runtime.getRuntime().availableProcessors() * 2); + ThreadPoolExecutor pool = new ThreadPoolExecutor(threads, threads, + IDLE_THREAD_SECONDS, TimeUnit.SECONDS, + new LinkedBlockingQueue(), + new ThreadFactory() { + private final AtomicInteger made = new AtomicInteger(); + + @Override + public Thread newThread(Runnable task) { + Thread thread = new Thread(task, + "owid-public-key-fetch-" + + made.incrementAndGet()); + thread.setDaemon(true); + return thread; + } + }); + // Core threads are the whole pool, so without this they would + // live for ever once started. + pool.allowCoreThreadTimeOut(true); + return pool; + } + } +} diff --git a/src/main/java/com/swancommunity/owid/Owid.java b/src/main/java/com/swancommunity/owid/Owid.java index 51f06cd..7f0b09a 100644 --- a/src/main/java/com/swancommunity/owid/Owid.java +++ b/src/main/java/com/swancommunity/owid/Owid.java @@ -24,14 +24,12 @@ import java.time.Instant; import java.util.Arrays; import java.util.Base64; -import java.util.List; /** * OWID structure which can be used as a node in a tree. * *

An OWID records that the processor operating the domain handled the - * payload, and any other OWIDs covered by the signature, at the date and time - * given.

+ * payload at the date and time given.

* *

An OWID is only worth anything because it is signed, so a caller cannot * build one. An instance reaches calling code by one of two routes, being @@ -109,7 +107,7 @@ public final class Owid { * *

A successful read says the bytes are a structurally valid OWID. It * says nothing about whether the signature is genuine, which is a - * separate question answered by {@link #verify(Crypto, List)}.

+ * separate question answered by {@link #verify(Crypto)}.

* * @param value the base 64 encoded OWID, which may be null * @return the OWID and {@link OwidParseStatus#PARSED}, or no value and @@ -281,12 +279,11 @@ private static void writeNoSignature(ByteArrayOutputStream buffer, } /** - * Builds the byte array used for signing and verification. Contains the - * fields of this OWID without the signature, followed by the complete byte - * form of each of the others in the order provided. + * Builds the byte array used for signing and verification, being the + * fields of this OWID without the signature and nothing else. */ - byte[] dataForCrypto(List others) throws OwidException { - return dataForCrypto(version, domain, date, payload, others); + byte[] dataForCrypto() throws OwidException { + return dataForCrypto(version, domain, date, payload); } /** @@ -296,17 +293,10 @@ byte[] dataForCrypto(List others) throws OwidException { * then builds the finished OWID in one step. */ static byte[] dataForCrypto(Version version, String domain, Instant date, - byte[] payload, List others) throws OwidException { - int length = byteCount(version, domain, payload, null, false); - for (Owid other : others) { - length = addLength(length, other.byteCount(true)); - } - ExactByteArrayOutputStream buffer = - new ExactByteArrayOutputStream(length); + byte[] payload) throws OwidException { + ExactByteArrayOutputStream buffer = new ExactByteArrayOutputStream( + byteCount(version, domain, payload, null, false)); writeNoSignature(buffer, version, domain, date, payload); - for (Owid other : others) { - other.toBuffer(buffer); - } return buffer.toExactByteArray(); } @@ -428,38 +418,27 @@ public long ageMinutes() { } /** - * Verifies this OWID, and any others that were included when it was - * signed, using the crypto instance provided. Pass an empty list for the - * others when the OWID was signed on its own. + * Verifies this OWID using the crypto instance provided. * * @param crypto the crypto instance holding the public key - * @param others the other OWIDs that were signed together with this one, - * in the same order as when signed * @return true if the signature verifies, false otherwise * @throws OwidException if the crypto instance cannot verify, or a field * cannot be encoded */ - public boolean verifyWithCrypto(Crypto crypto, List others) - throws OwidException { - byte[] data = dataForCrypto(others); - return crypto.verifyByteArray(data, signature); + public boolean verifyWithCrypto(Crypto crypto) throws OwidException { + return crypto.verifyByteArray(dataForCrypto(), signature); } /** - * Verifies this OWID, and any others that were included when it was - * signed, using the public key in SPKI PEM form provided. + * Verifies this OWID using the public key in SPKI PEM form provided. * * @param publicPem the public key in SPKI PEM form - * @param others the other OWIDs that were signed together with this - * one, in the same order as when signed * @return true if the signature verifies, false otherwise * @throws OwidException if the PEM is not a valid public key, or a field * cannot be encoded */ - public boolean verifyWithPublicKey(String publicPem, List others) - throws OwidException { - Crypto crypto = Crypto.newVerifyOnly(publicPem); - return verifyWithCrypto(crypto, others); + public boolean verifyWithPublicKey(String publicPem) throws OwidException { + return verifyWithCrypto(Crypto.newVerifyOnly(publicPem)); } /** @@ -472,11 +451,9 @@ public boolean verifyWithPublicKey(String publicPem, List others) * * @param crypto the crypto instance holding the public key, which may be * null when no key could be obtained - * @param others the other OWIDs that were signed together with this one, - * in the same order as when signed * @return the outcome of the check */ - public OwidVerificationResult verify(Crypto crypto, List others) { + public OwidVerificationResult verify(Crypto crypto) { if (crypto == null || crypto.canVerify() == false) { return OwidVerificationResult.of( OwidSignatureStatus.KEY_UNAVAILABLE); @@ -487,7 +464,7 @@ public OwidVerificationResult verify(Crypto crypto, List others) { } byte[] data; try { - data = dataForCrypto(others); + data = dataForCrypto(); } catch (CapacityException e) { return OwidVerificationResult.of( OwidSignatureStatus.IMPLEMENTATION_CAPACITY_EXCEEDED); @@ -508,8 +485,8 @@ public OwidVerificationResult verify(Crypto crypto, List others) { } /** - * The same question as {@link #verify(Crypto, List)}, starting from the - * public key in SPKI PEM form. + * The same question as {@link #verify(Crypto)}, starting from the public + * key in SPKI PEM form. * *

Key material that cannot be decoded reports * {@link OwidSignatureStatus#INVALID_KEY}, because the fault is in the @@ -517,12 +494,9 @@ public OwidVerificationResult verify(Crypto crypto, List others) { * * @param publicPem the public key in SPKI PEM form, which may be null * when no key could be obtained - * @param others the other OWIDs that were signed together with this - * one, in the same order as when signed * @return the outcome of the check */ - public OwidVerificationResult verify(String publicPem, - List others) { + public OwidVerificationResult verify(String publicPem) { if (publicPem == null || publicPem.trim().isEmpty()) { return OwidVerificationResult.of( OwidSignatureStatus.KEY_UNAVAILABLE); @@ -534,7 +508,7 @@ public OwidVerificationResult verify(String publicPem, return OwidVerificationResult.of( OwidSignatureStatus.INVALID_KEY); } - return verify(crypto, others); + return verify(crypto); } /** diff --git a/src/main/java/com/swancommunity/owid/OwidSignatureStatus.java b/src/main/java/com/swancommunity/owid/OwidSignatureStatus.java index e24153e..976baec 100644 --- a/src/main/java/com/swancommunity/owid/OwidSignatureStatus.java +++ b/src/main/java/com/swancommunity/owid/OwidSignatureStatus.java @@ -75,8 +75,8 @@ public enum OwidSignatureStatus { /** * The work required is more than this runtime can hold. * - *

Not covered by a test, because reaching it needs an OWID and its - * chain to approach the two gigabyte limit of a Java array, which cannot + *

Not covered by a test, because reaching it needs an OWID whose + * payload approaches the two gigabyte limit of a Java array, which cannot * be built in a test suite that has to run on an ordinary machine. The * path to it is real, being the overflow guard on the serialized length, * which raises a distinct exception so this status does not have to be diff --git a/src/main/java/com/swancommunity/owid/PublicKeyFetch.java b/src/main/java/com/swancommunity/owid/PublicKeyFetch.java index 47aa86b..53da3e1 100644 --- a/src/main/java/com/swancommunity/owid/PublicKeyFetch.java +++ b/src/main/java/com/swancommunity/owid/PublicKeyFetch.java @@ -16,16 +16,13 @@ package com.swancommunity.owid; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLConnection; -import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; /** * Fetches the signing public key of a creator from the well known end point @@ -33,7 +30,7 @@ * date the OWID carries. * *

The end point is - * {@code /owid/api/v{n}/public-key?date={minutes}&format=pkcs}, where the + * {@code /owid/api/v{n}/public-key?date={minutes}&format=spki}, where the * version in the path is the version byte of the OWID being checked rather * than a constant, and the minutes are counted from 2020-01-01 in the same * way the OWID stores the date. Creators rotate weekly, so without the date @@ -43,6 +40,18 @@ * earlier key reads as not matching, which is why a creator that rotates its * key has to honour the date.

* + *

Every method here that reaches the network answers with a + * {@link CompletableFuture} and returns at once. There is no form that + * waits, so a request thread or an event loop is never held while a creator + * answers, and a caller that wants to wait joins the future itself. The + * request is made by a {@link PublicKeyTransport}, and where the caller + * names none {@link HttpUrlConnectionTransport} is used, which runs the + * JDK's blocking connection on a background thread. On Java 11 and later a + * caller can supply a transport over + * {@code java.net.http.HttpClient.sendAsync} instead, which blocks no thread + * at all. Building the URL is pure text and reaches nothing, so + * {@link #publicKeyUrl(Owid, String)} answers in the ordinary way.

+ * *

Only the JDK is used, so the library keeps its promise of no runtime * dependencies and still runs on Java 8, which has no HTTP client of its * own.

@@ -52,31 +61,151 @@ */ public final class PublicKeyFetch { - /** How long to wait for the connection to be made, in milliseconds. */ - private static final int CONNECT_TIMEOUT_MILLISECONDS = 5000; - - /** How long to wait for the response, in milliseconds. */ - private static final int READ_TIMEOUT_MILLISECONDS = 10000; - /** * The most keys held in the cache before the cache is emptied and filled - * again. A bound is needed because a verifier sees identifiers from many - * domains and many weeks, and an unbounded map would grow for as long as - * the process runs. + * again, across every creator. A bound is needed because a verifier sees + * identifiers from many domains and many weeks, and an unbounded store + * would grow for as long as the process runs. */ private static final int MAXIMUM_CACHED_KEYS = 1024; /** - * Keys already fetched, held against the URL the keys were fetched from. + * How far a creator's clock may run ahead of or behind this one's, in + * minutes. + * + *

It is used in two places. A creator that does not state the end of + * the span of the key it answers with reads a date later than its own + * now as now, so within this window of now this process cannot tell + * whether the creator read the minute as its past or as its present, and + * nothing learned from such an answer is held or served. And a creator's + * signing machines may not agree with the creator's own schedule to the + * minute, so an identifier dated within this window of an edge of the + * span the creator stated for a key that does not verify under that key + * is checked against the key for the minute just beyond that edge before + * it is reported as not matching.

+ */ + private static final long CLOCK_DRIFT_ALLOWANCE_MINUTES = 15; + + /** The minute {@link #minuteOf} answers where the URL names none. */ + private static final long NO_MINUTE = -1; + + /** The last minute an OWID can carry, being an unsigned 32 bit count. */ + private static final long MAXIMUM_MINUTE = 0xFFFFFFFFL; + + /** + * One key a creator has answered with, and the span of minutes the key + * is known to cover. + * + *

A creator's key is in force from the start of its period until the + * next key starts, so a key the creator confirms at two minutes was in + * force at every minute between them. Where the creator stated the span + * in its answer the span is explicit and complete, and an identifier + * dated anywhere inside it is verified without a request. Otherwise the + * span grows as the creator confirms the same key for more minutes.

+ */ + private static final class HeldKey { + /** The key in PEM form, as the creator served it. */ + final String pem; + /** The earliest minute the key is known to cover. */ + long first; + /** The latest minute the key is known to cover. */ + long last; + /** Whether the creator stated the whole span itself. */ + boolean explicit; + /** + * Whether the creator stated the start of the span and no end, so + * that as far as the creator has said the key is in force until + * further notice, whatever this cache holds it for. + */ + boolean openEnded; + + HeldKey(String pem, long first, long last, boolean explicit, + boolean openEnded) { + this.pem = pem; + this.first = first; + this.last = last; + this.explicit = explicit; + this.openEnded = openEnded; + } + + /** Whether the minute lies within the known span. */ + boolean covers(long minute) { + return first <= minute && minute <= last; + } + } + + /** + * What the cache or a fetch answers with. The key and, where the creator + * stated one, the span of minutes the creator says the key covers, so + * that a caller can tell whether the identifier it is checking sits near + * an edge of the span, or outside it altogether. A span stated with a + * start and no end runs to the last minute there is. + */ + private static final class KeyAnswer { + /** The key in PEM form. */ + final String pem; + /** The first minute the creator says the key covers. */ + final long first; + /** The last minute the creator says the key covers. */ + final long last; + /** Whether the creator stated a span at all. */ + final boolean known; + + KeyAnswer(String pem, long first, long last, boolean known) { + this.pem = pem; + this.first = first; + this.last = last; + this.known = known; + } + + static KeyAnswer unknown(String pem) { + return new KeyAnswer(pem, 0, 0, false); + } + + /** Whether the minute lies within the stated span. */ + boolean covers(long minute) { + return known && first <= minute && minute <= last; + } + } + + /** + * Guards {@link #CACHE}, {@link #heldKeys} and {@link #IN_FLIGHT}. Held + * across a few map and list operations only, never across a request. + */ + private static final Object LOCK = new Object(); + + /** + * Keys already fetched, by the creator's key end point, which is the key + * URL without its date. Each end point holds the keys the creator has + * answered with, each with the span of minutes the creator has confirmed + * it for. * *

The specification asks implementations to cache so that verifying * many identifiers does not mean repeating requests to another - * processor. Holding the key against the whole URL is safe because the - * URL names the domain, the version and the minute, and the key a - * creator published for a minute in the past does not change.

+ * processor. The key URL carries the date of the identifier being + * verified, in minutes, and a creator's key changes on the order of a + * week. Keyed by end point and span rather than by the whole URL, an identifier dated between two minutes the + * creator has already answered for is verified without a request.

*/ - private static final Map CACHE = - new ConcurrentHashMap(); + private static final Map> CACHE = + new HashMap>(); + + /** How many keys are held across every end point. */ + private static int heldKeys; + + /** + * Requests under way, by the dated URL asked for, so that a second + * request for a key that is still on its way joins the request already + * made instead of making another. An entry is removed the moment its + * request ends, whatever the outcome, so a failure is never handed to a + * later caller and an outage is never remembered. + */ + private static final Map> IN_FLIGHT = + new HashMap>(); + + /** The transport used where the caller names none. */ + private static final PublicKeyTransport DEFAULT_TRANSPORT = + new HttpUrlConnectionTransport(); private PublicKeyFetch() { } @@ -90,7 +219,8 @@ private PublicKeyFetch() { * rotates its key returns the key that was in force when this OWID was * signed. The parameter is left out where the date cannot be counted, * which no OWID this library reads can be, because the wire format - * cannot hold such a date.

+ * cannot hold such a date. The key is asked for by name in the one + * format this library reads, {@link PublicKeyResponse#SPKI_FORMAT}.

* * @param owid the OWID whose creator key is wanted * @param scheme the scheme to use, normally {@code https} @@ -117,203 +247,615 @@ public static String publicKeyUrl(Owid owid, String scheme) if (minutes >= 0) { url.append("date=").append(minutes).append('&'); } - url.append("format=pkcs"); + url.append("format=").append(PublicKeyResponse.SPKI_FORMAT); return url.toString(); } /** - * Returns the public key PEM of the creator of the OWID, for the date - * the OWID carries. + * Fetches the public key PEM of the creator of the OWID, for the date + * the OWID carries, using {@link HttpUrlConnectionTransport} on its + * shared pool. + * + *

Returns at once. The future completes with the key in PEM form, + * fails with a {@link PublicKeyFetchException} where the key could not + * be obtained, carrying the status to report for the identifier, and + * fails with an {@link OwidException} where the OWID, the scheme or the + * domain is not usable. Nothing is thrown from the call itself.

* * @param owid the OWID whose creator key is wanted * @param scheme the scheme to use, normally {@code https} - * @return the public key in PEM form - * @throws PublicKeyFetchException if the key could not be obtained, with - * the status to report for the - * identifier - * @throws OwidException if the OWID, the scheme or the domain - * is not usable + * @return the public key in PEM form, through a future */ - public static String publicKeyPem(Owid owid, String scheme) - throws OwidException { - return publicKeyPemAtUrl(publicKeyUrl(owid, scheme), - owid.getDomain()); + public static CompletableFuture publicKeyPem(Owid owid, + String scheme) { + return publicKeyPem(owid, scheme, DEFAULT_TRANSPORT); + } + + /** + * Fetches the public key PEM of the creator of the OWID, for the date + * the OWID carries, using the transport given. + * + *

Returns at once. The future completes with the key in PEM form, + * fails with a {@link PublicKeyFetchException} where the key could not + * be obtained, carrying the status to report for the identifier, and + * fails with an {@link OwidException} where the OWID, the scheme, the + * domain or the transport is not usable. Nothing is thrown from the + * call itself.

+ * + * @param owid the OWID whose creator key is wanted + * @param scheme the scheme to use, normally {@code https} + * @param transport the transport to make the request with + * @return the public key in PEM form, through a future + */ + public static CompletableFuture publicKeyPem(Owid owid, + String scheme, PublicKeyTransport transport) { + String url; + try { + url = publicKeyUrl(owid, scheme); + } catch (OwidException e) { + return failed(e); + } + return publicKeyPemAtUrl(url, owid.getDomain(), transport); } /** * Asks whether the signature on the OWID is genuine, fetching the key - * that was in force when the OWID was signed from the creator domain. + * that was in force when the OWID was signed from the creator domain + * using {@link HttpUrlConnectionTransport} on its shared pool. * - *

A key that cannot be fetched is + *

Returns at once, and the future never fails, because every route + * out of the fetch promises a status. A key that cannot be fetched is * {@link OwidSignatureStatus#KEY_UNAVAILABLE} and one that arrives in a * form this library cannot read is * {@link OwidSignatureStatus#INVALID_KEY}. Neither is * {@link OwidSignatureStatus#SIGNATURE_INVALID}, because an outage or a * badly served key leaves the signature unjudged, and reporting either - * as invalid would read as an attack.

+ * as invalid would read as an attack. The signature is examined on the + * thread that completes the fetch, which for the default transport is + * one of its pool, or on the caller's own thread where the key is + * already held.

* * @param owid the OWID to check * @param scheme the scheme to use, normally {@code https} - * @param others the other OWIDs that were signed together with this one, - * in the same order as when signed - * @return the outcome of the check + * @return the outcome of the check, through a future */ - public static OwidVerificationResult verify(Owid owid, String scheme, - List others) { + public static CompletableFuture verify(Owid owid, + String scheme) { + return verify(owid, scheme, DEFAULT_TRANSPORT); + } + + /** + * Asks whether the signature on the OWID is genuine, fetching the key + * that was in force when the OWID was signed from the creator domain + * using the transport given. + * + *

Returns at once, and the future never fails, because every route + * out of the fetch promises a status. A key that cannot be fetched, a + * URL that cannot be built and a transport that is missing are all + * {@link OwidSignatureStatus#KEY_UNAVAILABLE}, and a key that arrives in + * a form this library cannot read is + * {@link OwidSignatureStatus#INVALID_KEY}. Neither is + * {@link OwidSignatureStatus#SIGNATURE_INVALID}, because an outage or a + * badly served key leaves the signature unjudged, and reporting either + * as invalid would read as an attack. The signature is examined on the + * thread that completes the fetch, or on the caller's own thread where + * the key is already held.

+ * + * @param owid the OWID to check + * @param scheme the scheme to use, normally {@code https} + * @param transport the transport to make the request with + * @return the outcome of the check, through a future + */ + public static CompletableFuture verify(Owid owid, + String scheme, PublicKeyTransport transport) { String url; try { url = publicKeyUrl(owid, scheme); } catch (OwidException e) { - return OwidVerificationResult.of( - OwidSignatureStatus.KEY_UNAVAILABLE); + return CompletableFuture.completedFuture( + OwidVerificationResult.of( + OwidSignatureStatus.KEY_UNAVAILABLE)); } - return verifyAtUrl(owid, url, others); + return verifyAtUrl(owid, url, transport); } /** - * Empties the cache of keys already fetched. Provided so that a long - * running process can release the memory, and so that a test can start - * from a known state. + * Empties the cache of keys already fetched, and forgets the requests + * under way so that the next caller for any key starts a request of its + * own. A request already under way is not stopped and still completes + * for whoever holds its future. This is how a long running process drops + * a key it has learned it should no longer trust, after a creator + * rotates its key following a compromise, and how a test starts from a + * known state. */ public static void clearCache() { - CACHE.clear(); + synchronized (LOCK) { + CACHE.clear(); + heldKeys = 0; + IN_FLIGHT.clear(); + } + } + + /** How many keys the cache holds, for the tests. */ + static int cachedKeyCount() { + synchronized (LOCK) { + return heldKeys; + } } /** - * The work {@link #verify(Owid, String, List)} does once the URL is - * known, kept apart so that the tests drive the real fetch against a key - * end point the tests can stand up locally rather than against a near - * copy of the fetch. + * The work {@link #verify(Owid, String, PublicKeyTransport)} does once + * the URL is known, kept apart so that the tests drive the real + * fetch against a key end point the tests can stand up locally rather + * than against a near copy of the fetch. + * + *

The signature is checked under the key the end point serves for the + * OWID's own minute, and under the neighbouring key where that minute is + * within the clock drift allowance of an edge of the span the creator + * stated. A key the creator says was not in force at the OWID's minute + * proves nothing about the identifier, so where nothing verifies under + * such a key the answer is that the key is unavailable and not that the + * signature does not match.

*/ - static OwidVerificationResult verifyAtUrl(Owid owid, String url, - List others) { - String pem; - try { - pem = publicKeyPemAtUrl(url, owid.getDomain()); - } catch (PublicKeyFetchException e) { - return OwidVerificationResult.of(e.getStatus()); - } catch (OwidException e) { - return OwidVerificationResult.of( - OwidSignatureStatus.KEY_UNAVAILABLE); - } - return owid.verify(pem, others); + static CompletableFuture verifyAtUrl( + final Owid owid, final String url, + final PublicKeyTransport transport) { + final long minute = Io.minutesSinceBase(owid.getDate()); + return keyAtUrl(url, owid.getDomain(), transport) + .handle((answer, failure) -> { + if (failure != null) { + return CompletableFuture.completedFuture( + OwidVerificationResult.of(statusOf(failure))); + } + OwidVerificationResult result = owid.verify(answer.pem); + if (result.getStatus() + != OwidSignatureStatus.SIGNATURE_INVALID + || minute < 0) { + return CompletableFuture.completedFuture(result); + } + return neighbourVerifies(owid, minute, url, answer, + transport).thenApply(verified -> { + if (verified) { + return OwidVerificationResult.of( + OwidSignatureStatus.SIGNATURE_VALID); + } + if (answer.known + && answer.covers(minute) == false) { + return OwidVerificationResult.of( + OwidSignatureStatus.KEY_UNAVAILABLE); + } + return result; + }); + }) + .thenCompose(future -> future); } /** - * Fetches the PEM at the URL, answering from the cache where the same - * URL has already been fetched. + * Whether a key neighbouring the one the OWID's own minute selected + * verifies the signature instead. + * + *

A creator's signing machines may not agree with its own schedule to + * the minute, so an identifier dated just after a key started may have + * been signed with the key before it, and one dated just before may have + * been signed with the key after. Where the signature does not verify + * under the key selected and the OWID's minute is within the clock drift + * allowance of an edge of the span the creator stated for that key, the + * key for the minute just beyond that edge is asked for and tried. A key + * already held for that minute is not asked for again, and a neighbour + * that turns out to be the same key is not tried again. A creator that + * stated no span has one key and no schedule, so there is no neighbour + * to try. This costs at most two more requests, and only for a signature + * that has already failed.

*/ - static String publicKeyPemAtUrl(String url, String domain) - throws OwidException { - String cached = CACHE.get(url); - if (cached != null) { - return cached; + private static CompletableFuture neighbourVerifies( + final Owid owid, long minute, String url, final KeyAnswer tried, + PublicKeyTransport transport) { + if (tried.known == false) { + return CompletableFuture.completedFuture(false); } - String pem = read(url, domain); - if (CACHE.size() >= MAXIMUM_CACHED_KEYS) { - CACHE.clear(); + List beyond = new ArrayList(2); + if (tried.first > 0 && nearEdge(minute, tried.first)) { + beyond.add(tried.first - 1); + } + if (tried.last < MAXIMUM_MINUTE && nearEdge(minute, tried.last)) { + beyond.add(tried.last + 1); } - CACHE.put(url, pem); - return pem; + final String endPoint = endPointOf(url); + CompletableFuture verified = + CompletableFuture.completedFuture(false); + for (final long at : beyond) { + verified = verified.thenCompose(already -> { + if (already) { + return CompletableFuture.completedFuture(true); + } + return keyAtUrl(endPoint + "?date=" + at + "&format=" + + PublicKeyResponse.SPKI_FORMAT, + owid.getDomain(), transport) + .handle((neighbour, failure) -> failure == null + && neighbour.pem.equals(tried.pem) == false + && owid.verify(neighbour.pem).getStatus() + == OwidSignatureStatus.SIGNATURE_VALID); + }); + } + return verified; } - /** Performs the request and returns the body as text. */ - private static String read(String url, String domain) - throws OwidException { - HttpURLConnection connection = null; - try { - URLConnection opened = new URL(url).openConnection(); - if ((opened instanceof HttpURLConnection) == false) { - // A scheme the caller chose that does not make an HTTP - // request, such as file. Reported as a key that could not be - // obtained rather than allowed to escape as a cast failure, - // because every route into this class promises a status. - throw new PublicKeyFetchException( - "the scheme used for domain " + quoted(domain) - + " does not make an HTTP request", - OwidSignatureStatus.KEY_UNAVAILABLE, - domain, - 0, - null); + /** + * Whether the minute is no further from the edge minute than the clocks + * of a creator's signing machines are allowed to differ from its + * schedule. + */ + private static boolean nearEdge(long minute, long edge) { + return Math.abs(minute - edge) <= CLOCK_DRIFT_ALLOWANCE_MINUTES; + } + + /** + * Fetches the PEM at the URL. See {@link #keyAtUrl}. + */ + static CompletableFuture publicKeyPemAtUrl(String url, + String domain, PublicKeyTransport transport) { + return keyAtUrl(url, domain, transport).thenApply(answer -> answer.pem); + } + + /** + * Fetches the key the URL asks for, with the span it is known to cover. + * Answered from the cache where a held key is known to cover the minute + * the URL names, from a request already under way for the same URL where + * there is one, and otherwise through the transport. The creator's + * answer states the moments the key is valid from and to, so the whole + * span is held from that one answer. + * + *

The future held for a request under way is this class's own rather + * than the transport's, so that the transport's completion can be + * watched, the answer read and held, and a failure forgotten, all before + * the callers waiting are answered.

+ */ + static CompletableFuture keyAtUrl(final String url, + final String domain, PublicKeyTransport transport) { + if (transport == null) { + return failed(new OwidException("the transport is missing")); + } + final String endPoint = endPointOf(url); + final CompletableFuture fetch; + synchronized (LOCK) { + KeyAnswer held = heldFor(endPoint, url); + if (held != null) { + return CompletableFuture.completedFuture(held); } - connection = (HttpURLConnection) opened; - // Never follow a redirect. HttpURLConnection follows one to - // any other host by default, so a creator whose domain - // answered 302 to some other place would have that other - // place's key trusted as its own, and a network attacker able - // to bend the creator's DNS, or a creator that was simply - // misconfigured, could put a key there and have forgeries - // verify. Left alone, the 3xx is the response code, and the - // check below reads it as the key being unavailable, which it - // is. - connection.setInstanceFollowRedirects(false); - connection.setRequestMethod("GET"); - connection.setConnectTimeout(CONNECT_TIMEOUT_MILLISECONDS); - connection.setReadTimeout(READ_TIMEOUT_MILLISECONDS); - connection.setRequestProperty("Accept", "text/plain"); - int code = connection.getResponseCode(); - if (code != HttpURLConnection.HTTP_OK) { - drain(connection.getErrorStream()); - throw new PublicKeyFetchException( - "domain " + quoted(domain) + " returned code '" + code - + "' for the public key", - OwidSignatureStatus.KEY_UNAVAILABLE, - domain, - code, - null); + CompletableFuture shared = IN_FLIGHT.get(url); + if (shared != null) { + // Another caller asked for the same key and its fetch is + // the one both callers share. + return shared; } - InputStream body = connection.getInputStream(); - try { - return new String(readAll(body), StandardCharsets.UTF_8); - } finally { - body.close(); + fetch = new CompletableFuture(); + IN_FLIGHT.put(url, fetch); + } + CompletableFuture started; + try { + started = transport.fetch(url, domain); + } catch (RuntimeException e) { + // A transport keeps its promise by failing the future rather + // than throwing, but one that breaks the promise must not leave + // a future among the requests under way that never completes. + started = failed(e); + } + if (started == null) { + started = failed(new PublicKeyFetchException( + "the transport returned no future for domain '" + domain + + "'", + OwidSignatureStatus.KEY_UNAVAILABLE, domain, 0, null)); + } + started.whenComplete((body, failure) -> { + if (failure == null && body != null) { + KeyAnswer answer; + try { + answer = readAnswer(body, domain, endPoint, url); + } catch (PublicKeyFetchException unreadable) { + synchronized (LOCK) { + forget(url, fetch); + } + fetch.completeExceptionally(unreadable); + return; + } + synchronized (LOCK) { + forget(url, fetch); + } + fetch.complete(answer); + return; } - } catch (IOException e) { - // A refused connection, a name that does not resolve and a - // timeout all arrive here, and all of them mean the signature - // was never examined. - throw new PublicKeyFetchException( - "the public key could not be fetched from domain " - + quoted(domain), - OwidSignatureStatus.KEY_UNAVAILABLE, - domain, - 0, - e); - } finally { - if (connection != null) { - connection.disconnect(); + synchronized (LOCK) { + forget(url, fetch); } + fetch.completeExceptionally(failure != null + ? unwrap(failure) + : new PublicKeyFetchException( + "the transport returned no key for domain '" + + domain + "'", + OwidSignatureStatus.KEY_UNAVAILABLE, domain, 0, + null)); + }); + return fetch; + } + + /** + * Reads a public key answer and holds the key it carries against the + * span it states, or against the minute asked about where it states + * none. An answer that is not the JSON form the specification requires, + * the PEM alone among the other forms, that states a format this library + * does not read, or that fails the checks a creator applies before + * sending it, is reported as a key that cannot be read. + */ + private static KeyAnswer readAnswer(String body, String domain, + String endPoint, String url) throws PublicKeyFetchException { + PublicKeyResponse answer; + try { + answer = PublicKeyResponse.parse(body); + answer.validate(null); + } catch (OwidException e) { + throw new PublicKeyFetchException( + "domain " + quoted(domain) + " answered with a public key " + + "answer that is not valid: " + e.getMessage(), + OwidSignatureStatus.INVALID_KEY, domain, 0, e); + } + synchronized (LOCK) { + return hold(endPoint, url, answer.getPublicKey(), + minutesOrNull(answer.getValidFrom()), + minutesOrNull(answer.getValidTo())); } } - /** The value in single quotes, for a message. */ + /** The moment as minutes since the base date, or null. */ + private static Long minutesOrNull(Instant moment) { + if (moment == null) { + return null; + } + long minutes = Io.minutesSinceBase(moment); + return minutes < 0 ? null : Long.valueOf(minutes); + } + private static String quoted(String value) { return "'" + value + "'"; } - /** Reads a stream to its end. */ - private static byte[] readAll(InputStream stream) throws IOException { - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - byte[] block = new byte[4096]; - int read = stream.read(block); - while (read > 0) { - buffer.write(block, 0, read); - read = stream.read(block); + /** + * Removes the request from those under way. Only this request is + * removed, never whatever replaced it after the cache was emptied and a + * fresh request started for the same URL in the meantime. Called under + * the lock. + */ + private static void forget(String url, CompletableFuture fetch) { + if (IN_FLIGHT.get(url) == fetch) { + IN_FLIGHT.remove(url); + } + } + + /** + * The key URL without its query, which names the scheme, the creator and + * the version, and so the key end point being asked. + */ + private static String endPointOf(String url) { + int query = url.indexOf('?'); + return query < 0 ? url : url.substring(0, query); + } + + /** + * The minute the URL asks about, or {@link #NO_MINUTE} where it names + * none. + */ + private static long minuteOf(String url) { + int query = url.indexOf('?'); + if (query < 0) { + return NO_MINUTE; + } + for (String pair : url.substring(query + 1).split("&")) { + if (pair.startsWith("date=")) { + try { + long minute = Long.parseLong(pair.substring(5)); + return minute < 0 ? NO_MINUTE : minute; + } catch (NumberFormatException notANumber) { + return NO_MINUTE; + } + } } - return buffer.toByteArray(); + return NO_MINUTE; } - /** Closes the error body of a refused request, where there is one. */ - private static void drain(InputStream stream) { - if (stream == null) { - return; + /** + * Whether the minute lies within the clock drift allowance of now or + * later, which is a minute a creator that does not state its spans may + * have read as its present rather than as the minute named. + */ + private static boolean recent(long minute) { + return minute > Io.minutesSinceBase(Instant.now()) + - CLOCK_DRIFT_ALLOWANCE_MINUTES; + } + + /** + * The key held for the end point that is known to cover the minute the + * URL asks about, or null where none is. Called under the lock. + * + *

A minute within the drift allowance of now is only served where the + * creator itself stated the span, because a span confirmed minute by + * minute says nothing certain about such a minute.

+ */ + private static KeyAnswer heldFor(String endPoint, String url) { + long minute = minuteOf(url); + if (minute == NO_MINUTE) { + return null; } - try { - stream.close(); - } catch (IOException e) { - // Nothing useful can be done about a body that will not close, - // and the refusal itself is what the caller is told about. + List keys = CACHE.get(endPoint); + if (keys != null) { + boolean recent = recent(minute); + for (HeldKey key : keys) { + if (key.covers(minute) && (key.explicit || recent == false)) { + return statedFor(key); + } + } + } + return null; + } + + /** + * The span the creator stated for a held key, which is the whole held + * span where the creator stated it, runs to the last minute there is + * where the creator stated a start and no end, and is nothing where the + * creator stated no span. + */ + private static KeyAnswer statedFor(HeldKey key) { + if (key.explicit) { + return new KeyAnswer(key.pem, key.first, key.last, true); + } + if (key.openEnded) { + return new KeyAnswer(key.pem, key.first, MAXIMUM_MINUTE, true); + } + return KeyAnswer.unknown(key.pem); + } + + /** + * The span the creator stated in its answer. See + * {@link #statedFor(HeldKey)}. + */ + private static KeyAnswer stated(String pem, Long start, Long end) { + if (start == null) { + return KeyAnswer.unknown(pem); + } + if (end != null && end > start) { + return new KeyAnswer(pem, start, end - 1, true); + } + return new KeyAnswer(pem, start, MAXIMUM_MINUTE, true); + } + + /** + * Records the creator's answer to the URL, being the key and, where the + * creator stated it, the span the key covers as the minute it came into + * force and the minute the next key starts. Returns the key with the span + * the creator stated for it. Called under the lock. + * + *

With both the start and the end the whole span is held as the + * creator's own statement. With the start alone the key is held from the + * start up to the drift allowance behind now, because no later key can + * have started before then. With neither the minute asked about is held + * on its own, as long as it is not within the drift allowance of now. A + * key already held for the end point has its span widened to take in the + * new one. A key not held before is added, emptying the cache first when + * it is full, because the cache must not grow on the input of whoever + * presents the identifiers.

+ */ + private static KeyAnswer hold(String endPoint, String url, String pem, + Long start, Long end) { + KeyAnswer stated = stated(pem, start, end); + long minute = minuteOf(url); + long first; + long last; + boolean explicit = false; + boolean openEnded = false; + if (start != null && end != null && end > start) { + first = start; + last = end - 1; + explicit = true; + } else if (start != null) { + first = start; + last = Math.max(start, Io.minutesSinceBase(Instant.now()) + - CLOCK_DRIFT_ALLOWANCE_MINUTES); + openEnded = true; + } else if (minute != NO_MINUTE && recent(minute) == false) { + first = minute; + last = minute; + } else { + return stated; + } + List keys = CACHE.get(endPoint); + if (keys != null) { + for (HeldKey key : keys) { + if (key.pem.equals(pem)) { + if (widen(keys, key, first, last)) { + key.explicit = key.explicit || explicit; + key.openEnded = key.explicit == false + && (key.openEnded || openEnded); + } + // Where the span was not widened the creator has + // answered with another key inside it before, which it + // does not do unless it went back to a key it had left, + // and nothing more is held about this key. + return stated; + } + } + for (HeldKey other : keys) { + if (other.last >= first && other.first <= last) { + return stated; + } + } + } + if (heldKeys >= MAXIMUM_CACHED_KEYS) { + CACHE.clear(); + heldKeys = 0; + keys = null; + } + if (keys == null) { + keys = new ArrayList(); + CACHE.put(endPoint, keys); + } + keys.add(new HeldKey(pem, first, last, explicit, openEnded)); + heldKeys++; + return stated; + } + + /** + * Widens the span of a held key to take in the span given, and says + * whether it did. + * + *

The span is not widened across a minute the creator has answered + * with another key for, because that would mean the creator had gone + * back to a key it had left, and the minutes between the two spans are + * then not this key's to claim.

+ */ + private static boolean widen(List keys, HeldKey key, long first, + long last) { + first = Math.min(first, key.first); + last = Math.max(last, key.last); + for (HeldKey other : keys) { + if (other != key && other.last >= first && other.first <= last) { + return false; + } + } + key.first = first; + key.last = last; + return true; + } + + /** A future that has already failed with the exception given. */ + private static CompletableFuture failed(Throwable failure) { + CompletableFuture future = new CompletableFuture(); + future.completeExceptionally(failure); + return future; + } + + /** + * The exception a failed future carries, with the wrapper a dependent + * future adds taken off so the one the transport raised is what a + * caller sees. + */ + private static Throwable unwrap(Throwable failure) { + Throwable cause = failure; + while (cause instanceof CompletionException + && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause; + } + + /** + * The status to report for a fetch that failed. A fetch failure carries + * its own status, and anything else, such as a URL that could not be + * built, means the key was never obtained. + */ + private static OwidSignatureStatus statusOf(Throwable failure) { + Throwable cause = unwrap(failure); + if (cause instanceof PublicKeyFetchException) { + return ((PublicKeyFetchException) cause).getStatus(); } + return OwidSignatureStatus.KEY_UNAVAILABLE; } /** diff --git a/src/main/java/com/swancommunity/owid/PublicKeyFetchException.java b/src/main/java/com/swancommunity/owid/PublicKeyFetchException.java index 22cc71a..20e04da 100644 --- a/src/main/java/com/swancommunity/owid/PublicKeyFetchException.java +++ b/src/main/java/com/swancommunity/owid/PublicKeyFetchException.java @@ -17,7 +17,8 @@ package com.swancommunity.owid; /** - * Raised when the public key of a creator could not be obtained. + * Carried by the failed future when the public key of a creator could not + * be obtained. * *

The status to report is decided where the failure happens and carried * here, so a caller never has to read message text to tell an outage from a diff --git a/src/main/java/com/swancommunity/owid/PublicKeyResponse.java b/src/main/java/com/swancommunity/owid/PublicKeyResponse.java new file mode 100644 index 0000000..1d66987 --- /dev/null +++ b/src/main/java/com/swancommunity/owid/PublicKeyResponse.java @@ -0,0 +1,367 @@ +/* **************************************************************************** + * 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 java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The JSON body of the public key end point. It carries the key, the + * encoding the key is in, and the moments it is valid from and to, in UTC, + * so a client holds the key for the whole span from one answer rather than + * asking again for every minute. + * + *

{@code validFrom} is null where the creator has a single key and no + * schedule, and {@code validTo} is null where no later key has been + * scheduled. Both the creator that sends the answer and the client that reads + * it check it with {@link #validate(Instant)}, so a fault in a creator's + * schedule or store is a server error at the creator rather than a bad answer + * a client then has to refuse.

+ * + *

Only the JDK is used, so the library keeps its promise of no runtime + * dependencies. The answer is a flat object of four fields, each a string or + * null, which is all the reading and writing here supports.

+ */ +public final class PublicKeyResponse { + + /** + * The one encoding of the key this library reads and writes, a Subject + * Public Key Info PEM. A request that asks for no format is answered in + * this one. + */ + public static final String SPKI_FORMAT = "spki"; + + private final String format; + private final String publicKey; + private final Instant validFrom; + private final Instant validTo; + + private PublicKeyResponse(String format, String publicKey, + Instant validFrom, Instant validTo) { + this.format = format; + this.publicKey = publicKey; + this.validFrom = validFrom; + this.validTo = validTo; + } + + /** + * An answer for the key in the one format this library writes and the + * moments it is valid from and to, either of which may be null. + * + * @param publicKey the key as a Subject Public Key Info PEM + * @param validFrom the UTC moment the key came into force, or null + * @param validTo the UTC moment the next key starts, or null + * @return the answer, not yet checked + */ + public static PublicKeyResponse of(String publicKey, Instant validFrom, + Instant validTo) { + return new PublicKeyResponse(SPKI_FORMAT, publicKey, validFrom, + validTo); + } + + /** + * The encoding of the key, which is {@link #SPKI_FORMAT} for any answer + * this library can read. + */ + public String getFormat() { + return format; + } + + /** The public key in the encoding {@link #getFormat()} names. */ + public String getPublicKey() { + return publicKey; + } + + /** The UTC moment the key came into force, or null where not known. */ + public Instant getValidFrom() { + return validFrom; + } + + /** The UTC moment the next key starts, or null where none is scheduled. */ + public Instant getValidTo() { + return validTo; + } + + /** + * Checks the answer the way both the creator that sends it and the client + * that reads it must. The format must be the one this library reads and + * the key must be a public key in it, a key valid to a moment must be + * valid from an earlier one, and where the moment asked about is known + * the key must have come into force by then and, if it has an end, not + * have ended. + * + * @param asked the moment asked about, or null where it is not known + * @throws OwidException if the answer is not valid + */ + public void validate(Instant asked) throws OwidException { + if (SPKI_FORMAT.equals(format) == false) { + throw new OwidException("the public key answer states a format " + + "this library does not read"); + } + if (publicKey == null || publicKey.trim().isEmpty()) { + throw new OwidException("the public key answer holds no key"); + } + try { + Crypto.newVerifyOnly(publicKey); + } catch (OwidException e) { + throw new OwidException( + "the public key answer holds a key that cannot be read"); + } + if (validTo != null) { + if (validFrom == null) { + throw new OwidException("the public key answer states when " + + "the key ends but not when it started"); + } + if (validTo.isAfter(validFrom) == false) { + throw new OwidException("the public key answer states a key " + + "that ends before it starts"); + } + } + if (asked != null) { + if (validFrom != null && validFrom.isAfter(asked)) { + throw new OwidException("the public key answer states a key " + + "that had not started at the moment asked about"); + } + if (validTo != null && validTo.isAfter(asked) == false) { + throw new OwidException("the public key answer states a key " + + "that had ended at the moment asked about"); + } + } + } + + /** + * The answer as JSON, with the moments as RFC 3339 strings in UTC and + * null where there is no moment. + * + * @return the JSON body + */ + public String toJson() { + StringBuilder json = new StringBuilder("{\"format\":"); + appendString(json, format); + json.append(",\"publicKey\":"); + appendString(json, publicKey); + json.append(",\"validFrom\":"); + appendMoment(json, validFrom); + json.append(",\"validTo\":"); + appendMoment(json, validTo); + return json.append('}').toString(); + } + + /** + * Reads an answer from its JSON body. An answer that names no format is + * read as {@link #SPKI_FORMAT}, the encoding a request that asks for none + * receives. + * + * @param json the body + * @return the answer, not yet checked with {@link #validate(Instant)} + * @throws OwidException if the body is not a JSON object of the four + * fields, each a string or null + */ + public static PublicKeyResponse parse(String json) throws OwidException { + Map fields = readFlatObject(json); + return new PublicKeyResponse( + fields.containsKey("format") + ? fields.get("format") + : SPKI_FORMAT, + fields.get("publicKey"), + moment(fields.get("validFrom"), "validFrom"), + moment(fields.get("validTo"), "validTo")); + } + + private static Instant moment(String text, String field) + throws OwidException { + if (text == null) { + return null; + } + try { + return Instant.parse(text); + } catch (DateTimeParseException e) { + throw new OwidException("the public key answer's " + field + + " is not a moment in UTC"); + } + } + + private static void appendMoment(StringBuilder json, Instant moment) { + if (moment == null) { + json.append("null"); + } else { + appendString(json, moment.toString()); + } + } + + private static void appendString(StringBuilder json, String value) { + if (value == null) { + json.append("null"); + return; + } + json.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': + json.append("\\\""); + break; + case '\\': + json.append("\\\\"); + break; + case '\n': + json.append("\\n"); + break; + case '\r': + json.append("\\r"); + break; + case '\t': + json.append("\\t"); + break; + default: + if (c < 0x20) { + json.append(String.format("\\u%04x", (int) c)); + } else { + json.append(c); + } + } + } + json.append('"'); + } + + /** + * Reads a JSON object whose values are strings or null. Anything else is + * refused, because the answer has no other shape. + */ + private static Map readFlatObject(String json) + throws OwidException { + Map fields = new LinkedHashMap(); + if (json == null) { + throw notJson(); + } + int[] at = {skipSpace(json, 0)}; + if (at[0] >= json.length() || json.charAt(at[0]) != '{') { + throw notJson(); + } + at[0]++; + at[0] = skipSpace(json, at[0]); + if (at[0] < json.length() && json.charAt(at[0]) == '}') { + return fields; + } + while (true) { + at[0] = skipSpace(json, at[0]); + String name = readString(json, at); + at[0] = skipSpace(json, at[0]); + if (at[0] >= json.length() || json.charAt(at[0]) != ':') { + throw notJson(); + } + at[0]++; + at[0] = skipSpace(json, at[0]); + if (json.startsWith("null", at[0])) { + fields.put(name, null); + at[0] += 4; + } else { + fields.put(name, readString(json, at)); + } + at[0] = skipSpace(json, at[0]); + if (at[0] >= json.length()) { + throw notJson(); + } + char next = json.charAt(at[0]); + at[0]++; + if (next == '}') { + break; + } + if (next != ',') { + throw notJson(); + } + } + if (skipSpace(json, at[0]) != json.length()) { + throw notJson(); + } + return fields; + } + + private static int skipSpace(String json, int at) { + while (at < json.length() && Character.isWhitespace(json.charAt(at))) { + at++; + } + return at; + } + + private static String readString(String json, int[] at) + throws OwidException { + if (at[0] >= json.length() || json.charAt(at[0]) != '"') { + throw notJson(); + } + at[0]++; + StringBuilder value = new StringBuilder(); + while (at[0] < json.length()) { + char c = json.charAt(at[0]++); + if (c == '"') { + return value.toString(); + } + if (c != '\\') { + value.append(c); + continue; + } + if (at[0] >= json.length()) { + throw notJson(); + } + char escaped = json.charAt(at[0]++); + switch (escaped) { + case '"': + case '\\': + case '/': + value.append(escaped); + break; + case 'b': + value.append('\b'); + break; + case 'f': + value.append('\f'); + break; + case 'n': + value.append('\n'); + break; + case 'r': + value.append('\r'); + break; + case 't': + value.append('\t'); + break; + case 'u': + if (at[0] + 4 > json.length()) { + throw notJson(); + } + try { + value.append((char) Integer.parseInt( + json.substring(at[0], at[0] + 4), 16)); + } catch (NumberFormatException e) { + throw notJson(); + } + at[0] += 4; + break; + default: + throw notJson(); + } + } + throw notJson(); + } + + private static OwidException notJson() { + return new OwidException("the public key answer is not the JSON " + + "object of four fields the specification requires"); + } +} diff --git a/src/main/java/com/swancommunity/owid/PublicKeySchedule.java b/src/main/java/com/swancommunity/owid/PublicKeySchedule.java index d6745b3..b82c3a8 100644 --- a/src/main/java/com/swancommunity/owid/PublicKeySchedule.java +++ b/src/main/java/com/swancommunity/owid/PublicKeySchedule.java @@ -37,9 +37,7 @@ * start is at or before the date asked about. Keys are generated in batches, * often many weeks ahead of the weeks the keys cover, so the moment key * material was generated says nothing about which key signed anything and is - * not held here at all. Selecting on a generation moment picks a key that has - * not started yet and reports a genuine identifier as not matching, which is - * what the .NET port did before that port was fixed.

+ * not held here at all. Selecting on a generation moment picks a key that has not started yet and reports a genuine identifier as not matching.

* *

A date the schedule does not reach, being one earlier than the first * start, has no key. That answer is reported as @@ -142,13 +140,29 @@ public DatedPublicKey keyInForce(Instant date) { *

This is not the key in force now. A creator publishes its schedule * ahead of time, so the last key by start is usually one whose period * has not begun and which has signed nothing yet. The key in force now - * is {@link #current()}. Serving the last key where the current one was - * meant is the same fault as selecting by the generation moment, being - * a key from a period that has not started, and it is the fault the - * .NET port carried in its answer to a request that named no date.

+ * is {@link #current()}. Serving the last key where the current one was meant is the same fault as selecting by the generation moment, being a key from a period that has not started.

* * @return the key with the latest start, or null when there are none */ + /** + * Returns the earliest start in the schedule after the key's own, being + * the moment the key stops being in force, or null where the key is the + * last in the schedule and is in force until further notice. + * + * @param key a key of this schedule + * @return the moment the next key starts, or null + */ + public Instant nextStartAfter(DatedPublicKey key) { + Instant next = null; + for (DatedPublicKey other : keys) { + if (other.getStartsAt().isAfter(key.getStartsAt()) + && (next == null || other.getStartsAt().isBefore(next))) { + next = other.getStartsAt(); + } + } + return next; + } + public DatedPublicKey last() { if (keys.isEmpty()) { return null; @@ -186,14 +200,12 @@ public DatedPublicKey keyFor(Owid owid) { * Asks whether the signature on the OWID is genuine, using the key that * was in force when the OWID was signed. * - * @param owid the OWID to check - * @param others the other OWIDs that were signed together with this one, - * in the same order as when signed + * @param owid the OWID to check * @return the outcome of the check, which is * {@link OwidSignatureStatus#KEY_UNAVAILABLE} where the schedule * holds no key for the date */ - public OwidVerificationResult verify(Owid owid, List others) { + public OwidVerificationResult verify(Owid owid) { if (owid == null) { return OwidVerificationResult.of( OwidSignatureStatus.KEY_UNAVAILABLE); @@ -203,6 +215,6 @@ public OwidVerificationResult verify(Owid owid, List others) { return OwidVerificationResult.of( OwidSignatureStatus.KEY_UNAVAILABLE); } - return owid.verify(key.getPublicKeyPem(), others); + return owid.verify(key.getPublicKeyPem()); } } diff --git a/src/main/java/com/swancommunity/owid/PublicKeyTransport.java b/src/main/java/com/swancommunity/owid/PublicKeyTransport.java new file mode 100644 index 0000000..60abf99 --- /dev/null +++ b/src/main/java/com/swancommunity/owid/PublicKeyTransport.java @@ -0,0 +1,66 @@ +/* **************************************************************************** + * 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 java.util.concurrent.CompletableFuture; + +/** + * Makes the request for a creator's public key and answers with a future, + * so that {@link PublicKeyFetch} never holds the thread that asked. + * + *

{@link HttpUrlConnectionTransport} is the one used where a caller names + * none. It runs the JDK's blocking connection on a background thread, which + * is the best Java 8 offers without a dependency. On Java 11 and later a + * caller can supply a transport of its own over + * {@code java.net.http.HttpClient.sendAsync}, which blocks no thread at all, + * and it must keep the two rules below.

+ * + *

The first rule is that a redirect is never followed. A creator whose + * domain answers 3xx must read as the key being unavailable, with no request + * made to wherever the redirect points, because otherwise a network attacker + * able to bend the creator's DNS, or a creator that was simply + * misconfigured, could have some other place's key trusted as the creator's + * own and forgeries would verify.

+ * + *

The second rule is that the URL is requested exactly as given. The + * query names the minute the identifier was signed as the {@code date} + * parameter, and a creator that rotates its key chooses the key by that + * parameter, so dropping or rewriting the query fetches the wrong key and + * every identifier signed under an earlier key reads as not matching.

+ */ +public interface PublicKeyTransport { + + /** + * Requests the URL and answers with the body as text. + * + *

The future completes with the body where the response code is 200, + * and fails with a {@link PublicKeyFetchException} carrying + * {@link OwidSignatureStatus#KEY_UNAVAILABLE} for anything else, which + * covers any other response code, a redirect, a connection that is + * refused, a name that does not resolve and a timeout. The method itself + * returns at once and never throws, and it never returns null.

+ * + * @param url the URL to request, exactly as given + * @param domain the creator domain the key is asked of, carried by the + * exception where the fetch fails so a caller can say whose + * key was wanted. The URL normally names the same host, + * but need not, because a test stands up an end point on + * the loopback address in place of the creator + * @return the body of the response, or the failure, through a future + */ + CompletableFuture fetch(String url, String domain); +} diff --git a/src/test/java/com/example/owidconsumer/ConstructionBoundaryTest.java b/src/test/java/com/example/owidconsumer/ConstructionBoundaryTest.java index 0a40473..fb4fd5c 100644 --- a/src/test/java/com/example/owidconsumer/ConstructionBoundaryTest.java +++ b/src/test/java/com/example/owidconsumer/ConstructionBoundaryTest.java @@ -34,7 +34,6 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.nio.charset.StandardCharsets; -import java.util.Collections; import org.junit.jupiter.api.Test; /** @@ -142,24 +141,22 @@ void writingIntoReturnedArraysDoesNotAlterTheOwid() throws OwidException { "writing into the copy should not reach the OWID"); assertArrayEquals(encoded, owid.asByteArray(), "the OWID should serialise to the same bytes"); - assertTrue(owid.verifyWithCrypto(crypto, Collections.emptyList()), + assertTrue(owid.verifyWithCrypto(crypto), "the OWID should still verify"); } /** * A library user can still do everything the old surface allowed, by the - * new route. Creating, chaining, serialising, reading back and verifying - * all work without ever naming a constructor. + * new route. Creating, serialising, reading back and verifying all work + * without ever naming a constructor. */ @Test void aLibraryUserCanStillDoEverything() throws OwidException { Crypto crypto = Crypto.generate(); Creator creator = Creator.create("example.com", crypto); - Owid root = creator.createString("root"); Owid party = creator.createBytes( - "party".getBytes(StandardCharsets.UTF_8), - Collections.singletonList(root)); + "party".getBytes(StandardCharsets.UTF_8)); OwidParseResult result = Owid.parse(party.asBase64()); assertEquals(OwidParseStatus.PARSED, result.getStatus(), @@ -167,9 +164,8 @@ void aLibraryUserCanStillDoEverything() throws OwidException { Owid copy = result.getValue(); assertEquals(party, copy, "should read back an equal OWID"); - assertTrue(copy.verifyWithPublicKey(crypto.publicKeyPem(), - Collections.singletonList(root)), - "should verify with the same others"); + assertTrue(copy.verifyWithPublicKey(crypto.publicKeyPem()), + "should verify with the creator's public key"); assertEquals("party", copy.payloadAsString(), "should carry the payload it was created with"); } diff --git a/src/test/java/com/example/owidconsumer/ReadmeExampleTest.java b/src/test/java/com/example/owidconsumer/ReadmeExampleTest.java index 3ca594c..593eebe 100644 --- a/src/test/java/com/example/owidconsumer/ReadmeExampleTest.java +++ b/src/test/java/com/example/owidconsumer/ReadmeExampleTest.java @@ -34,8 +34,8 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.Test; /** @@ -72,7 +72,7 @@ void createSerializeReadBackAndVerify() throws OwidException { Owid copy = result.getValue(); String publicPem = crypto.publicKeyPem(); boolean valid = copy.verifyWithPublicKey( - publicPem, Collections.emptyList()); + publicPem); assertTrue(valid, "the OWID read back should verify"); assertEquals("Hello World", copy.payloadAsString(), @@ -120,25 +120,6 @@ void readingOneOwidAfterAnother() throws OwidException { "the two OWIDs should account for every byte"); } - @Test - void chainingCoversTheOtherOwids() throws OwidException { - Crypto crypto = Crypto.generate(); - Creator creator = Creator.create("example.com", crypto); - - Owid root = creator.createString("root"); - Owid party = creator.createString( - "party", Collections.singletonList(root)); - - // Verifies with the root as the single other, fails without it. - 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"); - } - /** * The schedule example from the README, choosing between two weekly keys * by the date the identifier carries. @@ -158,7 +139,7 @@ void aScheduleChoosesTheKeyThatWasInForce() throws OwidException { DatedPublicKey.of( Instant.parse("2026-08-31T00:00:00Z"), thisWeekPem))); OwidVerificationResult result = schedule.verify( - owid, Collections.emptyList()); + owid); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, result.getStatus(), "should choose the key that was in force and verify"); @@ -179,8 +160,13 @@ void fetchingTheKeyFromTheCreatorDomain() throws OwidException { Owid owid = Creator.create("owid.invalid", Crypto.generate()) .createString("signed by a creator that cannot be reached"); - OwidVerificationResult result = PublicKeyFetch.verify( - owid, "https", Collections.emptyList()); + CompletableFuture pending = + PublicKeyFetch.verify( + owid, "https"); + // The call returns at once and the request runs on a background + // thread. Continue from the future, or join it where waiting is + // acceptable, as it is here. + OwidVerificationResult result = pending.join(); if (result.getStatus() == OwidSignatureStatus.KEY_UNAVAILABLE) { // The key could not be obtained, so the signature was never // examined. Only SIGNATURE_INVALID means the identifier should diff --git a/src/test/java/com/swancommunity/owid/CreatorTest.java b/src/test/java/com/swancommunity/owid/CreatorTest.java index 2662a91..862b342 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.Collections; import org.junit.jupiter.api.Test; /** Unit tests for the creator signing behaviour. */ @@ -56,7 +55,7 @@ void signSetsDomainVersionAndVerifies() throws OwidException { "should set the current version"); assertEquals(Owid.SIGNATURE_LENGTH, owid.getSignature().length, "should produce a 64 byte signature"); - assertTrue(owid.verifyWithCrypto(crypto, Collections.emptyList()), + assertTrue(owid.verifyWithCrypto(crypto), "the signed OWID should verify"); } @@ -67,8 +66,8 @@ void signAndSelfVerifyThroughPem() throws OwidException { Owid owid = creator.createString("payload"); String encoded = owid.asBase64(); Owid copy = ParseAssert.parsed(Owid.parse(encoded)); - assertTrue(copy.verifyWithPublicKey(crypto.publicKeyPem(), - Collections.emptyList()), "the decoded OWID should verify"); + assertTrue(copy.verifyWithPublicKey(crypto.publicKeyPem()), + "the decoded OWID should verify"); } @Test @@ -79,25 +78,10 @@ void tamperedSignedOwidFails() throws OwidException { byte[] bytes = owid.asByteArray(); bytes[bytes.length - 1] ^= 0x01; Owid tampered = ParseAssert.parsed(Owid.parse(bytes)); - assertFalse(tampered.verifyWithCrypto(crypto, Collections.emptyList()), + assertFalse(tampered.verifyWithCrypto(crypto), "a tampered signature should not verify"); } - @Test - void createWithOthersRoundTrips() throws OwidException { - Crypto crypto = Crypto.generate(); - Creator creator = Creator.create("example.com", crypto); - Owid root = creator.createString("root"); - Owid party = creator.createString( - "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"); - } - /** * A creator refuses a null payload rather than producing an OWID with * nothing in it. This is a caller mistake in code rather than data that @@ -130,7 +114,7 @@ void payloadHandedToCreatorIsCopied() throws OwidException { assertArrayEquals(new byte[] {1, 2, 3}, owid.getPayload(), "the OWID should keep the bytes it was signed over"); - assertTrue(owid.verifyWithCrypto(crypto, Collections.emptyList()), + assertTrue(owid.verifyWithCrypto(crypto), "the OWID should still verify"); } @@ -140,7 +124,7 @@ void fromPrivatePemCreatesWorkingCreator() throws OwidException { Creator creator = Creator.fromPrivatePem("example.com", crypto.privateKeyPem()); Owid owid = creator.createString("data"); - assertTrue(owid.verifyWithCrypto(crypto, Collections.emptyList()), + assertTrue(owid.verifyWithCrypto(crypto), "should sign with the imported key"); } } diff --git a/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java b/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java index 65dd0cc..7305ac3 100644 --- a/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java +++ b/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java @@ -17,19 +17,32 @@ package com.swancommunity.owid; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -43,11 +56,15 @@ * 51d.es schedule. The URL under test is the one the library builds, with * only the host replaced, so a fault in the path or the query is caught * here.

+ * + *

Every fetch answers with a future. The tests join the future, which is + * fine here and is not what a caller on a request thread would do.

*/ class DatedKeyFetchTest { - /** No other OWIDs were covered by the signature on the fixture. */ - private static final List ALONE = Collections.emptyList(); + /** The transport a caller gets without naming one. */ + private static final PublicKeyTransport HTTP = + new HttpUrlConnectionTransport(); /** The end points started by a test, stopped when the test ends. */ private final List started = new ArrayList(); @@ -76,6 +93,29 @@ private KeyEndPoint endPoint(KeyEndPoint.Answer answer) return endPoint; } + /** The status a fetch through the default transport ends with. */ + private static OwidSignatureStatus statusAt(Owid owid, String url) { + return PublicKeyFetch.verifyAtUrl(owid, url, HTTP).join() + .getStatus(); + } + + /** The PEM a fetch through the default transport ends with. */ + private static String pemAt(String url, String domain) { + return PublicKeyFetch.publicKeyPemAtUrl(url, domain, HTTP).join(); + } + + /** + * The exception a failed future carries. Joining wraps it in a + * completion exception, and the one inside is the one the library + * raised. + */ + private static T failureOf( + CompletableFuture future, Class type, String message) { + CompletionException wrapped = assertThrows(CompletionException.class, + future::join, message); + return assertInstanceOf(type, wrapped.getCause(), message); + } + /** * The URL names the minute the identifier was created, which is the value * the end point selects a key by, and it names the well known path from @@ -85,7 +125,7 @@ private KeyEndPoint endPoint(KeyEndPoint.Answer answer) void urlNamesTheMinuteTheIdentifierWasCreated() throws OwidException { assertEquals( "https://51d.es/owid/api/v3/public-key?date=" - + KeyFixtures.IDENTIFIER_MINUTES + "&format=pkcs", + + KeyFixtures.IDENTIFIER_MINUTES + "&format=spki", PublicKeyFetch.publicKeyUrl(KeyFixtures.identifier(), "https"), "should ask 51d.es for the key in force on 4 September 2026"); @@ -104,7 +144,7 @@ void urlUsesTheVersionTheIdentifierCarries() throws OwidException { "the crafted identifier is version 2"); assertEquals( "https://example.com/owid/api/v2/public-key?date=" - + KeyFixtures.IDENTIFIER_MINUTES + "&format=pkcs", + + KeyFixtures.IDENTIFIER_MINUTES + "&format=spki", PublicKeyFetch.publicKeyUrl(version2, "https"), "should ask the version 2 end point"); } @@ -117,7 +157,7 @@ void urlOfANewlySignedOwidNamesItsOwnMinute() throws OwidException { assertEquals( "https://example.com/owid/api/v3/public-key?date=" + Io.minutesSinceBase(owid.getDate()) - + "&format=pkcs", + + "&format=spki", PublicKeyFetch.publicKeyUrl(owid, "https"), "should name the minute the OWID was signed"); } @@ -135,8 +175,7 @@ void datedFetchVerifiesAnIdentifierFromAnEarlierKeyWeek() Owid owid = KeyFixtures.identifier(); KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, - PublicKeyFetch.verifyAtUrl(owid, endPoint.urlFor(owid), ALONE) - .getStatus(), + statusAt(owid, endPoint.urlFor(owid)), "should verify against the key in force when it was signed"); assertEquals( Collections.singletonList( @@ -150,8 +189,10 @@ void datedFetchVerifiesAnIdentifierFromAnEarlierKeyWeek() * The same identifier against the same end point without the date, which * is the request a port that forgets the date makes. The end point * answers with the key in force at the moment of the request, ten days - * after the identifier was signed, the signature does not match that - * key, and a genuine identifier reads as a forgery. + * after the identifier was signed, and states a span for it that does + * not include the identifier's date. Nothing verifies under a key the + * creator says was not in force then, so the key is reported as + * unavailable rather than a genuine identifier as a forgery. */ @Test void undatedFetchLeavesAnEarlierWeeksIdentifierUnverified() @@ -159,11 +200,12 @@ void undatedFetchLeavesAnEarlierWeeksIdentifierUnverified() Owid owid = KeyFixtures.identifier(); KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); String undated = endPoint.base() - + "/owid/api/v3/public-key?format=pkcs"; - assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, - PublicKeyFetch.verifyAtUrl(owid, undated, ALONE).getStatus(), + + "/owid/api/v3/public-key?format=spki"; + assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, + statusAt(owid, undated), "an undated request gets the key in force at the request, " - + "which did not sign it"); + + "which the creator says was not in force when the " + + "identifier was signed"); assertEquals(Collections.singletonList((String) null), endPoint.dates(), "the request carried no date"); @@ -183,9 +225,9 @@ void aKeyTheEndPointCannotServeIsKeyUnavailable() Instant before = KeyFixtures.scheduledKeys().get(0).startsAt() .minus(Duration.ofDays(14)); String url = endPoint.base() + "/owid/api/v3/public-key?date=" - + Io.minutesSinceBase(before) + "&format=pkcs"; + + Io.minutesSinceBase(before) + "&format=spki"; assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - PublicKeyFetch.verifyAtUrl(owid, url, ALONE).getStatus(), + statusAt(owid, url), "no key means the signature was never examined"); } @@ -194,11 +236,11 @@ void aKeyTheEndPointCannotServeIsKeyUnavailable() void aRefusedRequestCarriesTheStatusAndTheCode() throws IOException, OwidException { KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); - final String url = endPoint.base() + "/owid/api/v3/public-key?date=0" - + "&format=pkcs"; - PublicKeyFetchException failure = assertThrows( + String url = endPoint.base() + "/owid/api/v3/public-key?date=0" + + "&format=spki"; + PublicKeyFetchException failure = failureOf( + PublicKeyFetch.publicKeyPemAtUrl(url, "51d.es", HTTP), PublicKeyFetchException.class, - () -> PublicKeyFetch.publicKeyPemAtUrl(url, "51d.es"), "a date the schedule does not reach is refused"); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, failure.getStatus(), @@ -222,18 +264,11 @@ void anEndPointThatCannotBeReachedIsKeyUnavailable() String url = endPoint.urlFor(owid); endPoint.stop(); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - PublicKeyFetch.verifyAtUrl(owid, url, ALONE).getStatus(), + statusAt(owid, url), "a connection that is refused leaves the signature " + "unjudged"); } - /** - * Key material that arrives but cannot be read is the fault of the key - * and not of the identifier, so it is reported apart from a signature - * that does not match. This is the 30 August 2026 fault, where the key - * end points served PEM a strict parser refused and every offline check - * against them failed while the keys and the identifiers were both fine. - */ /** * A creator whose domain answers with a redirect does not get the key * at the other end trusted as its own. The other end here serves the @@ -251,8 +286,7 @@ void aRedirectIsNotFollowed() throws IOException, OwidException { KeyEndPoint.Answer.REDIRECT, elsewhere.urlFor(owid)); started.add(creator); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - PublicKeyFetch.verifyAtUrl(owid, creator.urlFor(owid), ALONE) - .getStatus(), + statusAt(owid, creator.urlFor(owid)), "a redirect is the key being unavailable, never a key from " + "wherever it points"); assertEquals(1, creator.dates().size(), @@ -262,14 +296,18 @@ void aRedirectIsNotFollowed() throws IOException, OwidException { + "never made"); } + /** + * Key material that arrives but cannot be read is the fault of the key + * and not of the identifier, so it is reported apart from a signature + * that does not match. + */ @Test void aKeyThatCannotBeReadIsInvalidKey() throws IOException, OwidException { Owid owid = KeyFixtures.identifier(); KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.BROKEN_KEY); assertEquals(OwidSignatureStatus.INVALID_KEY, - PublicKeyFetch.verifyAtUrl(owid, endPoint.urlFor(owid), ALONE) - .getStatus(), + statusAt(owid, endPoint.urlFor(owid)), "a key that cannot be read is not a signature that does not " + "match"); } @@ -286,21 +324,164 @@ void theKeyIsFetchedOnceAndHeldAfterThat() KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); String url = endPoint.urlFor(owid); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, - PublicKeyFetch.verifyAtUrl(owid, url, ALONE).getStatus(), + statusAt(owid, url), "the first check fetches the key"); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, - PublicKeyFetch.verifyAtUrl(owid, url, ALONE).getStatus(), + statusAt(owid, url), "the second check answers from the cache"); assertEquals(1, endPoint.dates().size(), "the end point was asked once"); PublicKeyFetch.clearCache(); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, - PublicKeyFetch.verifyAtUrl(owid, url, ALONE).getStatus(), + statusAt(owid, url), "the check still works once the cache is emptied"); assertEquals(2, endPoint.dates().size(), "emptying the cache means the key is fetched again"); } + /** + * Two requests for the same key made while the first is still on its + * way share one request. The transport here answers only when the test + * lets it, so both requests are in flight together for certain, and + * the count of requests the transport saw is the whole point. + */ + @Test + void twoRequestsInFlightForOneKeyMakeOneRequest() + throws IOException, OwidException { + Owid owid = KeyFixtures.identifier(); + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + String url = endPoint.urlFor(owid); + HeldTransport held = new HeldTransport(); + CompletableFuture first = PublicKeyFetch.publicKeyPemAtUrl( + url, owid.getDomain(), held); + CompletableFuture second = PublicKeyFetch.publicKeyPemAtUrl( + url, owid.getDomain(), held); + assertEquals(1, held.requests.get(), + "the second request joins the first rather than asking " + + "again"); + assertFalse(first.isDone(), "nothing has answered yet"); + // The genuine key, fetched through the transport itself rather than + // through the cache, because the cache holds the fetch still on its + // way and would hand back that same waiting future. + held.answer.complete(HTTP.fetch(url, owid.getDomain()).join()); + assertEquals(first.join(), second.join(), + "both callers get the one key that was fetched"); + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, + PublicKeyFetch.verifyAtUrl(owid, url, held).join() + .getStatus(), + "the key that arrived verifies the identifier"); + assertEquals(1, held.requests.get(), + "a key already held is not asked for again"); + } + + /** + * A fetch that fails is not held, so the next request for the same key + * asks again rather than repeating the failure for as long as the + * process runs. An outage is not a fact about the key. + */ + @Test + void aFetchThatFailsIsNotHeld() throws IOException, OwidException { + Owid owid = KeyFixtures.identifier(); + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + String url = endPoint.urlFor(owid); + HeldTransport held = new HeldTransport(); + CompletableFuture first = PublicKeyFetch.publicKeyPemAtUrl( + url, owid.getDomain(), held); + held.answer.completeExceptionally(new PublicKeyFetchException( + "the creator is away", OwidSignatureStatus.KEY_UNAVAILABLE, + owid.getDomain(), 503, null)); + PublicKeyFetchException failure = failureOf(first, + PublicKeyFetchException.class, + "the failure reaches the caller as the library raised it"); + assertEquals(503, failure.getStatusCode(), + "the failure is the one the transport gave"); + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, + statusAt(owid, url), + "the next request asks again and the key arrives"); + assertEquals(1, held.requests.get(), + "the failed transport was asked once"); + assertEquals(1, endPoint.dates().size(), + "the end point was asked once, by the request that came " + + "after the failure"); + } + + /** + * The request runs on the executor the caller gave the transport, and + * not on the thread that asked, which is what makes the fetch safe to + * call from a request thread or an event loop. + */ + @Test + void theRequestRunsOnTheExecutorGiven() + throws IOException, OwidException { + Owid owid = KeyFixtures.identifier(); + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + final AtomicReference ran = new AtomicReference(); + Executor executor = task -> { + Thread thread = new Thread(task, "the executor given"); + ran.set(thread); + thread.start(); + }; + PublicKeyTransport transport = + new HttpUrlConnectionTransport(executor); + String url = endPoint.urlFor(owid); + CompletableFuture fetch = transport.fetch(url, + owid.getDomain()); + assertNotNull(ran.get(), "the executor was given the request"); + assertNotEquals(Thread.currentThread(), ran.get(), + "the thread that asked is not the one that fetches"); + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, + owid.verify(PublicKeyResponse.parse(fetch.join()) + .getPublicKey()).getStatus(), + "the key fetched on the executor verifies the identifier"); + } + + /** + * An executor that refuses the request, because it has been shut down + * or is full, fails the future rather than throwing at the caller, so a + * caller has one place to look for every failure. + */ + @Test + void aRequestTheExecutorRefusesIsKeyUnavailable() + throws IOException, OwidException { + Owid owid = KeyFixtures.identifier(); + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + Executor refusing = task -> { + throw new RejectedExecutionException("shut down"); + }; + PublicKeyTransport transport = + new HttpUrlConnectionTransport(refusing); + PublicKeyFetchException failure = failureOf( + transport.fetch(endPoint.urlFor(owid), owid.getDomain()), + PublicKeyFetchException.class, + "the refusal arrives through the future"); + assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, + failure.getStatus(), "no request means no key"); + assertEquals(owid.getDomain(), failure.getDomain(), + "the domain asked of is carried"); + assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, + PublicKeyFetch.verifyAtUrl(owid, endPoint.urlFor(owid), transport).join().getStatus(), + "a check through the refusing executor is unjudged"); + assertTrue(endPoint.dates().isEmpty(), + "the end point was never reached"); + } + + /** A transport has to be given where the caller names one. */ + @Test + void aMissingTransportIsRefused() + throws IOException, OwidException { + Owid owid = KeyFixtures.identifier(); + failureOf(PublicKeyFetch.publicKeyPem(owid, "https", null), + OwidException.class, + "the key cannot be fetched with no transport"); + assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, + PublicKeyFetch.verify(owid, "https", null).join() + .getStatus(), + "a check with no transport is unjudged"); + assertThrows(IllegalArgumentException.class, + () -> new HttpUrlConnectionTransport(null), + "the default transport needs an executor"); + } + /** * Keys are held against the URL they came from, which names the minute, * so two identifiers from different weeks fetch two different keys, and @@ -315,14 +496,14 @@ void keysAreHeldPerRequestAndNotPerDomain() Instant.parse("2026-08-20T00:00:00Z")); Owid later = crafted(Version.VERSION3, KeyFixtures.IDENTIFIER_DOMAIN, Instant.parse("2026-09-04T00:00:00Z")); - String first = PublicKeyFetch.publicKeyPemAtUrl( - endPoint.urlFor(earlier), KeyFixtures.IDENTIFIER_DOMAIN); - String second = PublicKeyFetch.publicKeyPemAtUrl( - endPoint.urlFor(later), KeyFixtures.IDENTIFIER_DOMAIN); + String first = pemAt(endPoint.urlFor(earlier), + KeyFixtures.IDENTIFIER_DOMAIN); + String second = pemAt(endPoint.urlFor(later), + KeyFixtures.IDENTIFIER_DOMAIN); assertNotEquals(first, second, "two weeks, two keys"); assertEquals(2, endPoint.dates().size(), "one request per week"); - assertEquals(first, PublicKeyFetch.publicKeyPemAtUrl( - endPoint.urlFor(earlier), KeyFixtures.IDENTIFIER_DOMAIN), + assertEquals(first, pemAt(endPoint.urlFor(earlier), + KeyFixtures.IDENTIFIER_DOMAIN), "the held key is the one fetched for that week"); assertEquals(2, endPoint.dates().size(), "a week already held is not asked for again"); @@ -341,8 +522,12 @@ void aDomainThatIsNotADomainNameIsRefused() throws OwidException { assertThrows(OwidException.class, () -> PublicKeyFetch.publicKeyUrl(owid, "https"), "a domain carrying a path and a query is refused"); + failureOf(PublicKeyFetch.publicKeyPem(owid, "https"), + OwidException.class, + "a URL that cannot be built fails the fetch"); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - PublicKeyFetch.verify(owid, "https", ALONE).getStatus(), + PublicKeyFetch.verify(owid, "https").join() + .getStatus(), "a URL that cannot be built leaves the signature unjudged"); } @@ -361,7 +546,8 @@ void aDomainThatIsNotADomainNameIsRefused() throws OwidException { void aSchemeThatIsNotHttpIsKeyUnavailable() throws OwidException { assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, PublicKeyFetch.verify( - KeyFixtures.identifier(), "mailto", ALONE).getStatus(), + KeyFixtures.identifier(), "mailto").join() + .getStatus(), "a scheme that fetches no key leaves the signature unjudged"); } @@ -377,6 +563,25 @@ void missingValuesAreRefused() { "there is no URL without a scheme"); } + /** + * A transport that answers only when the test lets it, counting the + * requests made of it, so a test can hold two requests in flight + * together and say how many reached the wire. + */ + private static final class HeldTransport implements PublicKeyTransport { + + final AtomicInteger requests = new AtomicInteger(); + + final CompletableFuture answer = + new CompletableFuture(); + + @Override + public CompletableFuture fetch(String url, String domain) { + requests.incrementAndGet(); + return answer; + } + } + /** * Builds an OWID with the version, domain and date given and a signature * of zeroes, for the cases that are about the URL rather than about the @@ -401,4 +606,589 @@ private static Owid crafted(Version version, String domain, Instant date) "the crafted OWID carries no real signature"); return owid; } + + /** The minute count for a moment, counted the way the key URL counts it. */ + private static long minutes(String moment) { + return Io.minutesSinceBase(Instant.parse(moment)); + } + + /** A key URL on the end point for the minute given. */ + private static String urlFor(KeyEndPoint endPoint, long minute) { + return endPoint.base() + "/owid/api/v3/public-key?date=" + minute + + "&format=spki"; + } + + /** The PEM the published schedule says was in force at the minute. */ + private static String inForce(long minute) throws OwidException { + return KeyFixtures.schedule() + .keyInForce(Io.baseDate().plus(Duration.ofMinutes(minute))) + .getPublicKeyPem(); + } + + /** + * A key the creator has confirmed for two minutes is served for every + * minute between them without a request, because a key is in force from + * the start of its period until the next key starts. A minute outside + * every confirmed span is asked about. + */ + @Test + void aMinuteBetweenTwoConfirmedMinutesIsServedFromTheCache() + throws IOException, OwidException { + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SPANLESS); + // The week of 31 August 2026, which the fixture identifier was + // signed in, and which is wholly in the past so the cache reads + // each minute as itself rather than as now. + long first = minutes("2026-08-31T00:01:00Z"); + long last = minutes("2026-09-06T23:00:00Z"); + String pem = pemAt(urlFor(endPoint, first), + KeyFixtures.IDENTIFIER_DOMAIN); + assertEquals(pem, pemAt(urlFor(endPoint, last), + KeyFixtures.IDENTIFIER_DOMAIN), "one key covers the week"); + assertEquals(2, endPoint.dates().size(), + "the two ends of the span were asked about"); + for (long between : new long[] { + first + 1, first + 3 * 24 * 60, last - 1 }) { + assertEquals(pem, pemAt(urlFor(endPoint, between), + KeyFixtures.IDENTIFIER_DOMAIN), + "the key served for minute " + between); + } + assertEquals(2, endPoint.dates().size(), + "a minute between two confirmed minutes is not asked about"); + assertEquals(1, PublicKeyFetch.cachedKeyCount(), + "one key is held however many minutes it covers"); + assertNotEquals(pem, pemAt(urlFor(endPoint, first - 2), + KeyFixtures.IDENTIFIER_DOMAIN), + "a minute in the week before is the earlier week's key"); + assertEquals(3, endPoint.dates().size(), + "a minute before the span is asked about"); + assertEquals(2, PublicKeyFetch.cachedKeyCount(), + "the earlier week's key is held as a second key"); + } + + /** + * The case that made the cache almost useless when it was keyed by the + * whole URL. A hundred identifiers with a hundred different minutes + * inside one key's period cost a hundred requests then. With the ends + * of the period confirmed they cost none. + */ + @Test + void aHundredIdentifiersInOneConfirmedPeriodMakeNoRequest() + throws IOException, OwidException { + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SPANLESS); + long start = minutes("2026-09-01T00:00:00Z"); + pemAt(urlFor(endPoint, start), KeyFixtures.IDENTIFIER_DOMAIN); + pemAt(urlFor(endPoint, start + 100), KeyFixtures.IDENTIFIER_DOMAIN); + for (int i = 1; i <= 100; i++) { + pemAt(urlFor(endPoint, start + i), KeyFixtures.IDENTIFIER_DOMAIN); + } + assertEquals(2, endPoint.dates().size(), + "a hundred identifiers over a hundred minutes made no " + + "request once both ends of the span were known"); + } + + /** + * A key is only ever served for a minute inside the span the creator + * has confirmed it for. Where the creator rotated between two confirmed + * minutes, the minutes between them belong to neither key until the + * creator is asked, and every answer agrees with the published + * schedule. + */ + @Test + void aKeyIsNeverServedForAMinuteOutsideItsConfirmedSpan() + throws IOException, OwidException { + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SPANLESS); + long rotation = minutes("2026-08-31T00:00:00Z"); + long week = 7 * 24 * 60; + // The start of the week before the rotation and the end of the week + // after it, so the two keys are held with the rotation between. + pemAt(urlFor(endPoint, rotation - week), KeyFixtures.IDENTIFIER_DOMAIN); + pemAt(urlFor(endPoint, rotation + week - 1), + KeyFixtures.IDENTIFIER_DOMAIN); + assertEquals(2, endPoint.dates().size()); + assertEquals(2, PublicKeyFetch.cachedKeyCount()); + + // Every minute across the rotation, in an order that walks in from + // both sides, is answered with the key the schedule gives, whether + // from the cache or by asking. + long[] minutes = { + rotation - 1, rotation, rotation - 2, rotation + 1, + rotation - week / 2, rotation + week / 2, + rotation - 3, rotation + 2, rotation - 1, rotation }; + for (long minute : minutes) { + assertEquals(inForce(minute), pemAt(urlFor(endPoint, minute), + KeyFixtures.IDENTIFIER_DOMAIN), + "the key served for minute " + minute); + } + assertEquals(2, PublicKeyFetch.cachedKeyCount(), + "two keys are held, each with its own span"); + int asked = endPoint.dates().size(); + assertTrue(asked > 2 && asked < 2 + minutes.length, + "some minutes were asked about and some were served: " + + asked); + + // The minute either side of the rotation is now confirmed, so + // nothing across the whole fortnight needs asking. + for (long minute = rotation - week; minute < rotation + week; + minute += 60) { + assertEquals(inForce(minute), pemAt(urlFor(endPoint, minute), + KeyFixtures.IDENTIFIER_DOMAIN), + "the key served for minute " + minute); + } + assertEquals(asked, endPoint.dates().size(), + "both spans are fully confirmed, so nothing was asked"); + } + + /** + * A minute within the clock drift allowance of now, or later, is asked + * about every time and never held, because a creator whose clock differs + * from this one's may have read it as its present rather than as the + * minute named. A minute beyond the allowance is held as usual. Live + * identifiers from a creator that states no span therefore cost one + * request per minute and older ones cost none. + */ + @Test + void aMinuteWithinTheDriftAllowanceIsNotHeld() throws Exception { + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SPANLESS); + Field field = PublicKeyFetch.class.getDeclaredField( + "CLOCK_DRIFT_ALLOWANCE_MINUTES"); + field.setAccessible(true); + long allowance = field.getLong(null); + long started = Io.minutesSinceBase(Instant.now()); + long recent = started - 1; + pemAt(urlFor(endPoint, recent), KeyFixtures.IDENTIFIER_DOMAIN); + pemAt(urlFor(endPoint, recent), KeyFixtures.IDENTIFIER_DOMAIN); + pemAt(urlFor(endPoint, started + 7 * 24 * 60), + KeyFixtures.IDENTIFIER_DOMAIN); + pemAt(endPoint.base() + "/owid/api/v3/public-key?format=spki", + KeyFixtures.IDENTIFIER_DOMAIN); + long old = started - allowance - 1; + pemAt(urlFor(endPoint, old), KeyFixtures.IDENTIFIER_DOMAIN); + pemAt(urlFor(endPoint, old), KeyFixtures.IDENTIFIER_DOMAIN); + assumeTrue(Io.minutesSinceBase(Instant.now()) == started, + "the minute changed during the test, so the calls were not " + + "all about the same now"); + assertEquals(5, endPoint.dates().size(), + "the recent minute was asked about twice, the future minute " + + "and the request with no date once each, and the " + + "old minute once with the second call held"); + assertEquals(1, PublicKeyFetch.cachedKeyCount(), + "only the old minute's key is held"); + } + + /** + * The cache does not grow without limit. The number of distinct keys a + * verifier is shown is chosen by whoever presents the identifiers rather + * than by this process, so the stand in creator here answers every + * minute with a different key, which is the worst a creator can do to + * the cache. The bound is read from the library so the test cannot + * drift from it. + */ + @Test + void theCacheIsBounded() throws Exception { + Field bound = PublicKeyFetch.class.getDeclaredField( + "MAXIMUM_CACHED_KEYS"); + bound.setAccessible(true); + int maximum = bound.getInt(null); + final AtomicInteger requests = new AtomicInteger(); + PublicKeyTransport distinct = (url, domain) -> { + requests.incrementAndGet(); + try { + return CompletableFuture.completedFuture( + Endpoints.publicKeyAnswer( + Crypto.generate().subjectPublicKeyInfo(), + null, null, null)); + } catch (OwidException e) { + throw new IllegalStateException(e); + } + }; + for (int i = 0; i <= maximum; i++) { + PublicKeyFetch.publicKeyPemAtUrl( + "https://example.invalid/owid/api/v3/public-key?date=" + i + + "&format=spki", + "example.invalid", distinct).join(); + } + assertEquals(maximum + 1, requests.get(), + "every minute was a different key, so every one was asked"); + assertTrue(PublicKeyFetch.cachedKeyCount() <= maximum, + "held " + PublicKeyFetch.cachedKeyCount() + " of at most " + + maximum); + } + + /** A JSON answer for the key alone, as a creator with no schedule sends. */ + private static String spanless(String pem) throws OwidException { + return Endpoints.publicKeyAnswer(pem, null, null, null); + } + + /** + * An identifier for the domain dated at the moment and signed with the + * crypto given, standing for one whose signing machine's clock did not + * agree with the creator's schedule to the minute. + */ + private static Owid signedAt(String domain, Instant moment, Crypto crypto) + throws OwidException { + byte[] payload = "payload".getBytes(StandardCharsets.UTF_8); + byte[] data = Owid.dataForCrypto(Version.VERSION3, domain, moment, + payload); + return new Owid(Version.VERSION3, domain, moment, payload, + crypto.signByteArray(data)); + } + + /** + * A creator that states the moments the key is valid from and to, which + * is what the library's own server side helper answers, has the whole + * span held from that one answer, so every other minute of the span is + * served without a request. + */ + @Test + void aKeyAnsweredWithItsSpanIsHeldForTheWholeSpan() + throws IOException, OwidException { + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + String pem = pemAt(urlFor(endPoint, minutes("2026-08-31T00:01:00Z")), + KeyFixtures.IDENTIFIER_DOMAIN); + for (String moment : new String[] {"2026-09-06T23:59:00Z", + "2026-09-03T12:00:00Z", "2026-08-31T00:00:00Z"}) { + assertEquals(pem, pemAt(urlFor(endPoint, minutes(moment)), + KeyFixtures.IDENTIFIER_DOMAIN), moment); + } + assertEquals(1, endPoint.dates().size(), + "the whole week was held from one answer"); + assertEquals(1, PublicKeyFetch.cachedKeyCount()); + assertNotEquals(pem, pemAt(urlFor(endPoint, + minutes("2026-08-30T23:59:00Z")), KeyFixtures.IDENTIFIER_DOMAIN), + "the minute before the week is the earlier week's key"); + pemAt(urlFor(endPoint, minutes("2026-08-24T00:00:00Z")), + KeyFixtures.IDENTIFIER_DOMAIN); + assertEquals(2, endPoint.dates().size(), + "the earlier week was held from its one answer"); + } + + /** + * The drift allowance, which keeps minutes near now out of a cache built + * from confirmed minutes, does not apply to a span the creator stated + * itself, so live identifiers cost one request per key rather than one + * per minute. + */ + @Test + void aRecentMinuteIsServedWhereTheCreatorStatedTheSpan() + throws IOException, OwidException { + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + Instant now = Instant.now(); + DatedPublicKey current = KeyFixtures.schedule().keyInForce(now); + assumeTrue(current != null + && KeyFixtures.schedule().nextStartAfter(current) != null, + "the fixture schedule has no key after the one in force now"); + long started = Io.minutesSinceBase(now); + pemAt(urlFor(endPoint, started - 1), KeyFixtures.IDENTIFIER_DOMAIN); + pemAt(urlFor(endPoint, started), KeyFixtures.IDENTIFIER_DOMAIN); + pemAt(urlFor(endPoint, started - 10), KeyFixtures.IDENTIFIER_DOMAIN); + assertEquals(1, endPoint.dates().size(), + "the current key was served for every recent minute from one " + + "answer"); + } + + /** + * An identifier dated just after a key started, but signed with the key + * before it, verifies, and one dated just before a key started but + * signed with it verifies too, because the neighbouring key is tried + * when the selected key fails within the drift allowance of the span's + * edge. Further from the edge the failure stands. The stand in creator + * answers with the library's own server side helper, so the loop between + * the two halves of the library is closed. + */ + @Test + void aSignatureFailingNearTheEdgeOfASpanIsCheckedAgainstTheNeighbour() + throws OwidException { + Crypto first = Crypto.generate(); + Crypto second = Crypto.generate(); + Crypto third = Crypto.generate(); + Instant rotation = Instant.parse("2026-08-31T00:00:00Z"); + Duration week = Duration.ofDays(7); + final PublicKeySchedule schedule = PublicKeySchedule.of(Arrays.asList( + DatedPublicKey.of(rotation.minus(week), + first.subjectPublicKeyInfo()), + DatedPublicKey.of(rotation, second.subjectPublicKeyInfo()), + DatedPublicKey.of(rotation.plus(week), + third.subjectPublicKeyInfo()))); + final List requests = new ArrayList(); + PublicKeyTransport creator = (url, domain) -> { + requests.add(url); + String date = null; + int at = url.indexOf("date="); + if (at >= 0) { + date = url.substring(at + 5, url.indexOf('&', at)); + } + try { + Endpoints.Response response = Endpoints.publicKeyResponseAt( + schedule, "spki", date, Instant.now()); + return CompletableFuture.completedFuture(response.getBody()); + } catch (OwidException e) { + throw new IllegalStateException(e); + } + }; + Owid late = signedAt("creator.test", rotation.plus(Duration.ofMinutes(5)), + first); + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, + statusOf(late, creator), + "signed with the earlier key just after the rotation"); + assertEquals(2, requests.size(), + "the selected key and then the earlier key were asked for"); + Owid early = signedAt("creator.test", + rotation.minus(Duration.ofMinutes(5)), second); + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, + statusOf(early, creator), + "signed with the later key just before the rotation"); + assertEquals(2, requests.size(), "both keys are held with their spans"); + Owid far = signedAt("creator.test", rotation.plus(Duration.ofMinutes(20)), + first); + assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, + statusOf(far, creator), "well inside the later key's span"); + assertEquals(2, requests.size(), + "the identifier is further from every edge than clocks may " + + "differ"); + Owid genuine = signedAt("creator.test", rotation.plus(Duration.ofDays(3)), + second); + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, + statusOf(genuine, creator)); + Owid forged = signedAt("creator.test", rotation.plus(Duration.ofDays(3)), + third); + assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, + statusOf(forged, creator), + "signed with a key not in force at its date"); + } + + /** The date parameter of the key URL, or null where it names none. */ + private static String dateOf(String url) { + int at = url.indexOf("date="); + if (at < 0) { + return null; + } + int end = url.indexOf('&', at); + return url.substring(at + 5, end < 0 ? url.length() : end); + } + + /** + * A stand in creator answering from the schedule through the library's + * own server side helper, recording the date each request asked for. + */ + private static PublicKeyTransport creatorServing( + final PublicKeySchedule schedule, final List asked) { + return (url, domain) -> { + String date = dateOf(url); + asked.add(date); + try { + Endpoints.Response response = Endpoints.publicKeyResponseAt( + schedule, "spki", date, Instant.now()); + if (response.getStatus() != 200) { + CompletableFuture refused = + new CompletableFuture(); + refused.completeExceptionally(new PublicKeyFetchException( + "no key for the date asked about", + OwidSignatureStatus.KEY_UNAVAILABLE, domain, + response.getStatus(), null)); + return refused; + } + return CompletableFuture.completedFuture(response.getBody()); + } catch (OwidException e) { + throw new IllegalStateException(e); + } + }; + } + + /** + * The neighbouring key is asked for by the minute just beyond the edge of + * the span the creator stated, not by a minute a fixed distance from the + * identifier, so a key in force for less than the drift allowance is + * still the one tried. + */ + @Test + void theNeighbourIsAskedForByTheMinuteJustBeyondTheEdge() + throws OwidException { + Crypto first = Crypto.generate(); + Crypto second = Crypto.generate(); + Instant rotation = Instant.parse("2026-08-31T00:00:00Z"); + Duration week = Duration.ofDays(7); + PublicKeySchedule schedule = PublicKeySchedule.of(Arrays.asList( + DatedPublicKey.of(rotation.minus(week), + first.subjectPublicKeyInfo()), + DatedPublicKey.of(rotation, second.subjectPublicKeyInfo()), + DatedPublicKey.of(rotation.plus(week), + Crypto.generate().subjectPublicKeyInfo()))); + List asked = new ArrayList(); + Owid late = signedAt("creator.test", + rotation.plus(Duration.ofMinutes(5)), first); + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, + statusOf(late, creatorServing(schedule, asked)), + "signed with the earlier key just after the rotation"); + long minute = Io.minutesSinceBase(rotation); + assertEquals(Arrays.asList(Long.toString(minute + 5), + Long.toString(minute - 1)), asked, + "the identifier's own minute and then the minute just before " + + "the span started"); + } + + /** + * A key the creator states a start for and no end is in force until + * further notice as far as the creator has said, so a live identifier + * dated just after that start which does not verify under it is checked + * against the key before it, even though the cache holds the key only up + * to the drift allowance behind now. + */ + @Test + void aKeyStatedWithoutAnEndHasNoLaterEdge() throws OwidException { + Crypto first = Crypto.generate(); + Crypto second = Crypto.generate(); + Instant rotation = Io.baseDate().plus(Duration.ofMinutes( + Io.minutesSinceBase(Instant.now()) - 5)); + PublicKeySchedule schedule = PublicKeySchedule.of(Arrays.asList( + DatedPublicKey.of(rotation.minus(Duration.ofDays(7)), + first.subjectPublicKeyInfo()), + DatedPublicKey.of(rotation, second.subjectPublicKeyInfo()))); + List asked = new ArrayList(); + Owid live = signedAt("creator.test", + rotation.plus(Duration.ofMinutes(2)), first); + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, + statusOf(live, creatorServing(schedule, asked)), + "a live identifier signed with the key before the current " + + "one verifies"); + assertEquals(2, asked.size(), + "the current key and then the key before it were asked for"); + } + + /** + * A creator whose own statement puts the identifier's date outside the + * span of the key it answered with has said that key did not sign at + * that date, so nothing verifying under it leaves the key unavailable + * rather than the signature not matching. A forgery dated inside the + * span is still reported as not matching. + */ + @Test + void aKeyTheCreatorSaysWasNotInForceLeavesTheSignatureUnjudged() + throws OwidException { + Crypto first = Crypto.generate(); + Crypto second = Crypto.generate(); + Crypto stranger = Crypto.generate(); + Instant rotation = Instant.parse("2026-08-31T00:00:00Z"); + Instant end = rotation.plus(Duration.ofDays(7)); + final String answer = Endpoints.publicKeyAnswer( + second.subjectPublicKeyInfo(), rotation, end, null); + // A creator that ignores the date asked about and answers with the + // current key and its span whatever the request. + PublicKeyTransport current = (url, domain) -> + CompletableFuture.completedFuture(answer); + Owid earlier = signedAt("creator.test", + rotation.minus(Duration.ofDays(3)), first); + assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, + statusOf(earlier, current), + "the key answered with was not in force at the identifier's " + + "date"); + Owid forged = signedAt("creator.test", + rotation.plus(Duration.ofDays(3)), stranger); + assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, + statusOf(forged, current), + "a signature failing under the key in force at its date does " + + "not match"); + } + + /** The status of an OWID checked through the transport given. */ + private static OwidSignatureStatus statusOf(Owid owid, + PublicKeyTransport transport) throws OwidException { + return PublicKeyFetch.verifyAtUrl(owid, + PublicKeyFetch.publicKeyUrl(owid, "https"), transport) + .join().getStatus(); + } + + /** + * The PEM alone as text is reported as a key this library cannot read + * rather than used, and so is a span that ends before it starts. + */ + @Test + void anAnswerThatIsNotTheJsonFormIsAKeyThatCannotBeRead() + throws IOException, OwidException { + Owid owid = KeyFixtures.identifier(); + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.PEM_ONLY); + assertEquals(OwidSignatureStatus.INVALID_KEY, + statusAt(owid, endPoint.urlFor(owid))); + final String pem = KeyFixtures.schedule().getKeys().get(0).getPublicKeyPem(); + PublicKeyTransport contradictory = (url, domain) -> + CompletableFuture.completedFuture(PublicKeyResponse.of(pem, + Instant.parse("2026-08-31T00:00:00Z"), + Instant.parse("2026-08-24T00:00:00Z")).toJson()); + assertEquals(OwidSignatureStatus.INVALID_KEY, + statusOf(owid, contradictory)); + } + + /** + * An answer that states a format other than the one this library reads + * is a key that cannot be read, whatever the key field holds, because + * the key is not in the encoding the request asked for. + */ + @Test + void anAnswerInAnotherFormatIsAKeyThatCannotBeRead() + throws OwidException { + Owid owid = KeyFixtures.identifier(); + final String pem = KeyFixtures.schedule().keyFor(owid) + .getPublicKeyPem(); + final String other = PublicKeyResponse.of(pem, null, null).toJson() + .replace("\"spki\"", "\"pkcs\""); + assertEquals(OwidSignatureStatus.INVALID_KEY, statusOf(owid, + (url, domain) -> CompletableFuture.completedFuture(other)), + "a format this library does not read leaves the signature " + + "unjudged"); + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, statusOf(owid, + (url, domain) -> CompletableFuture.completedFuture( + PublicKeyResponse.of(pem, null, null).toJson())), + "the same key in the format asked for verifies"); + } + + /** + * Threads verifying the same OWID at the same moment make one request for + * its key between them, and every one of them gets the answer. The stand + * in transport holds its answer until every thread has asked, so all of + * them are in flight together against one request. + */ + @Test + void manyThreadsVerifyingOneOwidTogetherMakeOneRequest() + throws Exception { + final int callers = 32; + final Owid owid = KeyFixtures.identifier(); + final String answer = spanless( + KeyFixtures.schedule().keyFor(owid).getPublicKeyPem()); + final AtomicInteger requests = new AtomicInteger(); + final CompletableFuture held = new CompletableFuture(); + PublicKeyTransport transport = (url, domain) -> { + requests.incrementAndGet(); + return held; + }; + final String url = PublicKeyFetch.publicKeyUrl(owid, "https"); + final CyclicBarrier start = new CyclicBarrier(callers + 1); + final List statuses = Collections.synchronizedList( + new ArrayList()); + List threads = new ArrayList(); + for (int i = 0; i < callers; i++) { + Thread thread = new Thread(() -> { + try { + start.await(); + statuses.add(PublicKeyFetch.verifyAtUrl(owid, url, transport).join().getStatus()); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + thread.start(); + threads.add(thread); + } + // Every thread goes at the same moment, and the transport only + // answers once they are all waiting on it. + start.await(); + Thread.sleep(200); + held.complete(answer); + for (Thread thread : threads) { + thread.join(30_000); + } + assertEquals(callers, statuses.size(), "every thread finished"); + for (OwidSignatureStatus status : statuses) { + assertEquals(OwidSignatureStatus.SIGNATURE_VALID, status, + "every thread verified the OWID"); + } + assertEquals(1, requests.get(), "one request for " + callers + " threads"); + } } diff --git a/src/test/java/com/swancommunity/owid/DomainLengthTest.java b/src/test/java/com/swancommunity/owid/DomainLengthTest.java index f25ab6e..9f45c40 100644 --- a/src/test/java/com/swancommunity/owid/DomainLengthTest.java +++ b/src/test/java/com/swancommunity/owid/DomainLengthTest.java @@ -27,7 +27,6 @@ import java.lang.management.ThreadMXBean; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.Collections; import org.junit.jupiter.api.Test; /** @@ -221,7 +220,7 @@ void maximumLengthDomainWritten() throws OwidException { 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()), + assertTrue(parsed.verifyWithCrypto(crypto), "the parsed OWID should still verify"); } @@ -272,8 +271,7 @@ void overMaximumLengthDomainRefusedWhenAssemblingDataToSign() { OwidException thrown = assertThrows(OwidException.class, () -> Owid.dataForCrypto(Version.current(), domain, - Io.baseDate(), PAYLOAD, - Collections.emptyList()), + Io.baseDate(), PAYLOAD), "should refuse to assemble the bytes that would be signed"); assertNamesMaximum(thrown); @@ -320,7 +318,7 @@ void libraryOutputParses() throws OwidException { 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()), + assertTrue(parsed.verifyWithCrypto(crypto), "the parsed OWID should still verify"); } } diff --git a/src/test/java/com/swancommunity/owid/EndpointsTest.java b/src/test/java/com/swancommunity/owid/EndpointsTest.java index 1ec9106..b6133b2 100644 --- a/src/test/java/com/swancommunity/owid/EndpointsTest.java +++ b/src/test/java/com/swancommunity/owid/EndpointsTest.java @@ -18,8 +18,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.time.Instant; +import java.util.Collections; import org.junit.jupiter.api.Test; /** Unit tests for the well known end point helpers. */ @@ -31,41 +34,61 @@ private static Creator newCreator() throws OwidException { @Test void paths() { - assertEquals("/owid/api/v3/creator", - Endpoints.creatorPath(Version.VERSION3), - "should match the creator path"); assertEquals("/owid/api/v3/public-key", Endpoints.publicKeyPath(Version.VERSION3), "should match the public key path"); } - @Test - void creatorResponseFields() throws OwidException { - Creator creator = newCreator(); - String body = Endpoints.creatorResponse(creator, "Example Org", - "https://example.com/terms"); - assertTrue(body.contains("\"domain\":\"example.com\""), - "should contain the domain"); - assertTrue(body.contains("\"name\":\"Example Org\""), - "should contain the name"); - assertTrue(body.contains("publicKeySPKI"), - "should use the specification field names"); - assertTrue(body.contains("BEGIN PUBLIC KEY"), - "should embed the public key PEM"); - assertTrue(body.contains("\"contractURL\":\"https://example.com/terms\""), - "should contain the contract URL"); - } - + /** + * The format parameter names the encoding of the key in the answer. The + * one encoding defined is answered whether or not it is asked for by + * name, the answer echoes it, and any other value is refused rather than + * answered in an encoding the caller did not ask for. + */ @Test void publicKeyResponseFormats() throws OwidException { Creator creator = newCreator(); - for (String format : new String[] {"spki", "pkcs"}) { + for (String format : new String[] {"spki", null, ""}) { String body = Endpoints.publicKeyResponse(creator, format); - assertTrue(body.contains("BEGIN PUBLIC KEY"), + PublicKeyResponse answer = PublicKeyResponse.parse(body); + assertEquals("spki", answer.getFormat(), + "the answer names the encoding of the key"); + assertTrue(answer.getPublicKey().contains("BEGIN PUBLIC KEY"), "should return the PEM for format " + format); + assertNull(answer.getValidFrom(), "a single key has no schedule"); + assertNull(answer.getValidTo()); + } + for (String format : new String[] {"pkcs", "other"}) { + assertThrows(OwidException.class, + () -> Endpoints.publicKeyResponse(creator, format), + "should refuse format " + format); + } + } + + /** + * The scheduled form answers 400 to a format it does not serve, the way + * the specification requires of a creator, and answers the one format + * defined whether or not the request names it. + */ + @Test + void publicKeyResponseAtRefusesAnotherFormat() throws OwidException { + Instant now = Instant.parse("2026-09-07T12:00:00Z"); + PublicKeySchedule schedule = PublicKeySchedule.of( + Collections.singletonList(DatedPublicKey.of( + Instant.parse("2026-08-31T00:00:00Z"), + Crypto.generate().subjectPublicKeyInfo()))); + Endpoints.Response refused = Endpoints.publicKeyResponseAt(schedule, + "pkcs", null, now); + assertEquals(400, refused.getStatus(), + "a format this creator does not serve is a bad request"); + assertEquals("", refused.getBody()); + for (String format : new String[] {"spki", null}) { + Endpoints.Response served = Endpoints.publicKeyResponseAt( + schedule, format, null, now); + assertEquals(200, served.getStatus()); + assertEquals("spki", + PublicKeyResponse.parse(served.getBody()).getFormat(), + "the answer echoes the one format defined"); } - assertThrows(OwidException.class, - () -> Endpoints.publicKeyResponse(creator, "other"), - "should reject an unknown format"); } } diff --git a/src/test/java/com/swancommunity/owid/FixturesTest.java b/src/test/java/com/swancommunity/owid/FixturesTest.java index 9b94cb5..2ac1133 100644 --- a/src/test/java/com/swancommunity/owid/FixturesTest.java +++ b/src/test/java/com/swancommunity/owid/FixturesTest.java @@ -20,15 +20,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Base64; -import java.util.Collections; -import java.util.List; import org.junit.jupiter.api.Test; /** * Cross language signed fixtures. Each set of OWIDs was produced by a separate * implementation and signed with the matching public key. The test verifies - * the real signatures, the chain relationship, and that flipping the last - * signature byte breaks verification. + * the real signatures and that flipping the last signature byte breaks + * verification. */ class FixturesTest { @@ -38,16 +36,11 @@ 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) { + Fixtures(String spki, String simple, String utf8) { this.spki = spki; this.simple = simple; this.utf8 = utf8; - this.chainParty = chainParty; - this.chainRoot = chainRoot; } String spki() { @@ -61,14 +54,6 @@ String simple() { String utf8() { return utf8; } - - String chainParty() { - return chainParty; - } - - String chainRoot() { - return chainRoot; - } } private static final String UTF8_TEXT = "Zürich ❤ OWID £€"; @@ -82,11 +67,7 @@ String chainRoot() { + "Rk8U4fYacm0Ck4aOxoRDJPK/QrKavqZqCf7cCKbNuJ0aA7GhVeuy4ojeSzNX56Qn", "A2dvLnN3YW4tZGVtby51awA/vTMAFgAAAFrDvHJpY2gg4p2kIE9XSUQgwqPigqzx" + "Y+4QgUGt84xC9HxHmHXDt+wcB0Y9a6E+Txm2F147Qacbp0CtrF8x7QCWZfkcKCKN" - + "GSM8hYZEfYjJtViG+tA+", - "A2dvLnN3YW4tZGVtby51awA/vTMABQAAAHBhcnR5l7NyNmFw2lxqc4DKJWoq0UVd" - + "5ujGV/+fvVxqYTRlwCFxaSuwvnhLQQHjX5spxWb4O08IeuiuGCat1WFB/Wqlyw==", - "A2dvLnN3YW4tZGVtby51awA/vTMABAAAAHJvb3R/bEqzG8gAy9yTF1UMEtOlYXBB" - + "mn3a20jxXq5NmxIC8iuZvduOXKMf+K8VoAapkWwfpoDKQHS09IhljasZqC0k"); + + "GSM8hYZEfYjJtViG+tA+"); private static final Fixtures DOTNET = new Fixtures( "-----BEGIN PUBLIC KEY-----\n" @@ -98,13 +79,7 @@ String chainRoot() { + "Wzyh0w==", "A2RvdG5ldC5zd2FuLWRlbW8udWsAPb0zABYAAABaw7xyaWNoIOKdpCBPV0lEIMKj" + "4oKsVuaeaDUej0sF+cHfYj/icDBmlBLOviC6ZE28am8EtY+IGuesFcg2rKMybcsA" - + "xMmnrDtF2xsk1cJvHgoIYpSJJQ==", - "A2RvdG5ldC5zd2FuLWRlbW8udWsAPb0zAAUAAABwYXJ0eXtD6H4R7GbvRyFU+bCK" - + "gjMAZFFm8KHln80XPwQOBb/Ub9EZfE4Ml3ueRkKX51+MD98RFgTSmjbqrAnzFkLl" - + "ilA=", - "A2RvdG5ldC5zd2FuLWRlbW8udWsAPb0zAAQAAAByb290fErj2LccPYCduWUW8vY2" - + "aBjrecDfnTpVpv3+SESJMFW5pcuPKEQik2rC0fWEoB5Vr6e0k5inrhUGiF2c2Y2Y" - + "Dw=="); + + "xMmnrDtF2xsk1cJvHgoIYpSJJQ=="); private static final Fixtures RUST = new Fixtures( "-----BEGIN PUBLIC KEY-----\n" @@ -116,11 +91,7 @@ String chainRoot() { + "5CI=", "A3J1c3Quc3dhbi1kZW1vLnVrAD69MwAWAAAAWsO8cmljaCDinaQgT1dJRCDCo+KC" + "rDHenDds+W587AzXpBb94gmLOloeBJTlHnjCkez4Dz2yAPtjcoQ6M/ZUWDIobtJH" - + "E5n9a81pTsn/Kvi74Azzx4s=", - "A3J1c3Quc3dhbi1kZW1vLnVrAD69MwAFAAAAcGFydHmJ7qaxWgIZUHmGOQb2xC+R" - + "uZNwrkMmo1SA9/MfI4SoEpRYdnteXAKUQXxTOK3lmQ3Qz3UwBB6gBb3Q8hi1Wx0R", - "A3J1c3Quc3dhbi1kZW1vLnVrAD69MwAEAAAAcm9vdFd0+QLaBLGPyBrQO+VNunBI" - + "QZzw8/lhEiDOKTx36Dc93A0n0fzPDMt/C+BdWMqhnL4nVvyurb3IHR7DUAmgmO0="); + + "E5n9a81pTsn/Kvi74Azzx4s="); /** Returns a copy of the bytes with the final byte flipped. */ private static byte[] flipLastByte(byte[] bytes) { @@ -131,42 +102,26 @@ private static byte[] flipLastByte(byte[] bytes) { private void runFixtures(Fixtures fixtures) throws OwidException { Crypto crypto = Crypto.newVerifyOnly(fixtures.spki()); - List none = Collections.emptyList(); Owid simple = ParseAssert.parsed(Owid.parse(fixtures.simple())); - assertTrue(simple.verifyWithCrypto(crypto, none), + assertTrue(simple.verifyWithCrypto(crypto), "simple should verify"); - assertTrue(simple.verifyWithPublicKey(fixtures.spki(), none), + assertTrue(simple.verifyWithPublicKey(fixtures.spki()), "simple should verify by public key PEM"); Owid utf8 = ParseAssert.parsed(Owid.parse(fixtures.utf8())); - assertTrue(utf8.verifyWithCrypto(crypto, none), "utf8 should verify"); + assertTrue(utf8.verifyWithCrypto(crypto), "utf8 should verify"); org.junit.jupiter.api.Assertions.assertEquals(UTF8_TEXT, utf8.payloadAsString(), "utf8 payload text should match"); - Owid root = ParseAssert.parsed(Owid.parse(fixtures.chainRoot())); - assertTrue(root.verifyWithCrypto(crypto, none), - "chain root should verify alone"); - - Owid party = ParseAssert.parsed(Owid.parse(fixtures.chainParty())); - 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"); - // Each fixture with its last signature byte flipped must fail. - for (String encoded : new String[] {fixtures.simple(), fixtures.utf8(), - fixtures.chainRoot()}) { + for (String encoded : new String[] {fixtures.simple(), + fixtures.utf8()}) { byte[] tampered = flipLastByte(Base64.getMimeDecoder().decode(encoded)); Owid owid = ParseAssert.parsed(Owid.parse(tampered)); - assertFalse(owid.verifyWithCrypto(crypto, none), + assertFalse(owid.verifyWithCrypto(crypto), "a flipped signature byte should break verification"); } - byte[] tamperedParty = - flipLastByte(Base64.getMimeDecoder().decode(fixtures.chainParty())); - Owid party2 = ParseAssert.parsed(Owid.parse(tamperedParty)); - assertFalse(party2.verifyWithCrypto(crypto, Collections.singletonList(root)), - "a flipped party signature byte should break verification"); } @Test diff --git a/src/test/java/com/swancommunity/owid/KeyEndPoint.java b/src/test/java/com/swancommunity/owid/KeyEndPoint.java index bab46e5..7722d64 100644 --- a/src/test/java/com/swancommunity/owid/KeyEndPoint.java +++ b/src/test/java/com/swancommunity/owid/KeyEndPoint.java @@ -65,6 +65,15 @@ enum Answer { /** The published schedule, chosen by the date requested. */ SCHEDULE, + /** + * The key alone as JSON with no moments, as a creator with one key + * and no schedule answers. + */ + SPANLESS, + + /** The key alone as text, which the specification does not allow. */ + PEM_ONLY, + /** Text shaped like a PEM that no key can be read out of. */ BROKEN_KEY, @@ -110,8 +119,9 @@ static KeyEndPoint start(final Answer answer, final String redirectTo) server.createContext("/", new HttpHandler() { @Override public void handle(HttpExchange exchange) throws IOException { - String date = parameter( - exchange.getRequestURI().getRawQuery(), "date"); + String query = exchange.getRequestURI().getRawQuery(); + String date = parameter(query, "date"); + String format = parameter(query, "format"); endPoint.dates.add(date); if (answer == Answer.REDIRECT) { exchange.getResponseHeaders().set("Location", redirectTo); @@ -119,24 +129,24 @@ public void handle(HttpExchange exchange) throws IOException { exchange.close(); return; } - String body; + Endpoints.Response response; try { - body = body(schedule, answer, date); - } catch (NumberFormatException malformed) { - // A date that is not a number is refused, as the cloud - // refuses it, rather than failing inside the handler. - exchange.sendResponseHeaders(400, -1); + response = body(schedule, answer, date, format); + } catch (OwidException fault) { + exchange.sendResponseHeaders(500, -1); exchange.close(); return; } - if (body == null) { - exchange.sendResponseHeaders(404, -1); + if (response.getStatus() != 200) { + exchange.sendResponseHeaders(response.getStatus(), -1); exchange.close(); return; } - byte[] bytes = body.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders() - .set("Content-Type", "text/plain"); + byte[] bytes = response.getBody().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", + answer == Answer.PEM_ONLY + ? "text/plain" + : "application/json"); exchange.sendResponseHeaders(200, bytes.length); OutputStream stream = exchange.getResponseBody(); try { @@ -178,30 +188,51 @@ List dates() { } } - /** The body to serve, or null where the end point has no key. */ - private static String body(PublicKeySchedule schedule, Answer answer, - String date) { + /** + * The answer for the request, built by the library's own server side + * helper so the client is tested against what a creator built on it + * sends, honouring the format the request asks for the way the cloud + * does. A creator stating no moments, and the key alone as text, are + * built here for the tests that need them. + */ + private static Endpoints.Response body(PublicKeySchedule schedule, + Answer answer, String date, String format) throws OwidException { if (answer == Answer.BROKEN_KEY) { - // Shaped like a PEM, with a body no key can be read out of. This - // is the 30 August 2026 fault, where the end points served PEM a - // strict parser refused and good identifiers went unverified. - return "-----BEGIN PUBLIC KEY-----\n" - + "bm90IGEga2V5\n" - + "-----END PUBLIC KEY-----\n"; + // Shaped like a PEM, with a body no key can be read out of, sent + // as the JSON form without the check a creator applies, because + // that check is what catches it. + return new Endpoints.Response(200, + PublicKeyResponse.of("-----BEGIN PUBLIC KEY-----\n" + + "bm90IGEga2V5\n" + + "-----END PUBLIC KEY-----\n", null, null) + .toJson()); + } + if (answer == Answer.SCHEDULE) { + return Endpoints.publicKeyResponseAt(schedule, format, date, + REQUEST_MOMENT); } Instant asked = REQUEST_MOMENT; if (date != null) { - asked = Io.baseDate() - .plus(Duration.ofMinutes(Long.parseLong(date))); + try { + asked = Io.baseDate() + .plus(Duration.ofMinutes(Long.parseLong(date))); + } catch (NumberFormatException malformed) { + return new Endpoints.Response(400, ""); + } if (asked.isAfter(REQUEST_MOMENT)) { asked = REQUEST_MOMENT; } } DatedPublicKey key = schedule.keyInForce(asked); if (key == null) { - return null; + return new Endpoints.Response(404, ""); + } + if (answer == Answer.PEM_ONLY) { + return new Endpoints.Response(200, key.getPublicKeyPem()); } - return key.getPublicKeyPem(); + return new Endpoints.Response(200, + Endpoints.publicKeyAnswer(key.getPublicKeyPem(), null, null, + null)); } /** The value of a parameter in a query, or null where there is none. */ diff --git a/src/test/java/com/swancommunity/owid/ParseContractTest.java b/src/test/java/com/swancommunity/owid/ParseContractTest.java index 930cec3..b0fcc88 100644 --- a/src/test/java/com/swancommunity/owid/ParseContractTest.java +++ b/src/test/java/com/swancommunity/owid/ParseContractTest.java @@ -27,7 +27,6 @@ import java.nio.ByteBuffer; import java.util.Arrays; import java.util.Base64; -import java.util.Collections; import java.util.Random; import org.junit.jupiter.api.Test; @@ -273,7 +272,7 @@ void structurallyValidWithWrongSignatureParsesThenFailsVerification() Owid owid = ParseAssert.parsed(Owid.parse(bytes)); OwidVerificationResult verification = - owid.verify(crypto, Collections.emptyList()); + owid.verify(crypto); assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, verification.getStatus(), "the signature should be reported as not matching"); diff --git a/src/test/java/com/swancommunity/owid/PayloadLengthTest.java b/src/test/java/com/swancommunity/owid/PayloadLengthTest.java index e6f9b70..54306ba 100644 --- a/src/test/java/com/swancommunity/owid/PayloadLengthTest.java +++ b/src/test/java/com/swancommunity/owid/PayloadLengthTest.java @@ -26,7 +26,6 @@ import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.Collections; import org.junit.jupiter.api.Test; /** @@ -151,7 +150,7 @@ void libraryOutputParses() throws OwidException { 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()), + assertTrue(parsed.verifyWithCrypto(crypto), "the parsed OWID should still verify"); } diff --git a/src/test/java/com/swancommunity/owid/PublicKeyScheduleTest.java b/src/test/java/com/swancommunity/owid/PublicKeyScheduleTest.java index b9be284..6d8705b 100644 --- a/src/test/java/com/swancommunity/owid/PublicKeyScheduleTest.java +++ b/src/test/java/com/swancommunity/owid/PublicKeyScheduleTest.java @@ -45,9 +45,6 @@ class PublicKeyScheduleTest { private static final Instant WEEK_OF_THE_IDENTIFIER = Instant.parse("2026-08-31T00:00:00Z"); - /** No other OWIDs were covered by the signature on the fixture. */ - private static final List ALONE = Collections.emptyList(); - /** * The genuine identifier verifies against the key the published schedule * says was in force on the day the identifier was signed. This is the @@ -67,7 +64,7 @@ void genuineIdentifierVerifiesAgainstTheKeyInForceOnItsDate() assertEquals(WEEK_OF_THE_IDENTIFIER, key.getStartsAt(), "the week beginning 31 August covers 4 September"); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, - owid.verify(key.getPublicKeyPem(), ALONE).getStatus(), + owid.verify(key.getPublicKeyPem()).getStatus(), "should verify against the key that signed it"); } @@ -79,7 +76,7 @@ void genuineIdentifierVerifiesAgainstTheKeyInForceOnItsDate() void scheduleVerifiesTheGenuineIdentifier() throws OwidException { assertEquals(OwidSignatureStatus.SIGNATURE_VALID, KeyFixtures.schedule() - .verify(KeyFixtures.identifier(), ALONE).getStatus(), + .verify(KeyFixtures.identifier()).getStatus(), "should pick the signing key and verify in one call"); } @@ -100,7 +97,7 @@ void aLaterWeeksKeyDoesNotVerifyAnEarlierWeeksIdentifier() "the key in force in the following week starts after the identifier " + "was signed"); assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, - owid.verify(later.getPublicKeyPem(), ALONE).getStatus(), + owid.verify(later.getPublicKeyPem()).getStatus(), "a later week's key should not verify an earlier week's " + "identifier"); } @@ -170,7 +167,7 @@ void selectionIgnoresTheMomentTheKeysWereGenerated() throws OwidException { "the newest generated key had not started when the " + "identifier was signed"); assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, - owid.verify(newestGenerated.pem(), ALONE).getStatus(), + owid.verify(newestGenerated.pem()).getStatus(), "selecting on the generation moment reports a genuine " + "identifier as not matching"); @@ -179,7 +176,7 @@ void selectionIgnoresTheMomentTheKeysWereGenerated() throws OwidException { assertEquals(WEEK_OF_THE_IDENTIFIER, chosen.getStartsAt(), "selecting on the start picks the week that was running"); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, - owid.verify(chosen.getPublicKeyPem(), ALONE).getStatus(), + owid.verify(chosen.getPublicKeyPem()).getStatus(), "selecting on the start verifies the genuine identifier"); } @@ -225,7 +222,7 @@ void keysMayArriveInAnyOrder() throws OwidException { "the keys are held oldest start first"); } assertEquals(OwidSignatureStatus.SIGNATURE_VALID, - reversed.verify(KeyFixtures.identifier(), ALONE).getStatus(), + reversed.verify(KeyFixtures.identifier()).getStatus(), "the order the keys arrived in changes nothing"); } @@ -255,7 +252,7 @@ void anEmptyScheduleHasNoKey() throws OwidException { assertNull(schedule.keyInForce(Instant.now()), "no key was in force"); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - schedule.verify(KeyFixtures.identifier(), ALONE).getStatus(), + schedule.verify(KeyFixtures.identifier()).getStatus(), "no key means the signature was never examined"); } @@ -263,7 +260,7 @@ void anEmptyScheduleHasNoKey() throws OwidException { @Test void aMissingOwidIsKeyUnavailable() throws OwidException { assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - KeyFixtures.schedule().verify(null, ALONE).getStatus(), + KeyFixtures.schedule().verify(null).getStatus(), "there is nothing to find a key for"); } diff --git a/src/test/java/com/swancommunity/owid/SignatureStatusTest.java b/src/test/java/com/swancommunity/owid/SignatureStatusTest.java index 581442b..e09cef2 100644 --- a/src/test/java/com/swancommunity/owid/SignatureStatusTest.java +++ b/src/test/java/com/swancommunity/owid/SignatureStatusTest.java @@ -20,8 +20,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.Collections; -import java.util.List; import org.junit.jupiter.api.Test; /** @@ -35,14 +33,12 @@ * *

Every member of {@link OwidSignatureStatus} is exercised here except * {@link OwidSignatureStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}, which needs - * an OWID and its chain to approach the two gigabyte limit of a Java array + * an OWID whose payload approaches the two gigabyte limit of a Java array * and so cannot be built in a suite that has to run on an ordinary machine. * The reason is recorded on the member itself as well.

*/ class SignatureStatusTest { - private static final List NONE = Collections.emptyList(); - private static Crypto crypto() throws OwidException { return Crypto.generate(); } @@ -54,7 +50,7 @@ void genuineSignatureIsValid() throws OwidException { Owid owid = Creator.create("example.com", crypto) .createString("payload"); - OwidVerificationResult result = owid.verify(crypto, NONE); + OwidVerificationResult result = owid.verify(crypto); assertTrue(result.isValid(), "a genuine signature should be valid"); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, result.getStatus(), @@ -69,7 +65,7 @@ void genuineSignatureIsValidThroughPem() throws OwidException { .createString("payload"); OwidVerificationResult result = owid.verify( - crypto.publicKeyPem(), NONE); + crypto.publicKeyPem()); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, result.getStatus(), "should report the signature as valid"); @@ -85,7 +81,7 @@ void wrongKeyIsSignatureInvalid() throws OwidException { .createString("payload"); OwidVerificationResult result = - owid.verify(crypto(), NONE); + owid.verify(crypto()); assertFalse(result.isValid(), "the signature should not be valid"); assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, result.getStatus(), @@ -102,13 +98,13 @@ void noKeyIsKeyUnavailable() throws OwidException { .createString("payload"); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - owid.verify((Crypto) null, NONE).getStatus(), + owid.verify((Crypto) null).getStatus(), "a missing crypto instance should not judge the signature"); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - owid.verify((String) null, NONE).getStatus(), + owid.verify((String) null).getStatus(), "a missing PEM should not judge the signature"); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - owid.verify(" ", NONE).getStatus(), + owid.verify(" ").getStatus(), "an empty PEM should not judge the signature"); } @@ -125,13 +121,13 @@ void undecodableKeyIsInvalidKey() throws OwidException { .createString("payload"); assertEquals(OwidSignatureStatus.INVALID_KEY, - owid.verify("not a PEM", NONE) + owid.verify("not a PEM") .getStatus(), "material that is not a key should be reported as the key"); assertEquals(OwidSignatureStatus.INVALID_KEY, owid.verify( "-----BEGIN PUBLIC KEY-----\nAAAA\n" - + "-----END PUBLIC KEY-----\n", NONE) + + "-----END PUBLIC KEY-----\n") .getStatus(), "a PEM whose body is not a key should be reported as the key"); } @@ -157,10 +153,10 @@ void wrongLengthSignatureIsInvalidSignatureLength() throws OwidException { Envelope.filled(Owid.SIGNATURE_LENGTH - 1, (byte) 1)); assertEquals(OwidSignatureStatus.INVALID_SIGNATURE_LENGTH, - noSignature.verify(crypto(), NONE).getStatus(), + noSignature.verify(crypto()).getStatus(), "no signature is not the same as a signature that is wrong"); assertEquals(OwidSignatureStatus.INVALID_SIGNATURE_LENGTH, - shortSignature.verify(crypto(), NONE).getStatus(), + shortSignature.verify(crypto()).getStatus(), "a 63 byte signature is not a signature that is wrong"); } @@ -181,7 +177,7 @@ void unencodableFieldIsVerificationError() throws OwidException { Envelope.filled(Owid.SIGNATURE_LENGTH, (byte) 1)); assertEquals(OwidSignatureStatus.VERIFICATION_ERROR, - owid.verify(crypto(), NONE).getStatus(), + owid.verify(crypto()).getStatus(), "a field that cannot be encoded is not an invalid signature"); } @@ -196,11 +192,11 @@ void booleanSurfacesKeepTheirBehaviour() throws OwidException { Owid owid = Creator.create("example.com", crypto) .createString("payload"); - assertTrue(owid.verifyWithCrypto(crypto, NONE), + assertTrue(owid.verifyWithCrypto(crypto), "a genuine signature should verify"); - assertTrue(owid.verifyWithPublicKey(crypto.publicKeyPem(), NONE), + assertTrue(owid.verifyWithPublicKey(crypto.publicKeyPem()), "a genuine signature should verify through the PEM"); - assertFalse(owid.verifyWithCrypto(crypto(), NONE), + assertFalse(owid.verifyWithCrypto(crypto()), "a signature checked against another key should not verify"); } }