Skip to content

Fetch the creator's public key asynchronously only - #10

Merged
jwrosewell merged 9 commits into
mainfrom
feature/async-only-fetch
Sep 7, 2026
Merged

Fetch the creator's public key asynchronously only#10
jwrosewell merged 9 commits into
mainfrom
feature/async-only-fetch

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

What changed

Verification that reaches the network is now asynchronous and only asynchronous. Owid::verify and Owid::verify_status take a transport the caller supplies and return futures, and the synchronous forms are removed outright. This is one of a set of changes making every OWID port and every 51Did client asynchronous where the network is involved, so the libraries behave the same way in every language.

verify_with_public_key and verify_status_with_public_key take a key already in hand, are pure cryptography and are unchanged, as is public_key_url.

Removed

  • pub fn verify(&self, scheme: &str, others: &[&Owid]) -> Result<bool>
  • pub fn verify_status(&self, scheme: &str, others: &[&Owid]) -> SignatureStatus
  • the ureq dependency

New

pub type LocalBoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
pub struct FetchResponse { /* status, body, location */ }
pub trait PublicKeyFetch: Send + Sync {
    fn fetch<'a>(&'a self, url: &'a str) -> LocalBoxFuture<'a, Result<FetchResponse>>;
}
impl Owid {
    pub async fn verify(&self, fetch: &dyn PublicKeyFetch, scheme: &str, others: &[&Owid]) -> Result<bool>;
    pub async fn verify_status(&self, fetch: &dyn PublicKeyFetch, scheme: &str, others: &[&Owid]) -> SignatureStatus;
}

The future is deliberately not required to be Send. This crate is consumed on wasm32-wasip1, where the host environment produces futures that cannot be Send, so requiring it would shut that consumer out. A test proves the bound is absent by holding an Rc across an await inside the transport stub.

Features

fetch now adds no dependency at all and builds for WebAssembly targets, because the transport comes from the caller. A ready made one is available behind the new reqwest-fetch feature, ReqwestFetch, over asynchronous reqwest with rustls and redirect::Policy::none().

Kept exactly

The redirect refusal and the date parameter on the key URL. The refusal now lives in the library rather than the transport, so any 3xx reads as the key being unavailable no matter which transport is supplied and no transport can follow one by accident.

Verification

All clean from the repository root.

cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo build --no-default-features --features fetch --target wasm32-wasip1
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features

Tests: 128 passing across the suites with every feature on, including the converted fetch and redirect tests and the not-Send proof.

Cache keyed on the key's span, added 7 September 2026

The cache was keyed by the whole key url, and the url carries the identifier's date in minutes, so two identifiers signed a minute apart never shared an entry and a hundred identifiers over a hundred minutes made a hundred requests for one key. Keys are now held by creator end point, which is the key url without its date, each against the span of minutes the creator has confirmed it for. A key is in force from the start of its period until the next key starts, so a key the creator answers with at two minutes was in force at every minute between them, and an identifier dated inside a confirmed span is verified without a request. The span is never widened past a minute the creator has answered for, nor across a minute the creator answered with another key for. A date later than now is held against now, because the creator reads it that way, and a request with no date is read as now.

The bound of 1024 keys and the empty and refill are unchanged, as are the shared fetch between callers arriving together and the refusal to hold a failure. The same change is on every port that caches. Addresses #11.

cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings, cargo build --no-default-features --features fetch --target wasm32-wasip1 and cargo doc with warnings denied are all clean, and cargo test --all-features passed 157 on three consecutive runs. Five tests added. Delete the self.held >= MAXIMUM_CACHED_KEYS block in Cache::hold and the_cache_is_bounded fails with held 1025 of at most 1024. Replace held.in_flight.get(url) with None and callers_asking_for_one_key_at_the_same_time_share_one_fetch fails. Both checked.

Clock drift, added later the same day

The first version of this section read a date later than now as now and held the answer against now. A creator whose clock runs ahead signs identifiers dated in this process's future, and just after a rotation such an identifier would have been served the old key from a span confirmed up to now, reading as not matching for as long as the two clocks differ. A minute within fifteen minutes of now, or later, is now neither served from the cache nor held in it, and a request with no date is treated the same way. Live identifiers therefore cost one request per minute per creator, which is what they always cost, and every identifier older than the allowance is served from the spans. The test that read a future date as now is replaced by one that shows a recent minute asked about twice, a future minute and an undated request asked about, and a minute beyond the allowance held after its first request. fmt, clippy, the wasm build and doc clean and 157 tests on five consecutive runs after the change. The span tests count keys under their own end point only, because the cache is shared and the harness runs tests in parallel.

The answer carries the span, added 7 September 2026

The public key end point now answers with a JSON object carrying the key as publicKeySPKI together with validFrom and validTo, the UTC moments the key came into force and the next key starts. validFrom is null for a creator with one key and no schedule, and validTo is null for the last key in a schedule. The PEM alone as text is not a valid answer, and a client that receives it reports the key as one it cannot read. The answer is checked before it is sent, by the same code a client checks it with, so a store or schedule that would produce an answer a client refuses is a server error at the creator. endpoints::public_key_response_at and endpoints::public_key_answer build the answer from the new PublicKeySchedule, and PublicKeyAnswer reads, writes and checks the body without a dependency, so the fetch feature still adds none.

The client holds the key for the whole span the creator stated, so an identifier dated anywhere in it is verified without a request whatever the clock drift, and live identifiers cost one request per key rather than one per minute. A creator that states no span has its key held against the minutes it confirms, with the fifteen minute clock drift allowance kept out of that cache. A signature that does not verify under the key selected, where the identifier is dated within the drift allowance of an edge of the key's span, is checked against the neighbouring key before it is reported as not matching, because a creator's signing machines may not agree with its schedule to the minute.

The stand in creator in the tests answers with what this port's own server side code builds, so every client test runs against the response a creator built on this port sends. A test with eight threads, each driving its own runtime, verifying one OWID at the same moment shows one request between them.

fmt, clippy with warnings denied, the wasm32-wasip1 build and doc all clean, 167 tests on three consecutive runs. FetchResponse is unchanged, so a transport of the caller's own still compiles.

Brought into line with the specification as merged, 7 September 2026

Four commits, each on its own. The neighbouring key is asked for by the minute just beyond the edge of the span the creator stated, the span the neighbour check works from is the one the creator stated so a key with a start and no end has no later edge, a creator that stated no span has no neighbour to try, and where the creator's own statement puts the identifier's date outside the key it answered with and nothing verifies the key is reported as unavailable rather than the signature as not matching. The creator end point is gone, because the specification no longer has one. The answer carries the key as publicKey and its encoding as format, the one format is spki, a request without it receives spki, and any other value is answered 400. A signature covers the OWID's own bytes and nothing else, so signing and verifying over other OWIDs is gone with the chained interop fixtures, because nothing in production signs that way.

fmt, clippy with warnings denied, the wasm32-wasip1 build and doc with warnings denied all clean, 166 tests.

Notes

Written with AI assistance and reviewed before merging. The 51Degrees fork, the fodid crate that copies this source in, and the 51Did client follow this merge.

Verification that reaches the network now takes a transport the caller
supplies and returns a future, and the synchronous forms are gone. This
is one of a set of changes making every OWID port and every 51Did client
asynchronous where the network is involved, so the libraries behave the
same way in every language.

The transport is a trait whose future is not required to be Send,
because the crate is consumed on wasm32-wasip1 where the host produces
futures that cannot be Send. The blocking ureq dependency is gone and
the built-in transport is now optional, over asynchronous reqwest with
rustls, which never follows a redirect.

The library still refuses a redirect itself by treating any 3xx as the
key being unavailable, so no transport can follow one by accident, and
it still puts the date on the key URL.
The cache lives for as long as the process and nothing could empty it,
so a process that had learned it should no longer trust a key it already
held, because the creator rotated after a compromise, had no way to drop
it. Every other port that caches offers this: clearCache in Java and
PHP, clear_cache in Python, ClearKeyCache in Go, and ClearPublicKeyCache
in .NET.

clear_cache drops the keys held and the record of the fetches under way.
A fetch already running is not stopped and the callers waiting on it
still receive its answer, because they hold the shared state themselves,
so only what is held is dropped.

The tests that depend on the process wide cache now take a mutex, since
the harness runs tests in parallel and emptying the cache in one could
otherwise disturb another that is counting requests.
@jwrosewell

Copy link
Copy Markdown
Contributor Author

Added in ec2e95e. This port had no way to empty the key cache, and every other port that caches offers one: clearCache in Java and PHP, clear_cache in Python, ClearKeyCache in Go, and ClearPublicKeyCache in .NET. I confirmed each of those exists rather than taking it on trust. The cache here lives as long as the process and is only emptied by reaching its own limit, so a long running verifier that had learned it should no longer trust a key it already held, because the creator rotated after a compromise, could do nothing about it.

clear_cache drops the keys held and the record of the fetches under way. A fetch already running is not stopped and the callers waiting on it still get its answer, because they hold the shared state through an Arc rather than through the map, so only what is held is dropped.

One thing worth recording, because it would have become an intermittent CI failure rather than an obvious one. Adding a test that empties a process wide cache made it possible to disturb keys_are_held_per_request_and_not_per_domain, which fetches two keys and then asserts that asking for the first again makes no third request. The harness runs tests in parallel, so an empty landing between its second and third call would have failed it. It did not fire in twenty runs, which is exactly why it is worth fixing rather than trusting: both tests now take a mutex, so the ordering is decided rather than left to chance.

Verified on the committed state: cargo fmt --all -- --check clean, cargo clippy --all-targets --all-features -- -D warnings clean, 152 tests passing, cargo build --no-default-features --features fetch --target wasm32-wasip1 succeeds, and cargo doc clean with warnings denied. Fifteen consecutive full runs of the suite pass.

The readme records what clear_cache is for and the exported surface table lists it.

@jwrosewell

Copy link
Copy Markdown
Contributor Author

Verification run book for this change and the four alongside it: SWAN-community/owid-dotnet#17

It gives the commands and expected results for every port, the traps that waste time (Rust threads its tests where the other six do not, a restored file can leave a build stale and passing against an old binary, Python needs the owid submodule on PYTHONPATH with a Windows separator), and two deliberate breaks that must make specific tests fail, so the tests are shown to be worth something rather than assumed to be.

The cache was keyed by the whole key url, and the url carries the date of
the identifier being verified in minutes. A creator's key changes on the
order of a week, so two identifiers signed a minute apart never shared an
entry and a hundred identifiers over a hundred minutes made a hundred
requests for one key. The cache only ever served a repeat verification of
one identifier.

Keys are now held by end point, which is the key url without its date, and
each key carries the span of minutes the creator has confirmed it for. A key
is in force from the start of its period until the next key starts, so a key
the creator answers with at two minutes was in force at every minute between
them. An identifier dated inside a confirmed span is verified without a
request. One dated outside every span is asked about, and the answer widens
the span when the same key comes back or adds a key when it does not. The
span is never widened across a minute the creator has answered with another
key for.

A date later than now is held against now, because that is how a creator
reads it. Held against the future minute, the key would still be served for
that minute after the creator had rotated. A request without a date is read
as now for the same reason.

The bound of 1024 keys and the empty and refill are unchanged, as is the
sharing of one fetch between callers arriving together and the refusal to
hold a failure. The same change is made to every port that caches, so the
ports behave the same way.

Tests cover a minute between two confirmed minutes being served without a
request, a hundred identifiers inside one confirmed period making none, a
key never being served outside its span across a rotation, a future date
being read as now, and the bound against a creator that answers every
minute with a different key. The bound test and the shared fetch test were
checked by breaking the code and watching them fail. cargo fmt, clippy with
warnings denied, the wasm32-wasip1 build and cargo doc are all clean, and
the suite passed three consecutive runs.
…g them

