Skip to content

Add the dated public key fetch and the key schedule Java had none of - #6

Merged
jwrosewell merged 6 commits into
mainfrom
feature/dated-public-key-fetch
Sep 5, 2026
Merged

jwrosewell merged 6 commits into
mainfrom
feature/dated-public-key-fetch

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

The gap

Java was the only OWID port with no way to obtain a creator public key at
all. There was no HttpURLConnection, no HttpClient and no dated key
selection anywhere in src/main/java, so every Java integrator had to build
the request and choose the right key themselves.

That matters because choosing the key is exactly where the other ports went
wrong, each in a different way, and all of those have now been fixed.

Leaving Java with nothing meant every Java integrator had all three traps
still open to them.

What was built

PublicKeyFetch builds and performs the well known request, being
/owid/api/v{n}/public-key?date={minutes}&format=pkcs. The version in the
path comes from the version byte of the identifier being checked rather than
from a constant, and the minutes are counted from 2020-01-01 out of the date
the identifier itself carries. Only HttpURLConnection is used, so the
library keeps its promise of no runtime dependency and still runs on Java 8,
which has no HTTP client of its own. Keys already fetched are held against
the URL they came from, as the specification asks, and clearCache empties
that store.

PublicKeySchedule chooses between the keys a creator has published,
without any request, for a caller that already holds the schedule. The rule
is the latest key whose start is at or before the date asked about, which is
the rule the cloud itself applies. DatedPublicKey holds that start and
nothing else. The moment key material was generated is absent on purpose,
because creators generate keys in batches weeks ahead of the weeks the keys
cover, so many keys share one generation moment while starting on different
days, and selecting by that moment picks a key that has not started yet. That
is the .NET defect, and a field the type does not hold cannot be selected by.

Status rather than a boolean. The outcome is the existing
OwidVerificationResult carrying an OwidSignatureStatus, following the
naming already in this repository rather than copying another language. A key
that cannot be fetched is KEY_UNAVAILABLE and one that arrives in a form
this library cannot read is INVALID_KEY. Neither is SIGNATURE_INVALID,
because an outage or a badly served key leaves the signature unjudged and
reporting either as invalid would read as an attack.
PublicKeyFetchException carries the status, the creator domain and the
HTTP response code, so no caller has to read message text to tell an outage
from a signature that does not match. This matches KeyFetchError in the Go
port.

A domain check. The domain arrives inside an identifier that came from
outside, so a value carrying anything other than letters, digits, dots and
hyphens is refused before any request is made, rather than being pasted into
a URL where it could add a path, a query, a port or credentials and send the
request somewhere other than the creator.

Io.minutesSinceBase was added so the fetch and the wire format write agree
on what a date means without either repeating the arithmetic.

The README gained a section on verifying an identifier signed in an earlier
week, and both new examples in it are compiled and run by
ReadmeExampleTest, which is the convention this repository already follows.

Fixtures

Two real fixtures, both public and carrying no secret, in
src/test/resources.

  • identifier.txt, a 51Did creator context identifier created on
    4 September 2026 for the creator domain 51d.es.
  • public-key-schedule.txt, the thirty weekly keys that domain published
    from 11 May to 30 November 2026, each with the date it came into force and
    the moment the material was generated.

Thirteen of those keys share the generation moment of 2026-09-01T10:54:00Z
while starting on thirteen different weeks from 7 September to 30 November,
so the batch shape that broke .NET is exercised on real published data rather
than on keys invented for the occasion. The library itself holds no
generation moment, so the fixture keeps that field only for the test which
shows what selecting by it would have picked.

The live end point answers 401 without a credential, so the fetch tests run
against a stand in on the loopback address serving that same schedule, which
is what the Rust and Go work did for the same reason. 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 rather than papered over by a copy of the URL
builder living in the test.

How it was verified

Maven 3.9.16 on Java 21, the whole suite.

[INFO] Running com.example.owidconsumer.ReadmeExampleTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.swancommunity.owid.DatedKeyFetchTest
[INFO] Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.swancommunity.owid.PublicKeyScheduleTest
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0
...
[INFO] Tests run: 126, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

A green suite proves the code is not wrong, it cannot prove the tests would
notice if the rule changed, so the selection rule was replaced with "always
return the newest key", which is the shape of the .NET defect, and the suite
was run again.

[ERROR] Tests run: 12, Failures: 6 -- in com.swancommunity.owid.PublicKeyScheduleTest
[ERROR] Tests run: 13, Failures: 4 -- in com.swancommunity.owid.DatedKeyFetchTest
[ERROR]   PublicKeyScheduleTest.genuineIdentifierVerifiesAgainstTheKeyInForceOnItsDate:67
          the week beginning 31 August covers 4 September
          ==> expected: <2026-08-31T00:00:00Z> but was: <2026-11-30T00:00:00Z>
