Skip to content

Add the dated public key fetch and the key schedule PHP had none of - #3

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

Add the dated public key fetch and the key schedule PHP had none of#3
jwrosewell merged 1 commit into
mainfrom
feature/dated-public-key-fetch

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds to the PHP port the two things every other port now carries and this one did not: fetching a creator's public key from the well known end point for the date an OWID carries, and choosing a key out of a published schedule by the start of its period. The core keeps its promise of no network access, because the fetch lives in PublicKeyFetch, is loaded only by a caller that uses it, and the existing contract test that scans the source for ways to reach the network now names that one class as the exception and checks nothing else refers to it.

Why

This port was written with key fetching scoped out ("Fetching a creator public key over HTTP is out of scope"), so every application had to build the request, choose the key in force on the OWID's date, cache and time out on its own. The point of the library is that this is done once per language. The specification says where the key is and how to ask for it: the OWID's Domain field plus /owid/api/v{n}/public-key, sending the OWID's own date, with the key in force being the one with the latest start at or before that date (SWAN-community/owid#7). The Java port gained the same two pieces on 5 September 2026 in SWAN-community/owid-java#6, and this change mirrors that shape.

What changed

  • src/PublicKeyFetch.php (new): publicKeyUrl builds {scheme}://{domain}/owid/api/v{n}/public-key?date={minutes}&format=pkcs at the version the OWID carries; publicKeyPem, signatureStatus, verify and clearCache. The request goes through the HTTP stream wrapper PHP ships with, so there is no new dependency. Keys are held per request URL up to 1024 before the store is emptied, each request waits at most ten seconds, a domain that is not a host name and a scheme that is not HTTP are refused before anything is opened, a body beyond 64 KiB is not a key, and a key that cannot be had or read answers KeyUnavailable or InvalidKey rather than a forgery. A caller may pass a transport of its own, which is the route for a host that turns off remote stream access.
  • src/PublicKeyFetchException.php (new) carries the status, the domain and the response code. src/OwidException.php is no longer final so that it can be extended.
  • src/DatedPublicKey.php and src/PublicKeySchedule.php (new): keyInForce, keyFor, current, last, signatureStatus and verify. Two keys sharing a start settle in favour of the first supplied, as the cloud does. Starts are held in UTC.
  • src/Endpoints.php: publicKeyResponseAt answers the date parameter for a creator that rotates its key as the specification requires, being the key in force at the date, the key in force now for an undated or future date, 404 when nothing is in force and 400 for a date that is not a count of minutes.
  • src/Io.php: minutesSinceBase, sharing writeDate's arithmetic.
  • Tests: tests/PublicKeyFetchTest.php drives the real stream wrapper path against a stand in for the end point (tests/KeyEndPoint.php running PHP's built in web server with tests/key_end_point_router.php) that answers as the cloud does, with the request moment fixed at 14 September 2026 so an undated request is served a key other than the one that signed the fixture. tests/PublicKeyScheduleTest.php and tests/PublicKeyResponseAtTest.php cover the choice of key, including the batch generated on 1 September that broke the .NET port. tests/ParseContractTest.php keeps the no network promise with the fetch as the one named exception. tests/run.php, the plain runner, loads the new classes and checks the same behaviour through a transport of its own. The fixtures under tests/data are the genuine identifier the 51Degrees cloud issued on 4 September 2026 and the thirty published 51d.es keys, the same files the Java port uses.
  • README: the scope statement, a new section on verifying an identifier signed in an earlier week with two examples that ReadmeTest runs and checks, the interface list, and the testing notes.

Verification

  • php vendor/bin/phpunit on PHP 8.5 with PHPUnit 10.5: 151 tests, 8689 assertions, OK. Before this change the suite held 109, so 42 tests are new.
  • php tests/run.php: 142 checks passed, 9 of them new.
  • The two README examples run under the README test. The fetch one asks a creator in the reserved .invalid name space and answers KeyUnavailable; the schedule one verifies the README's own identifier under the key in force on its date.

Notes

  • The 51Did client in pipeline-php-did (DidClient) fetches the whole schedule from id/key with a resource key and selects by start itself, so it is unaffected. The 51Degrees fork and pipeline-php-did's submodule pin can follow this merge.
  • Against 51d.es, the creator named in a 51Did identifier, the fetch answers KeyUnavailable today, because that creator requires a resource key or licence key on both mandatory end points and answers 401 without one, which I checked against production on 5 September 2026. The Authentication section of the specification allows a creator to do that and requires a client to be prepared for it, so the creator is conformant and so is this port, whose duty there is to report the key as unavailable rather than the signature as forged. Presenting a credential is a separate matter, and only the JavaScript port has an answer to it today through owid.fetchHeaders. This port follows the other five in leaving it to a transport the caller supplies. Bringing the six into line with JavaScript needs one decision taken across them, because the specification lets each creator choose its own header and parameter names.

Produced with AI assistance and checked against the outputs above.

The library could verify only with a key the caller had already
obtained, so every application had to build the request to a creator's
well known end point, choose the key in force on the OWID's date, cache
and time out on its own. The other ports carry that inside the library,
and the specification (SWAN-community/owid#7) says a verifier sends the
OWID's own date every time.

PublicKeyFetch asks /owid/api/v{n}/public-key?date={minutes}&format=pkcs
on the domain the OWID carries, at the version the OWID carries, through
the HTTP stream wrapper with a ten second timeout. Keys are held per
request URL up to 1024, a domain that is not a host name or a scheme
that is not HTTP is refused before anything is opened, and a key that
cannot be had or read answers KeyUnavailable or InvalidKey rather than a
forgery. A caller may pass a transport of its own, which is the route for
a host that turns off remote stream access. PublicKeySchedule and
DatedPublicKey choose the key in force at a date out of a published
schedule, being the latest start at or before the date, with current()
the key in force now and last() documented as not that.
Endpoints::publicKeyResponseAt answers the date parameter for a creator
that rotates its key as the specification requires. Io::minutesSinceBase
shares writeDate's arithmetic. OwidException is no longer final so that
PublicKeyFetchException can carry the status, the domain and the code.

The core keeps no network access: the contract test that scans the
source for ways to reach the network now names PublicKeyFetch as the one
class allowed to, and checks nothing else refers to it. The tests drive
the real fetch against a stand in for the end point, PHP's built in web
server on the loopback address serving the published 51d.es schedule,
and check the choice of key against a genuine identifier the 51Degrees
cloud issued on 4 September 2026, the fixtures the Java port uses. The
plain runner gained the same checks. 151 PHPUnit tests and 142 plain
runner checks pass, README examples included.
@jwrosewell
jwrosewell merged commit 247178f into main Sep 5, 2026
6 checks passed
@jwrosewell
jwrosewell deleted the feature/dated-public-key-fetch branch September 5, 2026 21:34
jwrosewell added a commit to 51Degrees/pipeline-php-did that referenced this pull request Sep 6, 2026
…s do (#11)

Moves the owid-php submodule onto the 51Degrees fork head that carries the
dated public key fetch and the key schedule, which the PHP port had none of
until SWAN-community/owid-php#3.

DidClient itself needs nothing from that, because it fetches the whole
published schedule from the cloud's own key endpoint with a resource key
and selects by the start of a key's period itself, with a fifteen minute
boundary tolerance the OWID library does not model. The two are
deliberately different and both are correct.

What this package was missing is the detailed answer. Comparing the four
clients, .NET, Java and Python all report which of five things happened
when an offline check does not verify, and PHP alone answered a bare
false. A caller could not tell a forged identifier from one this package
never checked, so an outage in the key endpoint and a genuine attack read
the same, and the safe reading of a bare false is the one that reports
your own outage as an attack.

SignatureCheck names the five outcomes, and verifySignatureDetailed
returns one. Only Verified says a key was tried and matched. Only Invalid
says a key was tried and did not. NoKeyForDate, UnsupportedVersion and
InvalidLength all say the signature was never examined. verifySignature
now returns whether the outcome is Verified, so the two can never
disagree, and every existing caller keeps the answer it had.

The backing string of each case is the case name, which is the convention
FodIdParseStatus already states, being that the string is the cross
language name of the outcome so it can be logged or carried between
services. Worth noting for whoever aligns the rest: the same five outcomes
are spelled three different ways across .NET, Java and Python today, so
that convention is not yet met across the packages. Changing those is a
breaking change in two of them and is left alone here.

Adds hasLicenceKey, which Java and .NET already have and this did not.

Verified with the submodule at 247178f4: 125 tests, 695 assertions, 2
skipped, up from 117. The five outcome tests were each proved to fail when
the outcome they pin is changed in DidClient.
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