The span cache read a date later than now as now and held the answer
against now. A creator whose clock runs ahead of this one's signs
identifiers dated in this process's future, and just after a rotation the
cache would have served such an identifier the old key from a span
confirmed up to now. It would have read as not matching until this clock
caught up, for as long as the two clocks differ. The cache keyed by url did
not have this fault, because it asked the creator for the exact minute.

A minute within fifteen minutes of now, or later, is now neither served
from the cache nor held in it. The creator may have read such a minute as
its present rather than as the minute named, so its answer says nothing
certain about the minute. A request with no date is treated the same way.
Live identifiers therefore cost one request per minute per creator, which
is what they always cost, and every identifier older than the allowance is
served from the spans. The same allowance is applied on every port that
caches.

The test that read a future date as now is replaced by one that shows a
recent minute asked about twice, a future minute and an undated request
asked about, and a minute beyond the allowance held after its first
request.
The public key end point now answers with a JSON object carrying the key as
publicKeySPKI together with validFrom and validTo, the UTC moments the key
came into force and the next key starts. validFrom is null for a creator
with one key and no schedule, and validTo is null for the last key in a
schedule. The PEM alone as text is not a valid answer, and a client that
receives it reports the key as one it cannot read.

The answer is checked before it is sent, by the same code a client checks
it with. 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 the key must have been in
force at the moment asked about. A creator whose store or schedule fails
that check answers with a server error rather than a bad answer, so a fault
on the server side shows up in the server's own tests and never reaches a
client.

The client holds the key for the whole span the creator stated, so an
identifier dated anywhere in it is verified without a request whatever the
clock drift, and live identifiers cost one request per key rather than one
per minute. A creator that states no span has its key held against the
minutes it confirms, with the fifteen minute clock drift allowance kept out
of that cache as before.

A signature that does not verify under the key selected for the
identifier's minute, where that minute is within the drift allowance of an
edge of the key's span, is checked against the neighbouring key before it
is reported as not matching, because a creator's signing machines may not
agree with its schedule to the minute. Where the key tried was never in
force at the identifier's minute the neighbours are not tried.

Callers verifying the same identifier at the same moment make one request
between them, and a test with many threads proves it.

The stand in creator in the tests answers with what this library's own
server side code builds, so every client test runs against the response a
creator built on this library sends, and the loop between the two halves is
closed.
…d the stated span

The neighbouring key is asked for by the minute just beyond the edge of
the span the creator stated, rather than by a minute a fixed distance from
the identifier, so a key in force for less than the drift allowance is
still the one tried. The span a caller sees is the one the creator stated.
A key stated with a start and no end has no later edge, whatever the cache
holds it for, so a live identifier dated just after a rotation is checked
against the key before it. A creator that stated no span has one key and
no neighbour to try.

Where the creator's own statement puts the identifier's date outside the
span of the key it answered with and nothing verifies, the key is reported
as unavailable rather than the signature as not matching, because a key
that was not in force proves nothing about the identifier. The boolean
verify answers with an error in that case rather than false, which would
read as a forgery, and verify_status names it KeyUnavailable.

The README describes what is held and what a live identifier costs the
way the cache now behaves, and the transport and end point docs say the
answer is the JSON form rather than the PEM.
The specification no longer has a creator end point. It repeated the key
the public-key end point serves and added fields no verifier read. The
endpoints module serves the public-key end point alone, and the JSON
writer the creator answer needed goes with it, so the endpoints feature
depends on serde for the configuration type and nothing else.
The public-key answer carries the key as publicKey and the encoding it is
in as format. The one format served is spki, which a request without the
parameter receives, and any other value is answered 400 rather than in an
encoding the caller did not ask for. The client asks for spki by name and
refuses an answer that states another format as a key it cannot read.
A signature covers the OWID's own bytes without the signature field and
nothing else. Signing and verifying over other OWIDs is gone from Creator,
from every verification surface and from the tests, along with the
chained interop fixtures, because nothing in production signs that way
and an undocumented signing input is a liability for anyone implementing
from the specification.
@jwrosewell
jwrosewell merged commit 31c565f into main Sep 7, 2026
2 checks passed
@jwrosewell
jwrosewell deleted the feature/async-only-fetch branch September 7, 2026 18:31
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