[ERROR]   DatedKeyFetchTest.datedFetchVerifiesAnIdentifierFromAnEarlierKeyWeek:136
          should verify against the key in force when it was signed
          ==> expected: <SIGNATURE_VALID> but was: <SIGNATURE_INVALID>
[ERROR]   DatedKeyFetchTest.aKeyTheEndPointCannotServeIsKeyUnavailable:185
          no key means the signature was never examined
          ==> expected: <KEY_UNAVAILABLE> but was: <SIGNATURE_INVALID>

The rule was then restored and the suite is green again, as above.

CI ran the whole matrix on commit a94ae0e and all eight jobs passed, being
Java 8, 11, 17 and 21 on both Ubuntu and Windows. The Java 8 job matters most
here, because the new code has to hold the Java 8 floor the library ships at,
and it reports the same 126 tests.

test (Java 8, ubuntu-latest)
[INFO] Tests run: 126, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

The tests that carry the risk are these.

  • genuineIdentifierVerifiesAgainstTheKeyInForceOnItsDate reads the real
    identifier, picks the key the published schedule says was in force on
    4 September 2026, being the week beginning 31 August, and gets
    SIGNATURE_VALID. It uses no HTTP and no URL building, so it is the fixed
    point everything else is measured against.
  • selectionIgnoresTheMomentTheKeysWereGenerated finds the newest key
    generated on or before the identifier date, shows that thirteen keys share
    that moment, shows that the key it picks had not started yet, and shows
    that key producing SIGNATURE_INVALID on a genuine identifier. The same
    test then shows the start based rule producing SIGNATURE_VALID on the
    same data. This is the .NET defect reproduced and then closed.
  • datedFetchVerifiesAnIdentifierFromAnEarlierKeyWeek runs the real fetch
    and asserts both the outcome and that the request named minute 3510720,
    which is what went over the wire.
  • undatedFetchLeavesAnEarlierWeeksIdentifierUnverified makes the request a
    port that forgets the date would make, against the same end point, and
    shows a genuine identifier reading as a forgery. This is the Rust defect.
  • aKeyTheEndPointCannotServeIsKeyUnavailable,
    anEndPointThatCannotBeReachedIsKeyUnavailable,
    aKeyThatCannotBeReadIsInvalidKey and
    aSchemeThatIsNotHttpIsKeyUnavailable cover the four ways the question
    goes unanswered, none of which report as a forgery.

What remains

  • Nothing was merged and nothing was pushed to main.
  • The new code uses no API later than Java 8 and the test stand in uses
    com.sun.net.httpserver, which ships with the Java 8 JDK and compiles
    under --release 8. Both Java 8 jobs in CI pass, so the floor holds.
  • The fixture identifier and schedule are a fixed point in time. When the
    51d.es schedule is regenerated the fixture keeps working, because both the
    identifier and the keys are stored here, but it will not notice a change to
    how the cloud selects keys. A cross port check against the live end point
    would notice, and that is not something this repository can do without a
    credential.
  • PublicKeySchedule is fed by a caller. Nothing here fetches a whole
    schedule, because the public key end point serves one key per request. A
    creator wanting to publish a schedule in one document would need an end
    point that does not exist yet.

Review

Produced with AI assistance and needs human review before merging. The
fixtures are public material with no secret in them, and no internal
infrastructure address appears anywhere in the branch.

Review of 5 September 2026

Four adversarial reviews (specification, cross port, cloud alignment and test validity) were run over this and the other four port pull requests. What landed here, all confirmed by reading the code:

  • PublicKeySchedule.latest() was documented as the key a request naming no date is served. A schedule is published ahead of time, so that is a key that has not started, and it is the shape of the .NET defect in a second place. It is now last(), documented as not the key in force, and current() returns the key in force now.
  • Two keys sharing a start were settled in favour of the one supplied last, where the 51Degrees cloud and the .NET port settle it in favour of the first. The schedule now agrees with them.
  • The stand in end point served the last key of the schedule for an undated request. It now answers the way the cloud's OwidApiBase.TrySelectKey does: the key in force at a fixed request moment (14 September 2026, ten days after the fixture identifier, the moment every port's stand in uses), a date later than that read as that moment, and a 400 for a date that is not a number.
  • The description above said the write path no longer repeats the minute arithmetic. It did. Io.writeDate now goes through minutesSinceBase.
  • The README and the fetch documentation said a creator that ignores the date returns what an undated request would have received anyway, as though that were harmless. Every identifier signed under an earlier key then reads as not matching, so a creator that rotates keys has to honour the date, and SPEC: Select the signing key by the start of its period, and define the undated, future and format answers owid#7 now says so in the specification.

The suite is now 127 tests, one more than the count above, all green on the eight CI jobs and locally.

The test validity review then showed by mutation that nothing distinguished the URL keyed cache from one keyed by domain, which would hand an identifier from one week the key of another. keysAreHeldPerRequestAndNotPerDomain fails under that mutation and passes at head. 128 tests.

Java was the only port with no way to obtain a creator public key, so
every integrator had to build the request and choose the key themselves,
with the three traps the other ports each fell into still open to them.

