From 1cebabb8b1be404da69ce52e373001c7903842d5 Mon Sep 17 00:00:00 2001
From: James Rosewell 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. 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.
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,12 +59,6 @@ */ 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 @@ -67,16 +68,27 @@ public final class PublicKeyFetch { private static final int MAXIMUM_CACHED_KEYS = 1024; /** - * Keys already fetched, held against the URL the keys were fetched from. + * Keys fetched, or on their way, held against the URL they were asked + * 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.
+ * + *The value is the future of the fetch rather than the key itself, so + * a second request for a key that is still on its way joins the request + * already made instead of making another. A fetch that fails is removed + * the moment it fails, so an outage is never remembered and the next + * request tries again.
*/ - private static final MapReturns 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 CompletableFutureReturns 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 CompletableFutureA 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 CompletableFutureReturns 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 others the other OWIDs that were signed together with this + * one, in the same order as when signed + * @param transport the transport to make the request with + * @return the outcome of the check, through a future */ - public static OwidVerificationResult verify(Owid owid, String scheme, - ListThe future held in the cache is this class's own rather than the + * transport's, so that the transport's completion can be watched and a + * failure dropped from the cache without touching the map from inside + * one of its own operations, which a concurrent map does not allow.
*/ - static String publicKeyPemAtUrl(String url, String domain) - throws OwidException { - String cached = CACHE.get(url); - if (cached != null) { - return cached; + static CompletableFutureThe 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/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 + */ + CompletableFutureEvery 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 ListA 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. The span grows as the creator + * confirms the same key for more minutes, and an identifier dated inside + * it is verified without a request.
+ */ + private static final class HeldKey { + /** The key in PEM form, as the creator served it. */ + final String pem; + /** The earliest minute the creator has confirmed the key for. */ + long first; + /** The latest minute the creator has confirmed the key for. */ + long last; + + HeldKey(String pem, long minute) { + this.pem = pem; + this.first = minute; + this.last = minute; + } + + /** Whether the minute lies within the confirmed span. */ + boolean covers(long minute) { + return 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.
- * - *The value is the future of the fetch rather than the key itself, so - * a second request for a key that is still on its way joins the request - * already made instead of making another. A fetch that fails is removed - * the moment it fails, so an outage is never remembered and the next - * request tries again.
+ * 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 the whole URL, as this cache once was, two identifiers + * signed a minute apart never shared an entry, so a hundred identifiers + * over a hundred minutes made a hundred requests for one key. Keyed by + * end point and span, an identifier dated between two minutes the + * creator has already answered for is verified without a request. */ - private static final MapThe future held in the cache is this class's own rather than the - * transport's, so that the transport's completion can be watched and a - * failure dropped from the cache without touching the map from inside - * one of its own operations, which a concurrent map does not allow.
+ *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 key held against the minute it was asked for, and a + * failure forgotten, all before the callers waiting are answered.
*/ static CompletableFutureThe date parameter where the URL carries one, and otherwise now, + * because a creator answers a request without a date with the key in + * force now. A date later than now is read as now as well, because that + * is how a creator reads it. A schedule is published ahead of time and a + * key that has not started has signed nothing, so the creator answers a + * future date with the key in force now, and that answer must be held + * against now rather than against a minute the creator has not spoken + * for. Held against the future minute, the key would still be served + * for that minute after the creator had rotated, and a genuine + * identifier signed then would read as not matching.
+ */ + private static long minuteOf(String url) { + long now = Io.minutesSinceBase(Instant.now()); + int query = url.indexOf('?'); + if (query < 0) { + return now; + } + for (String pair : url.substring(query + 1).split("&")) { + if (pair.startsWith("date=")) { + try { + return Math.min(Long.parseLong(pair.substring(5)), now); + } catch (NumberFormatException notANumber) { + return now; + } + } + } + return now; + } + + /** + * The key held for the end point whose confirmed span covers the minute, + * or null where no held key does. Called under the lock. + */ + private static String heldPem(String endPoint, long minute) { + ListA key already held for the end point has its span widened to take + * in the minute. A key not held before is added, emptying the cache + * first when it is full, because the domains and dates asked about come + * from the identifiers presented to this process and the cache must not + * grow on their input.
+ */ + private static void hold(String endPoint, long minute, String pem) { + ListThe 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. The key is held again as a separate span + * instead.
+ */ + private static boolean widen(ListA creator reads a date later than its own now as now, and answers + * with the key in force now. Within this window this process cannot tell + * whether the creator read the minute as its past or as its present, so + * the answer says nothing certain about the minute. An identifier signed + * just after a rotation by a creator whose clock runs ahead would + * otherwise be served the old key from a span confirmed up to now, and + * would read as not matching until this clock caught up. Identifiers + * dated within the window are asked about once per minute per creator, + * as they always were, and every older identifier is served from the + * spans.
+ */ + private static final long CLOCK_DRIFT_ALLOWANCE_MINUTES = 15; + + /** The minute {@link #minuteOf} answers where the cache must not be used. */ + private static final long NOT_HELD = -1; + /** * One key a creator has answered with, and the span of minutes the * creator has confirmed it was in force for. @@ -358,7 +379,7 @@ static CompletableFutureThe date parameter where the URL carries one, and otherwise now, - * because a creator answers a request without a date with the key in - * force now. A date later than now is read as now as well, because that - * is how a creator reads it. A schedule is published ahead of time and a - * key that has not started has signed nothing, so the creator answers a - * future date with the key in force now, and that answer must be held - * against now rather than against a minute the creator has not spoken - * for. Held against the future minute, the key would still be served - * for that minute after the creator had rotated, and a genuine - * identifier signed then would read as not matching.
+ *The date parameter where the URL carries one and it is at least + * {@link #CLOCK_DRIFT_ALLOWANCE_MINUTES} behind now. A request without a + * date asks for the key in force now, and one dated within the + * allowance, or later, may be read by the creator as its present rather + * than as the minute named, so neither is served from the cache nor held + * in it.
*/ private static long minuteOf(String url) { long now = Io.minutesSinceBase(Instant.now()); int query = url.indexOf('?'); if (query < 0) { - return now; + return NOT_HELD; } for (String pair : url.substring(query + 1).split("&")) { if (pair.startsWith("date=")) { try { - return Math.min(Long.parseLong(pair.substring(5)), now); + long minute = Long.parseLong(pair.substring(5)); + if (minute >= 0 + && minute <= now - CLOCK_DRIFT_ALLOWANCE_MINUTES) { + return minute; + } } catch (NumberFormatException notANumber) { - return now; + // Not a count of minutes, so nothing to hold against. } + return NOT_HELD; } } - return now; + return NOT_HELD; } /** diff --git a/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java b/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java index 4831e8c..12edcc0 100644 --- a/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java +++ b/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java @@ -740,28 +740,40 @@ void aKeyIsNeverServedForAMinuteOutsideItsConfirmedSpan() } /** - * A date later than now is held against now, because a creator answers - * a future date with the key in force now and a key held against a - * minute the creator has not spoken for would be served for that minute - * after the creator had rotated. Two future dates therefore share one - * request, and so does a request with no date. + * 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 therefore cost one request per minute per creator, as they + * always did, and older ones cost none. */ @Test - void aFutureDateIsHeldAgainstNow() throws IOException, OwidException { + void aMinuteWithinTheDriftAllowanceIsNotHeld() throws Exception { KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + Field field = PublicKeyFetch.class.getDeclaredField( + "CLOCK_DRIFT_ALLOWANCE_MINUTES"); + field.setAccessible(true); + long allowance = field.getLong(null); long started = Io.minutesSinceBase(Instant.now()); - long week = 7 * 24 * 60; - pemAt(urlFor(endPoint, started + week), KeyFixtures.IDENTIFIER_DOMAIN); - pemAt(urlFor(endPoint, started + 2 * week), + 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=pkcs", 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(1, endPoint.dates().size(), - "two future dates and no date are all now, and now was " - + "asked about once"); + 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"); } /** From 8d25a3aea489fef49b1d4cf839da8a75b1de95f7 Mon Sep 17 00:00:00 2001 From: James RosewellThe 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.
* * @param creator the creator * @param format the format parameter, {@code spki} or {@code pkcs} - * @return the public key PEM + * @return the JSON body * @throws OwidException if the format is not valid, or the public key - * cannot be exported + * cannot be exported or read back */ public static String publicKeyResponse(Creator creator, String format) throws OwidException { - if ("spki".equals(format) || "pkcs".equals(format)) { - return creator.crypto().subjectPublicKeyInfo(); + if ("spki".equals(format) == false && "pkcs".equals(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( + "format parameter 'spki' or 'pkcs' must be provided"); + } + return publicKeyAnswer(creator.crypto().subjectPublicKeyInfo(), null, + null, null); + } + + /** + * 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 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 publicKeyAnswer(String publicKeyPem, Instant validFrom, + Instant validTo, Instant asked) throws OwidException { + PublicKeyResponse answer = PublicKeyResponse.of(publicKeyPem, validFrom, + validTo); + answer.validate(asked); + return answer.toJson(); + } + + /** + * 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; + } + } + + /** + * 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.
+ * + * @param schedule the published schedule + * @param format the format parameter, {@code spki} or {@code pkcs} + * @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 format is not valid, or 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 ("spki".equals(format) == false && "pkcs".equals(format) == false) { + throw new OwidException( + "format parameter 'spki' or 'pkcs' must be provided"); + } + 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, ""); } - // 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"); + return new Response(200, publicKeyAnswer(key.getPublicKeyPem(), + key.getStartsAt(), schedule.nextStartAfter(key), asked)); } private static void appendField(StringBuilder json, String name, diff --git a/src/main/java/com/swancommunity/owid/PublicKeyFetch.java b/src/main/java/com/swancommunity/owid/PublicKeyFetch.java index 914cb99..eab5405 100644 --- a/src/main/java/com/swancommunity/owid/PublicKeyFetch.java +++ b/src/main/java/com/swancommunity/owid/PublicKeyFetch.java @@ -71,55 +71,84 @@ public final class PublicKeyFetch { /** * How far a creator's clock may run ahead of or behind this one's, in - * minutes. A minute closer to now than this, or later, is asked about - * rather than served from the cache, and is not held. + * minutes. * - *A creator reads a date later than its own now as now, and answers - * with the key in force now. Within this window this process cannot tell - * whether the creator read the minute as its past or as its present, so - * the answer says nothing certain about the minute. An identifier signed - * just after a rotation by a creator whose clock runs ahead would - * otherwise be served the old key from a span confirmed up to now, and - * would read as not matching until this clock caught up. Identifiers - * dated within the window are asked about once per minute per creator, - * as they always were, and every older identifier is served from the - * spans.
+ *It is used in two places. A creator that does not state 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 a key's edge that does not + * verify under that key is checked against the neighbouring key before + * it is reported as not matching.
*/ private static final long CLOCK_DRIFT_ALLOWANCE_MINUTES = 15; - /** The minute {@link #minuteOf} answers where the cache must not be used. */ - private static final long NOT_HELD = -1; + /** The minute {@link #minuteOf} answers where the URL names none. */ + private static final long NO_MINUTE = -1; /** - * One key a creator has answered with, and the span of minutes the - * creator has confirmed it was in force for. + * 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. The span grows as the creator - * confirms the same key for more minutes, and an identifier dated inside - * it is verified without a request.
+ * 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 creator has confirmed the key for. */ + /** The earliest minute the key is known to cover. */ long first; - /** The latest minute the creator has confirmed the key for. */ + /** The latest minute the key is known to cover. */ long last; + /** Whether the creator stated the whole span itself. */ + boolean explicit; - HeldKey(String pem, long minute) { + HeldKey(String pem, long first, long last, boolean explicit) { this.pem = pem; - this.first = minute; - this.last = minute; + this.first = first; + this.last = last; + this.explicit = explicit; } - /** Whether the minute lies within the confirmed span. */ + /** 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 it is known, + * the span of minutes the key covers, so that a caller can tell whether + * the identifier it is checking sits near the edge of the span. + */ + private static final class KeyAnswer { + final String pem; + final long first; + final long last; + 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); + } + + 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. @@ -136,10 +165,7 @@ boolean covers(long minute) { * many identifiers does not mean repeating requests to another * 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 the whole URL, as this cache once was, two identifiers - * signed a minute apart never shared an entry, so a hundred identifiers - * over a hundred minutes made a hundred requests for one key. Keyed by - * end point and span, an identifier dated between two minutes the + * 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 MapA 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 the edge of the span that key is known to cover, the key + * for the minute just beyond that edge is asked for and tried. A key + * already known to cover the neighbouring minute is not asked for again, + * and a neighbour that turns out to be the same key is not tried again. + * This costs at most two more requests, and only for a signature that + * has already failed.
+ */ + private static CompletableFutureThe 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 key held against the minute it was asked for, and a - * failure forgotten, all before the callers waiting are answered.
+ * watched, the answer read and held, and a failure forgotten, all before + * the callers waiting are answered. */ - static CompletableFutureThe date parameter where the URL carries one and it is at least - * {@link #CLOCK_DRIFT_ALLOWANCE_MINUTES} behind now. A request without a - * date asks for the key in force now, and one dated within the - * allowance, or later, may be read by the creator as its present rather - * than as the minute named, so neither is served from the cache nor held - * in it.
+ * The minute the URL asks about, or {@link #NO_MINUTE} where it names + * none. */ private static long minuteOf(String url) { - long now = Io.minutesSinceBase(Instant.now()); int query = url.indexOf('?'); if (query < 0) { - return NOT_HELD; + return NO_MINUTE; } for (String pair : url.substring(query + 1).split("&")) { if (pair.startsWith("date=")) { try { long minute = Long.parseLong(pair.substring(5)); - if (minute >= 0 - && minute <= now - CLOCK_DRIFT_ALLOWANCE_MINUTES) { - return minute; - } + return minute < 0 ? NO_MINUTE : minute; } catch (NumberFormatException notANumber) { - // Not a count of minutes, so nothing to hold against. + return NO_MINUTE; } - return NOT_HELD; } } - return NOT_HELD; + return NO_MINUTE; } /** - * The key held for the end point whose confirmed span covers the minute, - * or null where no held key does. Called under the lock. + * 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 String heldPem(String endPoint, long minute) { + 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; + } ListA key already held for the end point has its span widened to take - * in the minute. A key not held before is added, emptying the cache - * first when it is full, because the domains and dates asked about come - * from the identifiers presented to this process and the cache must not - * grow on their input.
+ *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 void hold(String endPoint, long minute, String pem) { + private static KeyAnswer hold(String endPoint, String url, String pem, + Long start, Long end) { + long minute = minuteOf(url); + long first; + long last; + boolean explicit = 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); + } else if (minute != NO_MINUTE && recent(minute) == false) { + first = minute; + last = minute; + } else { + return KeyAnswer.unknown(pem); + } ListThe 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. The key is held again as a separate span - * instead.
+ * then not this key's to claim. */ - private static boolean widen(List{@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 three fields, each a string or + * null, which is all the reading and writing here supports.
+ */ +public final class PublicKeyResponse { + + private final String publicKeySpki; + private final Instant validFrom; + private final Instant validTo; + + private PublicKeyResponse(String publicKeySpki, Instant validFrom, + Instant validTo) { + this.publicKeySpki = publicKeySpki; + this.validFrom = validFrom; + this.validTo = validTo; + } + + /** + * An answer for the key and the moments it is valid from and to, either + * of which may be null. + * + * @param publicKeySpki 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 + * @return the answer, not yet checked + */ + public static PublicKeyResponse of(String publicKeySpki, Instant validFrom, + Instant validTo) { + return new PublicKeyResponse(publicKeySpki, validFrom, validTo); + } + + /** The key in PEM form. */ + public String getPublicKeySpki() { + return publicKeySpki; + } + + /** 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 key must be a public key this library can read, + * 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 (publicKeySpki == null || publicKeySpki.trim().isEmpty()) { + throw new OwidException("the public key answer holds no key"); + } + try { + Crypto.newVerifyOnly(publicKeySpki); + } 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("{\"publicKeySPKI\":"); + appendString(json, publicKeySpki); + json.append(",\"validFrom\":"); + appendMoment(json, validFrom); + json.append(",\"validTo\":"); + appendMoment(json, validTo); + return json.append('}').toString(); + } + + /** + * Reads an answer from its JSON body. + * + * @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 three + * fields, each a string or null + */ + public static PublicKeyResponse parse(String json) throws OwidException { + MapA 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; @@ -189,6 +203,7 @@ public DatedPublicKey keyFor(Owid owid) { * @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 + * * @return the outcome of the check, which is * {@link OwidSignatureStatus#KEY_UNAVAILABLE} where the schedule * holds no key for the date diff --git a/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java b/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java index 12edcc0..1087e5b 100644 --- a/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java +++ b/src/test/java/com/swancommunity/owid/DatedKeyFetchTest.java @@ -29,6 +29,7 @@ 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; @@ -36,6 +37,7 @@ 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; @@ -297,9 +299,7 @@ void aRedirectIsNotFollowed() throws IOException, OwidException { /** * 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. + * that does not match. */ @Test void aKeyThatCannotBeReadIsInvalidKey() @@ -359,7 +359,6 @@ void twoRequestsInFlightForOneKeyMakeOneRequest() assertEquals(1, held.requests.get(), "the second request joins the first rather than asking " + "again"); - assertSame(first, second, "both callers hold the same fetch"); 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 @@ -431,7 +430,8 @@ void theRequestRunsOnTheExecutorGiven() assertNotEquals(Thread.currentThread(), ran.get(), "the thread that asked is not the one that fetches"); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, - owid.verify(fetch.join(), ALONE).getStatus(), + owid.verify(PublicKeyResponse.parse(fetch.join()) + .getPublicKeySpki(), ALONE).getStatus(), "the key fetched on the executor verifies the identifier"); } @@ -635,7 +635,7 @@ private static String inForce(long minute) throws OwidException { @Test void aMinuteBetweenTwoConfirmedMinutesIsServedFromTheCache() throws IOException, OwidException { - KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + 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. @@ -675,7 +675,7 @@ void aMinuteBetweenTwoConfirmedMinutesIsServedFromTheCache() @Test void aHundredIdentifiersInOneConfirmedPeriodMakeNoRequest() throws IOException, OwidException { - KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + 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); @@ -697,7 +697,7 @@ void aHundredIdentifiersInOneConfirmedPeriodMakeNoRequest() @Test void aKeyIsNeverServedForAMinuteOutsideItsConfirmedSpan() throws IOException, OwidException { - KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + 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 @@ -743,13 +743,11 @@ void aKeyIsNeverServedForAMinuteOutsideItsConfirmedSpan() * 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 therefore cost one request per minute per creator, as they - * always did, and older ones cost none. + * minute named. A minute beyond the allowance is held as usual. Live identifiers therefore cost one request per minute per creator and older ones cost none. */ @Test void aMinuteWithinTheDriftAllowanceIsNotHeld() throws Exception { - KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SCHEDULE); + KeyEndPoint endPoint = endPoint(KeyEndPoint.Answer.SPANLESS); Field field = PublicKeyFetch.class.getDeclaredField( "CLOCK_DRIFT_ALLOWANCE_MINUTES"); field.setAccessible(true); @@ -793,11 +791,14 @@ void theCacheIsBounded() throws Exception { final AtomicInteger requests = new AtomicInteger(); PublicKeyTransport distinct = (url, domain) -> { requests.incrementAndGet(); - String minute = url.substring(url.indexOf("date=") + 5, - url.indexOf("&format")); - return CompletableFuture.completedFuture( - "-----BEGIN PUBLIC KEY-----\n" + minute - + "\n-----END PUBLIC KEY-----\n"); + 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( @@ -811,4 +812,226 @@ void theCacheIsBounded() throws Exception { "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, ALONE); + 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 ListIt is used in two places. A creator that does not state 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 a key's edge that does not - * verify under that key is checked against the neighbouring key before + *
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; @@ -88,6 +89,9 @@ public final class PublicKeyFetch { /** 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. @@ -108,12 +112,20 @@ private static final class HeldKey { long last; /** Whether the creator stated the whole span itself. */ boolean explicit; - - HeldKey(String pem, long first, long last, 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. */ @@ -123,14 +135,20 @@ boolean covers(long minute) { } /** - * What the cache or a fetch answers with. The key, and where it is known, - * the span of minutes the key covers, so that a caller can tell whether - * the identifier it is checking sits near the edge of the span. + * 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) { @@ -144,6 +162,7 @@ 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; } @@ -374,10 +393,19 @@ static int cachedKeyCount() { * 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 CompletableFutureWith 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
@@ -674,10 +749,12 @@ private static KeyAnswer heldFor(String endPoint, String url) {
*/
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;
@@ -686,11 +763,12 @@ private static KeyAnswer hold(String endPoint, String url, String pem,
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 KeyAnswer.unknown(pem);
+ return stated;
}
List The mandatory end points are: The mandatory end point is {@code /owid/api/v{version}/public-key},
+ * returning a JSON object carrying the public key as {@code publicKeySPKI}
+ * together with the moments it is valid from and to. 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 publicKeySPKI}
- * together with the moments it is valid from and to. The {@code format}
- * query parameter must be {@code spki} or {@code pkcs}.
- *
+ *
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.
+ * 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 format the format parameter, {@code spki} or {@code pkcs} + * @param format the format parameter, {@code spki} or null where the + * request has none * @return the JSON body - * @throws OwidException if the format is not valid, or the public key - * cannot be exported or read back + * @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 ("spki".equals(format) == false && "pkcs".equals(format) == false) { + 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( - "format parameter 'spki' or 'pkcs' must be provided"); + 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. + */ + private static boolean served(String format) { + return format == null || format.isEmpty() + || PublicKeyResponse.SPKI_FORMAT.equals(format); + } + /** * Returns the JSON body of the public key end point for the key and the * span it covers, checked with {@link PublicKeyResponse#validate(Instant)} @@ -131,21 +141,21 @@ public String getBody() { * {@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. + * minutes or the format is one this creator does not serve. * * @param schedule the published schedule - * @param format the format parameter, {@code spki} or {@code pkcs} + * @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 format is not valid, or the answer would - * fail its check, which is a fault in the schedule + * @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 ("spki".equals(format) == false && "pkcs".equals(format) == false) { - throw new OwidException( - "format parameter 'spki' or 'pkcs' must be provided"); + if (served(format) == false) { + return new Response(400, ""); } Instant asked = now; if (date != null && date.isEmpty() == false) { diff --git a/src/main/java/com/swancommunity/owid/PublicKeyFetch.java b/src/main/java/com/swancommunity/owid/PublicKeyFetch.java index bb66658..31f2cf0 100644 --- a/src/main/java/com/swancommunity/owid/PublicKeyFetch.java +++ b/src/main/java/com/swancommunity/owid/PublicKeyFetch.java @@ -30,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 @@ -219,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} @@ -246,7 +247,7 @@ 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(); } @@ -474,7 +475,8 @@ private static CompletableFuture{@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 @@ -34,39 +35,57 @@ * 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 three fields, each a string or + * 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 { - private final String publicKeySpki; + /** + * 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 publicKeySpki, Instant validFrom, - Instant validTo) { - this.publicKeySpki = publicKeySpki; + 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 and the moments it is valid from and to, either - * of which may be null. + * 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 publicKeySpki 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 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 publicKeySpki, Instant validFrom, + public static PublicKeyResponse of(String publicKey, Instant validFrom, Instant validTo) { - return new PublicKeyResponse(publicKeySpki, validFrom, validTo); + return new PublicKeyResponse(SPKI_FORMAT, publicKey, validFrom, + validTo); } - /** The key in PEM form. */ - public String getPublicKeySpki() { - return publicKeySpki; + /** + * 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. */ @@ -81,20 +100,25 @@ public Instant getValidTo() { /** * Checks the answer the way both the creator that sends it and the client - * that reads it must. The key must be a public key this library can read, - * 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. + * 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 (publicKeySpki == null || publicKeySpki.trim().isEmpty()) { + 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(publicKeySpki); + Crypto.newVerifyOnly(publicKey); } catch (OwidException e) { throw new OwidException( "the public key answer holds a key that cannot be read"); @@ -128,8 +152,10 @@ public void validate(Instant asked) throws OwidException { * @return the JSON body */ public String toJson() { - StringBuilder json = new StringBuilder("{\"publicKeySPKI\":"); - appendString(json, publicKeySpki); + StringBuilder json = new StringBuilder("{\"format\":"); + appendString(json, format); + json.append(",\"publicKey\":"); + appendString(json, publicKey); json.append(",\"validFrom\":"); appendMoment(json, validFrom); json.append(",\"validTo\":"); @@ -138,17 +164,22 @@ public String toJson() { } /** - * Reads an answer from its JSON body. + * 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 three + * @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 { MapThis 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
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(ListKey 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 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 31f2cf0..53da3e1 100644
--- a/src/main/java/com/swancommunity/owid/PublicKeyFetch.java
+++ b/src/main/java/com/swancommunity/owid/PublicKeyFetch.java
@@ -317,14 +317,11 @@ public static CompletableFuture 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.