Skip to content

Add the 51Did client: two-step verification, redeem and the outcome types - #33

Open
jwrosewell wants to merge 2 commits into
mainfrom
feature/51did-client
Open

Add the 51Did client: two-step verification, redeem and the outcome types#33
jwrosewell wants to merge 2 commits into
mainfrom
feature/51did-client

Conversation

@jwrosewell

Copy link
Copy Markdown
Contributor

Closes #32.

Why

Every other 51Did language package carries a client for the two-step
verification. Rust had none, so a Rust server could create a 51Did and check
its signature but could not take part in the verification at all. The .NET
DidClient is the model the Java, Node, Python and PHP ports follow, and
this is the Rust port of it.

The gap surfaced on 3 September 2026 while the cloud gained two new creator
context outcomes. The other five ports were updated for those the same day
(51Degrees/pipeline-dotnet#386, 51Degrees/pipeline-java#122,
51Degrees/pipeline-node#189, 51Degrees/pipeline-python#72,
51Degrees/pipeline-php-did#10). Rust had nothing to update.

What the crate does

fodid-client, a new workspace member depending on fodid.

  • Fetches and caches the published signing keys, refreshing when the cache is
    stale, when no key is on or before an identifier's date, or when the date
    is later than the newest start held.
  • Verifies a signature offline against the key in force when the identifier
    was created, trying a neighbouring key within fifteen minutes of a boundary
    so ordinary clock skew between two nodes does not read as a bad signature.
  • Verifies a signature through the cloud.
  • Redeems the sealed creator context result a browser relays, which is step
    two of the two-step flow, sending the licence key the browser never sees.

SignatureCheck separates "no published key covers this date", which is an
operational matter, from "the signature does not match", which is the only
answer that says anything about the identifier.

The outcome types

ContextOutcome and FactorOutcome carry the same vocabulary the other five
ports now report, misconfigured and invaliddate included.

A factor of misconfigured says the checking service is not configured to
determine that factor, so it could not have checked it for any request. It is
read on its own and never falls through to a mismatch, because a mismatch is
a replay indicator and the identifier says nothing about that factor either
way. Everything else that is not the word verified is still a mismatch, so
an unexpected value never reads as a pass.

Transport design, and why it matters here

Every request goes through a DidHttpClient trait, following the same shape
as CloudHttpClient in cloud-request-engine. The crate carries no network
stack by default, and reqwest-client turns on a built-in blocking
reqwest transport for a native caller.

That is not tidiness. This crate has to work where there is no reqwest, in
particular an edge runtime that supplies its own fetch, which is why the
wasm32-wasip1 build is one of the checks below rather than an afterthought.

Verification

Every command below was run and passed.

cargo build -p fodid-client                              Finished
cargo build -p fodid-client --features reqwest-client     Finished
cargo build -p fodid-client --target wasm32-wasip1        Finished
cargo test  -p fodid-client         49 passed, 0 failed; 4 doc tests passed
cargo clippy -p fodid-client --all-targets -- -D warnings Finished, clean

The tests use a fake DidHttpClient that records requests and answers canned
responses, covering the key cache refresh rules, the verify URL shape, the
redeem form shape including that no licence field is sent when no licence key
was given, and each status mapping. No test touches the network.

Not verified. There is no live cloud integration test in this change. The
fodid crate has one (tests/cloud_51did.rs) and the same pattern would
suit, but it needs a resource key in the environment and a reviewer should
decide whether that belongs here or in CI.

Reference

The .NET implementation this ports:
https://github.com/51Degrees/pipeline-dotnet/tree/main/FiftyOne.Did/Client

…come types

Every other 51Did language package carries a client for the two-step
verification, and Rust had none, so a Rust server could create a 51Did
and check its signature but could not take part in the verification at
all. This adds the fodid-client crate as a port of the .NET DidClient,
which is the model the Java, Node, Python and PHP ports follow.

The crate fetches and caches the published signing keys, verifies a
signature offline against the key in force when the identifier was
created, verifies a signature through the cloud, and redeems the sealed
creator context result a browser relays, reading the outcomes the other
packages report, misconfigured and invaliddate included. A factor of
misconfigured is read on its own and never falls through to a mismatch,
because it says the checking service could not determine that factor
and the identifier says nothing about it either way.

Every request goes through a DidHttpClient trait, and the crate builds
without a network stack by default so it compiles for wasm32-wasip1 and
an edge runtime can supply its own transport. The reqwest-client
feature turns on the built-in blocking reqwest transport, following the
cloud request engine. Credentials never travel in a URL, and the
licence key is sent only in the redeem form body.

Closes #32.
Every method of DidClient that may reach the network (public_keys,
public_key_for, verify_signature, verify_signature_detailed, verify,
verify_encoded, redeem, redeem_encoded) is now `pub async fn` with the
same name and return type, and the synchronous versions are gone. The
methods that never touch the network (resource_key, endpoint,
has_licence_key and the key selection helpers) are unchanged.

The DidHttpClient trait's one method now returns a LocalBoxFuture, a
boxed future that borrows the request and transport and is deliberately
not required to be Send. The crate carries no async runtime and no
async-trait dependency, so it builds for wasm32-wasip1 and a
single-threaded host such as a Trusted Server appliance can implement
the transport and await the client, which is the same shape the cloud
request engine's awaitable transport takes.

The built-in transport behind the reqwest-client feature is now the
asynchronous reqwest client with rustls and the form feature, keeping
the thirty second default timeout, zero meaning none, and the default
redirect handling the blocking client had.

The key cache keeps every rule the tests pin (fetch on first use, again
after a day, when no key covers the date, or when the date is past the
newest start) and now shares one in-flight fetch between concurrent
callers. A caller that finds a fetch in flight waits for it and answers
from the keys it landed, fetching for itself only when that fetch
failed, and a fetch dropped before it lands clears the in-flight mark so
no waiter is stranded. The lock is never held across an await.

The tests run on a tokio current-thread runtime as a dev-dependency,
with new cases for the shared fetch, a failed shared fetch, a dropped
fetch, and a transport whose future holds an Rc across an await to prove
the future need not be Send. The README and crate documentation show the
awaited calls and the new trait shape, and their examples remain
documentation tests.
@jwrosewell

Copy link
Copy Markdown
Contributor Author

Pushed one more commit, 247e4cf, making the client asynchronous with no synchronous form, so it ships that way from the start rather than shipping synchronous and being changed before anyone uses it. This is part of the set making every 51Did client and every OWID port asynchronous where the network is involved.

The transport trait now returns a boxed future that is deliberately not required to be Send, matching the shape used in the cloud request engine, because the crate is consumed on wasm32-wasip1 where the host produces futures that cannot be Send. A test proves the bound is absent by holding an Rc across an await inside the stub. The built-in transport moves to asynchronous reqwest behind the same reqwest-client feature name.

Every network method keeps its name, arguments and return type and becomes pub async fn. resource_key, endpoint, has_licence_key and the key selection helpers never touch the network and are unchanged. The key cache now holds the in-flight fetch so concurrent callers share one request.

Verified from the workspace root: cargo fmt --all -- --check clean, cargo clippy -p fodid-client --all-targets --all-features -- -D warnings clean, cargo test -p fodid-client 53 passed and 5 doctests, the same with --features reqwest-client, and cargo build -p fodid-client --target wasm32-wasip1 succeeds.

Written with AI assistance and needs human review.

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.

51Did client parity: verify-context, redeem and the outcome types

1 participant