PublicKeyFetch builds and performs the well known request, being
/owid/api/v{n}/public-key?date={minutes}&format=pkcs, with the version
taken from the version byte of the identifier and the minutes counted
from 2020-01-01 out of the date the identifier carries. Only
HttpURLConnection is used, so there is still no runtime dependency and
the library still runs on Java 8. Keys are held against the URL they
came from, as the specification asks.

PublicKeySchedule chooses between the keys a creator has published, and
the rule is the latest key whose start is at or before the date asked
about, which is what the cloud applies. DatedPublicKey holds only that
start. The moment key material was generated is absent on purpose,
because creators generate keys in batches weeks ahead of the weeks the
keys cover, so many keys share one generation moment while starting on
different days, and choosing by that moment picks a key that has not
started yet.

The outcome is reported as OwidSignatureStatus rather than a boolean, so
a key that could not be fetched is KEY_UNAVAILABLE and one that could not
be read is INVALID_KEY, and neither reads as a forgery.
PublicKeyFetchException carries the status, the domain and the response
code, so no caller has to read message text to tell an outage from a
signature that does not match. A domain carrying anything other than
letters, digits, dots and hyphens is refused before a request is made,
because the domain arrives inside an identifier that came from outside.

Tested against two real fixtures, being a 51Did creator context
identifier created on 4 September 2026 for 51d.es and the thirty weekly
keys that domain published from 11 May to 30 November 2026. Thirteen of
those keys share one generation moment and start on thirteen different
weeks, so the batch shape is exercised on real data. The live end point
answers 401 without a credential, so the fetch runs against a stand in on
the loopback address serving that same schedule.
The file already promises that the examples in it are compiled and run,
so the two new ones say which test runs them and what the fetch example
can and cannot show without a network.
Reading every exit path of the fetch found one that could leave without
a status. A caller passing a scheme other than http or https reaches a
connection of another type, and the cast to HttpURLConnection would then
fail as an unchecked exception, past the status every route into this
class promises to answer with.

The type is now checked and a scheme that makes no HTTP request is
reported as a key that could not be obtained, with a test for it.
…ted request as the cloud does

PublicKeySchedule offered latest() and documented it as the key a request naming no date is served. That is wrong twice over. A schedule is published ahead of time, so the last key by start is usually one whose period has not begun, and the cloud answers an undated request with the key in force at the moment of the request, clamping a date in the future to that moment. Serving the last key where the current one was meant is the same fault the .NET port carried in its answer to a request with no date. The method is now last(), documented as not being the key in force, and current() returns the key in force now. The stand in end point in the tests answers the way the cloud does, at a fixed moment a week after the fixture identifier was signed so the undated case stays repeatable, and the test that used the last key as the current key now says what it checks.
…ay what a creator that ignores the date costs

Two keys sharing a start were settled in favour of the one supplied last, where the 51Degrees cloud and the .NET port settle it in favour of the first, so the same schedule could name different keys in different ports. The schedule now agrees with them. The write path repeated the minute arithmetic that minutesSinceBase holds, which the pull request description said it did not, so it now goes through that function. The README and the fetch documentation said a creator that ignores the date returns what an undated request would have received anyway, as though that were harmless, when every identifier signed under an earlier key then reads as not matching. The stand in end point refuses a date that is not a number with a 400, as the cloud does, and its comments say the fixed request moment is ten days after the fixture, which it is.
The cache was keyed by URL but no test distinguished that from a cache keyed by domain, which would hand an identifier from one week the key of another. Two identifiers from different weeks now fetch two different keys with one request each, and the second ask for a week is answered from what is held.
@jwrosewell
jwrosewell merged commit b92f1cd into main Sep 5, 2026
8 checks passed
@jwrosewell
jwrosewell deleted the feature/dated-public-key-fetch branch September 5, 2026 13:43
Automation51D pushed a commit to 51Degrees/pipeline-java that referenced this pull request Sep 6, 2026
…n: BUILD: Move owid-java to the key selection fix, exclude PublicKeyFetch'

The 51Degrees/owid-java fork took SWAN-community/owid-java#6 on
5 September 2026, which adds PublicKeySchedule and DatedPublicKey and
selects the creator's signing key by the start of its period rather
than the moment it was generated. The submodule moves from 694f6333 to
764996e0, whose tree is identical to SWAN main at b92f1cd2.

pipeline.did compiles the submodule source with -Xlint:all -Werror at
source and target 1.8, and on JDK 21 the new HTTP-only PublicKeyFetch
class trips the deprecation of URL(String). The module does not use
that class, because DidClient fetches the cloud's published key list
from id/key through its own HttpTransport, so it is excluded alongside
Endpoints and the pom comment says why.

mvn -pl pipeline.did test on JDK 21: 127 tests, 0 failures, 2 skipped.
Fixture check: the real 4 September identifier verifies under the key
starting 31 August 2026 and answers Invalid on the schedule shifted a
week.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant