From 139e6488add3b1f9afda03f69ee167375b26fe2e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 3 Sep 2026 15:10:15 +0100 Subject: [PATCH 1/2] FEAT: Add the 51Did client, two-step verification, redeem and the outcome 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. --- Cargo.lock | 11 + Cargo.toml | 1 + README.md | 1 + fodid-client/Cargo.toml | 43 ++ fodid-client/README.md | 234 +++++++ fodid-client/src/client.rs | 1246 +++++++++++++++++++++++++++++++++++ fodid-client/src/error.rs | 87 +++ fodid-client/src/http.rs | 142 ++++ fodid-client/src/key.rs | 231 +++++++ fodid-client/src/lib.rs | 161 +++++ fodid-client/src/outcome.rs | 208 ++++++ fodid-client/src/redeem.rs | 344 ++++++++++ 12 files changed, 2709 insertions(+) create mode 100644 fodid-client/Cargo.toml create mode 100644 fodid-client/README.md create mode 100644 fodid-client/src/client.rs create mode 100644 fodid-client/src/error.rs create mode 100644 fodid-client/src/http.rs create mode 100644 fodid-client/src/key.rs create mode 100644 fodid-client/src/lib.rs create mode 100644 fodid-client/src/outcome.rs create mode 100644 fodid-client/src/redeem.rs diff --git a/Cargo.lock b/Cargo.lock index 16014bd..6fe661b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,17 @@ dependencies = [ "ureq", ] +[[package]] +name = "fodid-client" +version = "4.5.0" +dependencies = [ + "chrono", + "fodid", + "reqwest", + "serde_json", + "thiserror", +] + [[package]] name = "foldhash" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 2878ffb..f76dd47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ resolver = "2" members = [ "fodid", + "fodid-client", "pipeline-core", "caching", "pipeline-engines", diff --git a/README.md b/README.md index 2458f96..984b356 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ path, so cloud-only users and most of CI build without a C compiler. | [`pipeline-examples`](examples/pipeline-examples) | Runnable pipeline examples: custom flow elements, caching, usage sharing and the combined-pipeline server-side examples. | | [`examples-benches`](examples/benches) | Criterion micro-benchmarks guarding the DD, IPI and JavaScript-builder throughput budgets. | | [`fodid`](fodid) | Standalone reader for the 51Did (51Degrees Identifier) returned by the cloud, described in the [identifiers documentation](https://51degrees.com/documentation/_identifiers__index.html?utm_source=github&utm_medium=readme&utm_campaign=rust&utm_content=readme.md&utm_term=51did). It parses the OWID envelope and is independent of the pipeline stack. | +| [`fodid-client`](fodid-client) | The server side of the 51Did two-step verification: fetches and caches the signing keys, verifies a signature offline or through the cloud, and redeems the sealed creator context result a browser relays, with the typed outcomes the other 51Did packages report. Builds without a network stack by default; the `reqwest-client` feature turns on the built-in transport. | ## Feature notes diff --git a/fodid-client/Cargo.toml b/fodid-client/Cargo.toml new file mode 100644 index 0000000..7d34d66 --- /dev/null +++ b/fodid-client/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "fodid-client" +version = "4.5.0" +description = "Client for the 51Did (51Degrees Identifier) two-step verification: fetches and caches the published signing keys, verifies a 51Did signature offline or through the cloud, and redeems the sealed creator context result a browser relays, with the typed outcomes the other 51Did packages report." +keywords = ["51degrees", "fodid", "51did", "identifier", "owid"] +categories = ["web-programming::http-client", "cryptography"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[features] +# Compile the built-in blocking reqwest transport (ReqwestClient). Off by +# default so the crate builds without reqwest, and so for wasm32-wasip1 where +# an edge runtime supplies its own DidHttpClient. Native consumers enable this +# to use the built-in client. +reqwest-client = ["dep:reqwest"] + +[dependencies] +# The 51Did reader, which also carries the OWID envelope types the offline +# signature check verifies through. +fodid = { path = "../fodid", version = "4.5" } +chrono.workspace = true +serde_json.workspace = true +thiserror.workspace = true +# Blocking HTTP client for the built-in transport. rustls avoids a system +# OpenSSL dependency on Windows, and the form feature carries the url-encoded +# redeem body. Optional and gated behind the `reqwest-client` feature, because +# reqwest::blocking needs native sockets and threads and so does not build for +# targets such as wasm32-wasip1, exactly as in the cloud request engine. +reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls", "form"], optional = true } + +[dev-dependencies] +# The tests create a real signed 51Did to stand in for the cloud, which needs +# the OWID creator types fodid exposes under its creator feature. +fodid = { path = "../fodid", version = "4.5", features = ["creator"] } + +[package.metadata.docs.rs] +# Build the documentation on docs.rs with every feature enabled, so the +# built-in transport is documented alongside the trait it implements. +all-features = true diff --git a/fodid-client/README.md b/fodid-client/README.md new file mode 100644 index 0000000..7a2e1bb --- /dev/null +++ b/fodid-client/README.md @@ -0,0 +1,234 @@ +# 51Degrees Identifier Client + +[![51Degrees](https://51degrees.com/img/logo.png?utm_source=github&utm_medium=readme&utm_campaign=rust&utm_content=fodid-client-readme.md&utm_term=logo "Data rewards the curious")](https://51degrees.com/?utm_source=github&utm_medium=readme&utm_campaign=rust&utm_content=fodid-client-readme.md&utm_term=logo) +**Pipeline API** + +[Developer Documentation](https://51degrees.com/documentation/index.html?utm_source=github&utm_medium=readme&utm_campaign=rust&utm_content=fodid-client-readme.md&utm_term=documentation) + +## Introduction + +The server side of the **51Degrees identifier** (51Did) two-step +verification, for Rust. The +[identifiers documentation](https://51degrees.com/documentation/_identifiers__index.html?utm_source=github&utm_medium=readme&utm_campaign=rust&utm_content=fodid-client-readme.md&utm_term=51did) +describes what a 51Did is and how it is used. This crate is the Rust port of +the client the .NET, Java, Node, Python and PHP packages already carry, with +the .NET `DidClient` as its model. It fetches and caches the published signing +keys, verifies a 51Did 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 a 51Did is the job of the [`fodid`](../fodid) crate, which this crate +builds on and re-exports. Creating one is not part of either, because a 51Did +is created from the browser through the cloud `json` endpoint, since the +identifier describes the browser's own connection. + +The code blocks in this file are compiled as documentation tests of the +crate, so they stay true to the code. + +## The two steps + +A 51Did carries a creator context, being a record of the connection it was +created on. Checking that the identifier is being presented from that same +connection takes two steps, and the split exists so that the account's +licence key never reaches the browser. + +1. **The browser verifies.** The page calls the cloud's `verify-context` (or + `verify-full`) endpoint from the browser, so the cloud sees the browser's + own connection and compares it with the context inside the identifier. + The cloud answers with a sealed result, which the browser cannot read or + alter, and the page relays that result to its own server. +2. **The server redeems.** The server calls `DidClient::redeem` with the + identifier it knows independently, the sealed result the browser relayed, + and the licence key only the server holds. The cloud opens the seal, + confirms the result is for that identifier, is fresh and has not been + redeemed before, and answers with a `RedeemResult`. + +## Usage + +Add the crate, turning on the built-in transport where the program runs on a +native host. + +```toml +[dependencies] +fodid-client = { version = "4.5", features = ["reqwest-client"] } +``` + +Without the feature the crate carries no HTTP stack at all, which is what +lets it build for `wasm32-wasip1`, and the host supplies a transport by +implementing `DidHttpClient` and giving it to the builder. The examples below +take the transport as a parameter so they read the same either way. On a +native host, `Arc::new(fodid_client::ReqwestClient::default())` is the +transport to pass, or leave `http_client` out and the builder creates one. + +### Step two, redeeming on the server + +```rust,no_run +use std::sync::Arc; +use fodid::FodId; +use fodid_client::{ContextOutcome, DidClient, DidHttpClient, FactorOutcome}; + +fn redeem( + transport: Arc, + encoded_51did: &str, + sealed_result: &str, + challenge: Option<&str>, +) -> Result<(), Box> { + // One client for the process. It is Send + Sync and its key cache is + // shared, so build it once and reuse it. The licence key is sent only + // in the redeem form body and is never exposed by the client. + let client = DidClient::builder("your-resource-key") + .licence_key("your-licence-key") + .http_client(transport) + .build()?; + + // The identifier the server knows independently, for example from a + // cookie set when the identifier was created. Reading it says nothing + // about its signature, which the redemption reports separately. + let fod_id = FodId::from_base64(encoded_51did)?; + + let outcome = client.redeem(&fod_id, sealed_result, challenge)?; + match outcome.context() { + ContextOutcome::Verified => { + // Presented from the connection it was created on. + } + ContextOutcome::Mismatch => { + // A genuine identifier presented from somewhere else. The + // factors say which parts of the connection differ. + if let Some(factors) = outcome.factors() { + for (name, factor) in factors { + match factor { + FactorOutcome::Mismatch => println!("{name} differs"), + FactorOutcome::Verified => {} + // Not a mismatch. The checking service could not + // determine this factor, so it says nothing. + FactorOutcome::Misconfigured => {} + } + } + } + } + ContextOutcome::Misconfigured => { + // The checking service, not the identifier, is at fault. Its + // own logs name the setting to change. + } + ContextOutcome::InvalidDate => { + // Created in the future or before the scheme began, so the + // identifier is fabricated. + } + ContextOutcome::Expired | ContextOutcome::Replayed => { + // The sealed result was too old or has been seen before. + } + ContextOutcome::Unconfirmed => { + // The service answered 503 and could not confirm first use. + // Not a verdict, and the call may be retried. + } + ContextOutcome::NoContext + | ContextOutcome::NotCheckable + | ContextOutcome::Unreadable => { + // No verdict this time. outcome.body() keeps the raw answer. + } + } + Ok(()) +} +``` + +The redeem call counts as one use of the resource key, the second of the two +a browser-based context check costs. A 400 from the service comes back as +`Error::InvalidArgument` carrying the service's own message, a 404 as +`Error::NotSupported` because that host does not offer the creator context, +and any other unexpected status as `Error::UnexpectedStatus`. A value that +is not a 51Did is refused locally, before any call is made. + +### Checking a signature without the cloud + +The cloud publishes the schedule of signing keys, each in force from its +start until the next one starts. The client fetches that schedule on first +use and again when it is a day old, when no key covers the identifier's date, +or when the date is later than the newest start it holds. + +```rust,no_run +use std::sync::Arc; +use fodid::FodId; +use fodid_client::{DidClient, DidHttpClient, SignatureCheck}; + +fn check( + transport: Arc, + encoded_51did: &str, +) -> Result<(), Box> { + let client = DidClient::builder("your-resource-key") + .http_client(transport) + .build()?; + let fod_id = FodId::from_base64(encoded_51did)?; + + // Once the keys are cached this makes no network call. + match client.verify_signature_detailed(&fod_id)? { + SignatureCheck::Verified => println!("genuine"), + SignatureCheck::Invalid => println!("distrust this identifier"), + SignatureCheck::NoKey => println!("no published key covers its date"), + SignatureCheck::KeyUnusable => println!("the published key could not be read"), + } + + // The same check through the cloud, which costs one use and needs no + // licence key. + let genuine_by_cloud: bool = client.verify(&fod_id)?; + let _ = genuine_by_cloud; + Ok(()) +} +``` + +Only `SignatureCheck::Invalid` means the identifier should be distrusted. The +other two say the check could not be made, which is an operational matter to +log rather than a fraud signal. + +### Supplying a transport + +Every request goes through the `DidHttpClient` trait, one blocking `send` +that returns whatever the server answered, whatever the status. A host with +its own HTTP stack implements it and hands the client an `Arc` of it. A +transport returns `Err` only when the request did not complete, because the +client decides what each status means. + +```rust +use fodid_client::{DidHttpClient, DidHttpRequest, DidHttpResponse, HttpMethod}; + +struct HostTransport; + +impl DidHttpClient for HostTransport { + fn send(&self, request: &DidHttpRequest) -> Result { + // Hand request.url, request.form (url-encoded for a POST) and + // request.user_agent to the host's own fetch, then return the + // status and body it answered with. + let _ = (request.method == HttpMethod::Post, &request.url); + Err("not connected in this example".to_string()) + } +} +``` + +### Endpoint + +The default endpoint is the public cloud, `https://cloud.51degrees.com/api/v4/`. +A privately hosted copy of the service is reached by giving the builder its +base with `endpoint(...)`, or by setting the `FOD_CLOUD_API_URL` environment +variable, which is the same variable the cloud request engine honours. + +## Find out more + +The other 51Did clients this crate is a port of, and the engine repositories: + +- https://github.com/51Degrees/rust +- https://github.com/51Degrees/pipeline-dotnet +- https://github.com/51Degrees/pipeline-java +- https://github.com/51Degrees/pipeline-node +- https://github.com/51Degrees/pipeline-python +- https://github.com/51Degrees/pipeline-php-did +- https://github.com/51Degrees/owid-rust + +On 51degrees.com: + +- [What a 51Did is and how it is used](https://51degrees.com/documentation/_identifiers__index.html?utm_source=github&utm_medium=readme&utm_campaign=rust&utm_content=fodid-client-readme.md&utm_term=identifiers-documentation) +- [The OWID envelope a 51Did travels in](https://51degrees.com/documentation/_pipeline_api__advanced_features__o_w_i_d.html?utm_source=github&utm_medium=readme&utm_campaign=rust&utm_content=fodid-client-readme.md&utm_term=owid-documentation) +- [The 51Did inspector, a visual breakdown of an identifier](https://51degrees.com/developers/51did-inspector?utm_source=github&utm_medium=readme&utm_campaign=rust&utm_content=fodid-client-readme.md&utm_term=51did-inspector) +- [Get a resource key](https://configure.51degrees.com/?utm_source=github&utm_medium=readme&utm_campaign=rust&utm_content=fodid-client-readme.md&utm_term=configure) + +## License + +EUPL-1.2. See [LICENSE](../LICENSE). diff --git a/fodid-client/src/client.rs b/fodid-client/src/client.rs new file mode 100644 index 0000000..62109ff --- /dev/null +++ b/fodid-client/src/client.rs @@ -0,0 +1,1246 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +//! The client, being everything a server does with a 51Did against the +//! 51Degrees cloud. + +use std::sync::{Arc, Mutex, MutexGuard}; + +use chrono::{DateTime, Duration, Utc}; +use fodid::FodId; + +use crate::error::{Error, Result}; +use crate::http::{DidHttpClient, DidHttpRequest, DidHttpResponse, HttpMethod}; +use crate::key::{candidates_for_date, in_force_at, parse_keys, DidPublicKey}; +use crate::outcome::SignatureCheck; +use crate::redeem::RedeemResult; + +/// The public cloud API base, used when no endpoint is given and +/// [`ENDPOINT_ENVIRONMENT_VARIABLE`] is not set. +pub const DEFAULT_ENDPOINT: &str = "https://cloud.51degrees.com/api/v4/"; + +/// The environment variable read for the API base when the builder is given +/// none, the same variable the cloud request engine honours. A host other +/// than the public cloud is used for a privately hosted copy of the same +/// service. +pub const ENDPOINT_ENVIRONMENT_VARIABLE: &str = "FOD_CLOUD_API_URL"; + +/// How old the cached key list may be before a lookup fetches it again. Keys +/// are published up to three months ahead of their start, so a day is far +/// inside that margin. +pub const KEY_CACHE_LIFETIME: Duration = Duration::days(1); + +/// The `User-Agent` every request carries, naming this crate and its +/// version. +pub const USER_AGENT: &str = concat!("fodid-client/", env!("CARGO_PKG_VERSION")); + +/// The longest encoded value the client will parse or send. +/// +/// A guard against obviously malformed input, so the client does no work +/// and makes no call for a value that cannot be an identifier. The figure is +/// arbitrary and deliberately generous, well beyond anything the cloud +/// issues, because the length of a 51Did is the cloud's business and not +/// this crate's. +pub const MAXIMUM_ENCODED_LENGTH: usize = 4096; + +/// The clock the key cache ages against, replaceable so a test can move +/// time on without waiting. +type Clock = Arc DateTime + Send + Sync>; + +/// The cached key schedule and when it was fetched. +struct KeyCache { + keys: Option>, + fetched_at: DateTime, +} + +/// Everything a server does with a 51Did against the 51Degrees cloud: fetch +/// and cache the signing public keys, verify a signature offline against the +/// key in force when the identifier was created, verify a signature through +/// the cloud, and redeem a sealed creator context result with the account's +/// licence key. +/// +/// Creating a 51Did is not part of this client. Creation is the cloud `json` +/// endpoint through the cloud request engine and pipeline, and a page +/// creates from the browser because the identifier describes the browser's +/// own connection. The `verify-context` and `verify-full` endpoints are +/// browser calls for the same reason, so they are not here either. This +/// client is the server side, which holds the licence key the browser never +/// sees. +/// +/// Credentials never travel in a URL. The resource key is part of the route, +/// as the endpoints accept, and the licence key travels only in a POST form +/// body, because a query string is written to access logs. +/// +/// The key cache is per instance and safe to share across threads, so +/// create one client for the process and reuse it. Every call blocks until +/// the transport answers. +pub struct DidClient { + http: Arc, + resource_key: String, + licence_key: Option, + endpoint: String, + clock: Clock, + cache: Mutex, +} + +impl std::fmt::Debug for DidClient { + /// Names the endpoint and whether a licence key is held, and never the + /// licence key itself. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DidClient") + .field("endpoint", &self.endpoint) + .field("has_licence_key", &self.licence_key.is_some()) + .finish_non_exhaustive() + } +} + +/// Builds a [`DidClient`]. Start from [`DidClient::builder`]. +pub struct DidClientBuilder { + resource_key: String, + licence_key: Option, + endpoint: Option, + http: Option>, + clock: Option, +} + +impl DidClientBuilder { + /// A licence key of the same account, server side only. Needed to + /// redeem where the account holds licence keys, and sent only in the + /// redeem form body. An empty value is the same as none. + pub fn licence_key(mut self, licence_key: impl Into) -> Self { + let value = licence_key.into(); + self.licence_key = if value.is_empty() { None } else { Some(value) }; + self + } + + /// The API base including `/api/v4/`. When not given, + /// [`ENDPOINT_ENVIRONMENT_VARIABLE`] is read, and when that is unset too + /// [`DEFAULT_ENDPOINT`] is used. A value with or without a trailing + /// slash is accepted, and is normalised to end in exactly one. + pub fn endpoint(mut self, endpoint: impl Into) -> Self { + self.endpoint = Some(endpoint.into()); + self + } + + /// The transport to send through, so a test can stand in for the + /// network and a host without `reqwest` can supply its own. + /// + /// Without the `reqwest-client` feature this is required, because the + /// crate then carries no transport of its own. + pub fn http_client(mut self, http: Arc) -> Self { + self.http = Some(http); + self + } + + /// The clock the key cache ages against, so a test can move time on. + /// The system clock is used when none is given. + pub fn clock(mut self, clock: impl Fn() -> DateTime + Send + Sync + 'static) -> Self { + self.clock = Some(Arc::new(clock)); + self + } + + /// Builds the client. + /// + /// # Errors + /// + /// [`Error::InvalidArgument`] when the resource key is blank, the + /// endpoint is not an absolute URL, or no transport was given and the + /// crate was built without the `reqwest-client` feature. + pub fn build(self) -> Result { + if self.resource_key.trim().is_empty() { + return Err(Error::InvalidArgument( + "a resource key is required".to_string(), + )); + } + let endpoint = normalise_endpoint(self.endpoint.or_else(read_endpoint_variable))?; + let http = match self.http { + Some(http) => http, + None => default_transport()?, + }; + let clock: Clock = self.clock.unwrap_or_else(|| Arc::new(Utc::now)); + let fetched_at = clock(); + Ok(DidClient { + http, + resource_key: self.resource_key, + licence_key: self.licence_key, + endpoint, + clock, + cache: Mutex::new(KeyCache { + keys: None, + fetched_at, + }), + }) + } +} + +#[cfg(feature = "reqwest-client")] +fn default_transport() -> Result> { + let client = crate::http::ReqwestClient::new(std::time::Duration::from_secs(30)) + .map_err(Error::Transport)?; + Ok(Arc::new(client)) +} + +#[cfg(not(feature = "reqwest-client"))] +fn default_transport() -> Result> { + Err(Error::InvalidArgument( + "a transport is required: this build carries no HTTP client of its \ + own, so give the builder a DidHttpClient or enable the \ + reqwest-client feature" + .to_string(), + )) +} + +fn read_endpoint_variable() -> Option { + std::env::var(ENDPOINT_ENVIRONMENT_VARIABLE) + .ok() + .filter(|value| !value.trim().is_empty()) +} + +/// Trims the endpoint, makes it end in exactly one slash, and refuses +/// anything that is not an absolute URL. +fn normalise_endpoint(endpoint: Option) -> Result { + let value = match endpoint { + Some(value) if !value.trim().is_empty() => value.trim().to_string(), + _ => DEFAULT_ENDPOINT.to_string(), + }; + let value = format!("{}/", value.trim_end_matches('/')); + let absolute = value.split_once("://").is_some_and(|(scheme, rest)| { + !scheme.is_empty() + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && rest.len() > 1 + }); + if !absolute { + return Err(Error::InvalidArgument(format!( + "the endpoint '{value}' is not an absolute URL" + ))); + } + Ok(value) +} + +impl DidClient { + /// Starts building a client for the resource key, which is public by + /// nature. + pub fn builder(resource_key: impl Into) -> DidClientBuilder { + DidClientBuilder { + resource_key: resource_key.into(), + licence_key: None, + endpoint: None, + http: None, + clock: None, + } + } + + /// The resource key the client sends. + pub fn resource_key(&self) -> &str { + &self.resource_key + } + + /// The API base every request is built on, ending in one slash. + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + /// Whether a licence key was given. The key itself is not exposed. + pub fn has_licence_key(&self) -> bool { + self.licence_key.is_some() + } + + /// The signing public keys the cloud publishes, fetched on first use and + /// then answered from the cache. Use [`DidClient::public_key_for`] to + /// pick the key for one identifier, which also refreshes the cache when + /// it is stale. + /// + /// # Errors + /// + /// [`Error::Transport`] when the cloud cannot be reached, and + /// [`Error::UnexpectedStatus`] when it answers with a status other than + /// 200. + pub fn public_keys(&self) -> Result> { + let mut cache = self.lock_cache(); + if cache.keys.is_none() { + self.refresh_keys_locked(&mut cache)?; + } + Ok(cache.keys.clone().unwrap_or_default()) + } + + /// The key in force when the identifier was created, being the entry + /// whose start is latest on or before the identifier's date. The cache + /// is fetched again, once, before answering when it holds no entry on + /// or before the date, when the date is later than the newest start + /// held, or when the cache is older than [`KEY_CACHE_LIFETIME`]. + /// + /// Answers `None` when the date precedes the whole schedule. + /// + /// # Errors + /// + /// [`Error::Transport`] and [`Error::UnexpectedStatus`] when a fetch was + /// needed and did not answer with 200. + pub fn public_key_for(&self, fod_id: &FodId) -> Result> { + let date = fod_id.date(); + let keys = self.keys_covering(date)?; + Ok(in_force_at(&keys, date).cloned()) + } + + /// Verifies the identifier's signature offline against the published + /// keys, without a cloud call once the keys are cached. + /// + /// True only when the signature verifies under a key in force at the + /// identifier's date. See [`DidClient::verify_signature_detailed`] for + /// why a check did not pass. + pub fn verify_signature(&self, fod_id: &FodId) -> Result { + Ok(self.verify_signature_detailed(fod_id)? == SignatureCheck::Verified) + } + + /// Verifies the identifier's signature offline and says why when the + /// check did not pass. + /// + /// The keys tried are the one in force at the identifier's date and, + /// near a boundary in the schedule, the neighbouring key where the two + /// differ, best first. A longer payload carries a creator context + /// section and is accepted, because the signature covers the whole + /// payload. + /// + /// # Errors + /// + /// [`Error::Transport`] and [`Error::UnexpectedStatus`] when a key fetch + /// was needed and did not answer with 200. + pub fn verify_signature_detailed(&self, fod_id: &FodId) -> Result { + let date = fod_id.date(); + let keys = self.keys_covering(date)?; + let candidates = candidates_for_date(&keys, date); + if candidates.is_empty() { + return Ok(SignatureCheck::NoKey); + } + let mut unusable = false; + for candidate in candidates { + match fod_id.verify_with_public_key(candidate.public_key_pem(), &[]) { + Ok(true) => return Ok(SignatureCheck::Verified), + Ok(false) => {} + Err(_) => unusable = true, + } + } + Ok(if unusable { + SignatureCheck::KeyUnusable + } else { + SignatureCheck::Invalid + }) + } + + /// Verifies the identifier's signature through the cloud's verify + /// endpoint, which needs no licence key and counts as one use. + /// + /// # Errors + /// + /// [`Error::InvalidArgument`] with the cloud's message when the cloud + /// refused the value, [`Error::Transport`] when the cloud cannot be + /// reached, and [`Error::UnexpectedStatus`] when it answers with a + /// status this client does not expect. + pub fn verify(&self, fod_id: &FodId) -> Result { + // A parsed identifier is already known to be a 51Did, so the string + // surface's local check is not repeated. + let encoded = fod_id + .as_base64() + .map_err(|e| Error::InvalidArgument(format!("the 51Did could not be encoded: {e}")))?; + self.verify_encoded_unchecked(&encoded) + } + + /// Verifies a 51Did string's signature through the cloud's verify + /// endpoint, which needs no licence key and counts as one use. The + /// identifier is sent as `51did` and again as `owid`, the name the + /// endpoint first went live under, so a service of either age answers. + /// + /// # Errors + /// + /// [`Error::InvalidArgument`] when the value is not a 51Did, refused + /// here before any call is made, or with the cloud's message when the + /// cloud refused it. [`Error::Transport`] when the cloud cannot be + /// reached, and [`Error::UnexpectedStatus`] when it answers with a + /// status this client does not expect. + pub fn verify_encoded(&self, fod_id: &str) -> Result { + validate_encoded_value(fod_id)?; + self.verify_encoded_unchecked(fod_id) + } + + fn verify_encoded_unchecked(&self, fod_id: &str) -> Result { + // The documented parameter is 51did. The same value is sent again as + // owid, the name the verify endpoint first went live under, which a + // service that predates the 51did name reads and a current one + // accepts as an alias, so both answer. + let encoded = escape_data_string(fod_id); + let url = format!( + "{}id/verify/{}?51did={encoded}&owid={encoded}", + self.endpoint, + escape_data_string(&self.resource_key) + ); + let response = self.send(HttpMethod::Get, url, Vec::new())?; + if response.status == 200 || response.status == 400 { + if let Some(valid) = read_valid(&response.body) { + return Ok(valid); + } + if response.status == 400 { + if let Some(errors) = read_errors(&response.body) { + return Err(Error::InvalidArgument(errors)); + } + } + } + Err(unexpected("verify", &response)) + } + + /// Redeems a sealed creator context result against the identifier it + /// was made for, sending the licence key where one was given. Counts as + /// one use, the second of the two a browser-based context check costs. + /// + /// `result` is the sealed result the browser relayed, and `challenge` + /// the single-use challenge given to the verify call, where one was. + /// + /// # Errors + /// + /// [`Error::InvalidArgument`] with the cloud's message when the cloud + /// answered 400, [`Error::NotSupported`] when the host answered 404 and + /// so does not offer the creator context, [`Error::Transport`] when the + /// cloud cannot be reached, and [`Error::UnexpectedStatus`] for any + /// other status. + pub fn redeem( + &self, + fod_id: &FodId, + result: &str, + challenge: Option<&str>, + ) -> Result { + // A parsed identifier is already known to be a 51Did, so the string + // surface's local check is not repeated. + let encoded = fod_id + .as_base64() + .map_err(|e| Error::InvalidArgument(format!("the 51Did could not be encoded: {e}")))?; + self.redeem_encoded_unchecked(&encoded, result, challenge) + } + + /// Redeems a sealed creator context result against a 51Did string. See + /// [`DidClient::redeem`]. + /// + /// # Errors + /// + /// [`Error::InvalidArgument`] when the value is not a 51Did, refused + /// here before any call is made, and otherwise as [`DidClient::redeem`]. + pub fn redeem_encoded( + &self, + fod_id: &str, + result: &str, + challenge: Option<&str>, + ) -> Result { + validate_encoded_value(fod_id)?; + self.redeem_encoded_unchecked(fod_id, result, challenge) + } + + fn redeem_encoded_unchecked( + &self, + fod_id: &str, + result: &str, + challenge: Option<&str>, + ) -> Result { + // Everything travels in the form body, the resource key included, + // because the redeem endpoint's POST route is the bare path and reads + // its parameters from the form. Nothing here is written to an access + // log. + let mut form = vec![ + ("resource".to_string(), self.resource_key.clone()), + ("51did".to_string(), fod_id.to_string()), + ("result".to_string(), result.to_string()), + ( + "challenge".to_string(), + challenge.unwrap_or_default().to_string(), + ), + ]; + if let Some(licence_key) = &self.licence_key { + form.push(("license".to_string(), licence_key.clone())); + } + let url = format!("{}id/redeem", self.endpoint); + let response = self.send(HttpMethod::Post, url, form)?; + match response.status { + 200 | 503 => Ok(RedeemResult::from_response(response.status, &response.body)), + 400 => Err(Error::InvalidArgument( + read_errors(&response.body).unwrap_or_else(|| response.body.clone()), + )), + 404 => Err(Error::NotSupported(self.endpoint.clone())), + _ => Err(unexpected("redeem", &response)), + } + } + + /// The cached keys, fetched again first when + /// [`DidClient::public_key_for`] says a fetch is due for the date. + fn keys_covering(&self, date: DateTime) -> Result> { + let mut cache = self.lock_cache(); + let refresh = match &cache.keys { + None => true, + Some(keys) => self.needs_refresh_locked(keys, cache.fetched_at, date), + }; + if refresh { + self.refresh_keys_locked(&mut cache)?; + } + Ok(cache.keys.clone().unwrap_or_default()) + } + + fn needs_refresh_locked( + &self, + keys: &[DidPublicKey], + fetched_at: DateTime, + date: DateTime, + ) -> bool { + if (self.clock)() - fetched_at > KEY_CACHE_LIFETIME { + return true; + } + if in_force_at(keys, date).is_none() { + return true; + } + let newest = keys.iter().map(DidPublicKey::starts_at).max(); + newest.is_none_or(|newest| date > newest) + } + + fn refresh_keys_locked(&self, cache: &mut MutexGuard<'_, KeyCache>) -> Result<()> { + let url = format!( + "{}id/key/{}", + self.endpoint, + escape_data_string(&self.resource_key) + ); + let response = self.send(HttpMethod::Get, url, Vec::new())?; + if response.status != 200 { + return Err(unexpected("key", &response)); + } + cache.keys = Some(parse_keys(&response.body)?); + cache.fetched_at = (self.clock)(); + Ok(()) + } + + fn lock_cache(&self) -> MutexGuard<'_, KeyCache> { + // A thread that panicked while holding the lock leaves the cache in + // a state that is still a whole key list or none, so the guard is + // taken over rather than the poison spread to every later caller. + self.cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn send( + &self, + method: HttpMethod, + url: String, + form: Vec<(String, String)>, + ) -> Result { + let request = DidHttpRequest { + method, + url, + form, + user_agent: USER_AGENT.to_string(), + }; + self.http.send(&request).map_err(Error::Transport) + } +} + +fn unexpected(endpoint: &'static str, response: &DidHttpResponse) -> Error { + Error::UnexpectedStatus { + endpoint, + status: response.status, + body: Error::truncate(&response.body), + } +} + +/// Refuses a string that cannot be a 51Did before any key is fetched or any +/// call is made. The length guard comes first, so that nothing is parsed for +/// a value far larger than any identifier, then the value is parsed, so that +/// a malformed one is named for what it is here rather than sent to the +/// cloud to be refused there. The parse says nothing about the signature, +/// which is the question the call is being made to answer. +fn validate_encoded_value(fod_id: &str) -> Result<()> { + if fod_id.trim().is_empty() { + return Err(Error::InvalidArgument("a 51Did is required".to_string())); + } + if fod_id.chars().count() > MAXIMUM_ENCODED_LENGTH { + return Err(Error::InvalidArgument( + "the value is too long to be a 51Did".to_string(), + )); + } + FodId::from_base64(fod_id) + .map(|_| ()) + .map_err(|e| Error::InvalidArgument(format!("the value is not a 51Did ({e})"))) +} + +/// Percent-encodes a value for a URL path segment or query value, leaving +/// only the unreserved characters of RFC 3986 as they are. +fn escape_data_string(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +/// The `valid` boolean of a verify answer, or `None` when the body is not a +/// JSON object carrying one. +fn read_valid(body: &str) -> Option { + serde_json::from_str::(body) + .ok()? + .as_object()? + .get("valid")? + .as_bool() +} + +/// The cloud's `errors` array joined into one message, or `None` when the +/// body carries none. +fn read_errors(body: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(body).ok()?; + let errors = value.as_object()?.get("errors")?.as_array()?; + if errors.is_empty() { + return None; + } + Some( + errors + .iter() + .map(|e| match e.as_str() { + Some(text) => text.to_string(), + None => e.to_string(), + }) + .collect::>() + .join(" "), + ) +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::Mutex; + + use fodid::{Creator, Crypto}; + + use super::*; + use crate::outcome::ContextOutcome; + + const RESOURCE_KEY: &str = "AQS5HKcy-resource"; + const ENDPOINT: &str = "https://example.test/api/v4/"; + + /// Stands in for the network, recording every request and answering + /// canned responses in order. + #[derive(Default)] + struct FakeHttp { + requests: Mutex>, + responses: Mutex>>, + } + + impl FakeHttp { + fn answering(responses: Vec<(u16, &str)>) -> Arc { + let fake = Self::default(); + for (status, body) in responses { + fake.responses + .lock() + .unwrap() + .push_back(Ok(DidHttpResponse { + status, + body: body.to_string(), + })); + } + Arc::new(fake) + } + + fn failing(message: &str) -> Arc { + let fake = Self::default(); + fake.responses + .lock() + .unwrap() + .push_back(Err(message.to_string())); + Arc::new(fake) + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + } + + impl DidHttpClient for FakeHttp { + fn send(&self, request: &DidHttpRequest) -> std::result::Result { + self.requests.lock().unwrap().push(request.clone()); + self.responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| Err("no canned response left for this request".to_string())) + } + } + + /// A signing key pair standing in for the cloud's, and the 51Did it + /// signs. + struct Fixture { + public_pem: String, + fod_id: FodId, + } + + impl Fixture { + fn new() -> Self { + let crypto = Crypto::new(); + let public_pem = crypto.public_key_pem().expect("export public key"); + let creator = Creator::new("51degrees.com", crypto).expect("create creator"); + let payload = vec![0u8; fodid::HEADER_LENGTH + fodid::MATCH_KEY_LENGTH]; + let owid = creator.create(payload).expect("sign the envelope"); + let fod_id = FodId::from_owid(owid).expect("a 51Did"); + Self { public_pem, fod_id } + } + + fn encoded(&self) -> String { + self.fod_id.as_base64().expect("encode") + } + + /// A key list whose one entry started yesterday and whose second + /// entry is published ahead, as the cloud does, so an identifier + /// created now is inside the schedule and before the newest start. + fn keys_json(&self) -> String { + self.keys_json_with(&self.public_pem) + } + + fn keys_json_with(&self, pem: &str) -> String { + let now = Utc::now(); + let yesterday = (now - Duration::days(1)).to_rfc3339(); + let next_month = (now + Duration::days(30)).to_rfc3339(); + let escaped = pem.replace('\n', "\\n"); + format!( + r#"[{{"startsAt":"{yesterday}","publicKey":"{escaped}"}}, + {{"startsAt":"{next_month}","publicKey":"another"}}]"# + ) + } + } + + fn new_client(http: Arc) -> DidClient { + DidClient::builder(RESOURCE_KEY) + .endpoint(ENDPOINT) + .http_client(http) + .build() + .expect("the client builds") + } + + fn new_client_with_licence(http: Arc) -> DidClient { + DidClient::builder(RESOURCE_KEY) + .endpoint(ENDPOINT) + .licence_key("licence-value") + .http_client(http) + .build() + .expect("the client builds") + } + + fn form_value<'a>(request: &'a DidHttpRequest, name: &str) -> Option<&'a str> { + request + .form + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.as_str()) + } + + // Building. + + #[test] + fn a_blank_resource_key_is_refused() { + let error = DidClient::builder(" ") + .endpoint(ENDPOINT) + .http_client(FakeHttp::answering(vec![])) + .build() + .unwrap_err(); + assert!(matches!(error, Error::InvalidArgument(_)), "{error}"); + } + + #[test] + fn the_endpoint_ends_in_exactly_one_slash() { + let with_none = DidClient::builder(RESOURCE_KEY) + .endpoint("https://example.test/api/v4") + .http_client(FakeHttp::answering(vec![])) + .build() + .unwrap(); + assert_eq!(with_none.endpoint(), ENDPOINT); + let with_two = DidClient::builder(RESOURCE_KEY) + .endpoint(" https://example.test/api/v4// ") + .http_client(FakeHttp::answering(vec![])) + .build() + .unwrap(); + assert_eq!(with_two.endpoint(), ENDPOINT); + } + + #[test] + fn a_relative_endpoint_is_refused() { + let error = DidClient::builder(RESOURCE_KEY) + .endpoint("api/v4") + .http_client(FakeHttp::answering(vec![])) + .build() + .unwrap_err(); + assert!(matches!(error, Error::InvalidArgument(_)), "{error}"); + } + + #[test] + fn the_default_endpoint_and_the_environment_variable() { + // Only this test touches the variable, and every other test gives + // the builder an endpoint, so nothing else reads it. + std::env::remove_var(ENDPOINT_ENVIRONMENT_VARIABLE); + let default = DidClient::builder(RESOURCE_KEY) + .http_client(FakeHttp::answering(vec![])) + .build() + .unwrap(); + assert_eq!(default.endpoint(), DEFAULT_ENDPOINT); + + std::env::set_var(ENDPOINT_ENVIRONMENT_VARIABLE, "https://private.test/api/v4"); + let from_variable = DidClient::builder(RESOURCE_KEY) + .http_client(FakeHttp::answering(vec![])) + .build() + .unwrap(); + std::env::remove_var(ENDPOINT_ENVIRONMENT_VARIABLE); + assert_eq!(from_variable.endpoint(), "https://private.test/api/v4/"); + } + + #[test] + fn the_licence_key_is_held_but_never_shown() { + let without = new_client(FakeHttp::answering(vec![])); + assert!(!without.has_licence_key()); + let with = new_client_with_licence(FakeHttp::answering(vec![])); + assert!(with.has_licence_key()); + let shown = format!("{with:?}"); + assert!(!shown.contains("licence-value"), "{shown}"); + assert!(shown.contains("has_licence_key: true"), "{shown}"); + } + + // Keys and the cache. + + #[test] + fn keys_are_fetched_from_the_key_endpoint_with_the_user_agent() { + let fixture = Fixture::new(); + let http = FakeHttp::answering(vec![(200, &fixture.keys_json())]); + let client = new_client(http.clone()); + let keys = client.public_keys().unwrap(); + assert_eq!(keys.len(), 2); + let requests = http.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, HttpMethod::Get); + assert_eq!( + requests[0].url, + format!("{ENDPOINT}id/key/{}", escape_data_string(RESOURCE_KEY)) + ); + assert!(requests[0].form.is_empty()); + assert_eq!(requests[0].user_agent, USER_AGENT); + assert_eq!( + USER_AGENT, + concat!("fodid-client/", env!("CARGO_PKG_VERSION")) + ); + } + + #[test] + fn a_key_answer_other_than_200_is_unexpected() { + let http = FakeHttp::answering(vec![(500, "down")]); + let error = new_client(http).public_keys().unwrap_err(); + match error { + Error::UnexpectedStatus { + endpoint, status, .. + } => { + assert_eq!(endpoint, "key"); + assert_eq!(status, 500); + } + other => panic!("expected UnexpectedStatus, got {other}"), + } + } + + #[test] + fn a_transport_failure_is_reported_as_one() { + let error = new_client(FakeHttp::failing("no route")) + .public_keys() + .unwrap_err(); + assert!( + matches!(error, Error::Transport(ref m) if m == "no route"), + "{error}" + ); + } + + #[test] + fn a_fresh_cache_inside_the_schedule_is_not_fetched_again() { + let fixture = Fixture::new(); + let http = FakeHttp::answering(vec![(200, &fixture.keys_json())]); + let client = new_client(http.clone()); + assert!(client.public_key_for(&fixture.fod_id).unwrap().is_some()); + assert!(client.public_key_for(&fixture.fod_id).unwrap().is_some()); + assert!(client.verify_signature(&fixture.fod_id).unwrap()); + assert_eq!(http.requests().len(), 1, "one fetch serves every lookup"); + } + + #[test] + fn a_cache_older_than_a_day_is_fetched_again() { + let fixture = Fixture::new(); + let keys = fixture.keys_json(); + let http = FakeHttp::answering(vec![(200, &keys), (200, &keys)]); + let now = Arc::new(Mutex::new(Utc::now())); + let clock_now = now.clone(); + let client = DidClient::builder(RESOURCE_KEY) + .endpoint(ENDPOINT) + .http_client(http.clone()) + .clock(move || *clock_now.lock().unwrap()) + .build() + .unwrap(); + client.public_key_for(&fixture.fod_id).unwrap(); + *now.lock().unwrap() += KEY_CACHE_LIFETIME - Duration::minutes(1); + client.public_key_for(&fixture.fod_id).unwrap(); + assert_eq!(http.requests().len(), 1, "still inside the lifetime"); + *now.lock().unwrap() += Duration::minutes(2); + client.public_key_for(&fixture.fod_id).unwrap(); + assert_eq!(http.requests().len(), 2, "stale, so fetched again"); + } + + #[test] + fn a_date_before_every_key_held_is_fetched_again() { + let fixture = Fixture::new(); + // A schedule that only starts tomorrow does not cover an identifier + // created now, so the client looks again before answering. + let tomorrow = (Utc::now() + Duration::days(1)).to_rfc3339(); + let later = format!(r#"[{{"startsAt":"{tomorrow}","publicKey":"x"}}]"#); + let http = FakeHttp::answering(vec![(200, &later), (200, &later), (200, &later)]); + let client = new_client(http.clone()); + assert!(client.public_key_for(&fixture.fod_id).unwrap().is_none()); + assert!(client.public_key_for(&fixture.fod_id).unwrap().is_none()); + assert_eq!( + http.requests().len(), + 2, + "each lookup fetched, none covered" + ); + assert_eq!( + client.verify_signature_detailed(&fixture.fod_id).unwrap(), + SignatureCheck::NoKey + ); + assert_eq!( + http.requests().len(), + 3, + "the signature check looked again too" + ); + } + + #[test] + fn a_date_after_the_newest_start_held_is_fetched_again() { + let fixture = Fixture::new(); + // A schedule with no key published ahead: the newest start is + // yesterday, and an identifier created now is later than it, so the + // cloud may have published a newer key and the client looks again. + let yesterday = (Utc::now() - Duration::days(1)).to_rfc3339(); + let escaped = fixture.public_pem.replace('\n', "\\n"); + let json = format!(r#"[{{"startsAt":"{yesterday}","publicKey":"{escaped}"}}]"#); + let http = FakeHttp::answering(vec![(200, &json), (200, &json)]); + let client = new_client(http.clone()); + assert!(client.public_key_for(&fixture.fod_id).unwrap().is_some()); + assert!(client.public_key_for(&fixture.fod_id).unwrap().is_some()); + assert_eq!(http.requests().len(), 2); + } + + // Offline signature checking. + + #[test] + fn a_genuine_signature_verifies_under_the_key_in_force() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![(200, &fixture.keys_json())])); + assert_eq!( + client.verify_signature_detailed(&fixture.fod_id).unwrap(), + SignatureCheck::Verified + ); + assert!(client.verify_signature(&fixture.fod_id).unwrap()); + } + + #[test] + fn a_signature_under_another_key_is_invalid() { + let fixture = Fixture::new(); + let other = Crypto::new().public_key_pem().unwrap(); + let client = new_client(FakeHttp::answering(vec![( + 200, + &fixture.keys_json_with(&other), + )])); + assert_eq!( + client.verify_signature_detailed(&fixture.fod_id).unwrap(), + SignatureCheck::Invalid + ); + assert!(!client.verify_signature(&fixture.fod_id).unwrap()); + } + + #[test] + fn a_key_that_cannot_be_read_is_unusable_not_invalid() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![( + 200, + &fixture.keys_json_with("not a PEM"), + )])); + assert_eq!( + client.verify_signature_detailed(&fixture.fod_id).unwrap(), + SignatureCheck::KeyUnusable + ); + } + + // The online verify call. + + #[test] + fn verify_gets_the_verify_route_with_both_parameter_names() { + let fixture = Fixture::new(); + let http = FakeHttp::answering(vec![(200, r#"{"valid":true}"#)]); + let client = new_client(http.clone()); + assert!(client.verify(&fixture.fod_id).unwrap()); + let requests = http.requests(); + assert_eq!(requests.len(), 1); + let encoded = escape_data_string(&fixture.encoded()); + assert_eq!(requests[0].method, HttpMethod::Get); + assert_eq!( + requests[0].url, + format!( + "{ENDPOINT}id/verify/{}?51did={encoded}&owid={encoded}", + escape_data_string(RESOURCE_KEY) + ) + ); + assert!( + !requests[0].url.contains('+') && !requests[0].url.contains("/?"), + "the base64 is percent-encoded: {}", + requests[0].url + ); + assert!(requests[0].form.is_empty()); + assert_eq!(requests[0].user_agent, USER_AGENT); + } + + #[test] + fn verify_reads_a_false_answer() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![(200, r#"{"valid":false}"#)])); + assert!(!client.verify_encoded(&fixture.encoded()).unwrap()); + } + + #[test] + fn verify_reports_the_service_errors_on_400() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![( + 400, + r#"{"errors":["first problem","second problem"]}"#, + )])); + let error = client.verify_encoded(&fixture.encoded()).unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(ref m) if m == "first problem second problem"), + "{error}" + ); + } + + #[test] + fn verify_treats_any_other_answer_as_unexpected() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![(500, "oops")])); + let error = client.verify(&fixture.fod_id).unwrap_err(); + assert!( + matches!( + error, + Error::UnexpectedStatus { + endpoint: "verify", + status: 500, + .. + } + ), + "{error}" + ); + let client = new_client_with_licence(FakeHttp::answering(vec![(200, "not json")])); + let error = client.verify(&fixture.fod_id).unwrap_err(); + assert!(matches!(error, Error::UnexpectedStatus { .. }), "{error}"); + } + + #[test] + fn a_value_that_is_not_a_51did_is_refused_before_any_call() { + let http = FakeHttp::answering(vec![]); + let client = new_client(http.clone()); + for value in ["", " ", "not base 64!", "AAAA"] { + let error = client.verify_encoded(value).unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(_)), + "{value:?}: {error}" + ); + let error = client.redeem_encoded(value, "sealed", None).unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(_)), + "{value:?}: {error}" + ); + } + let too_long = "A".repeat(MAXIMUM_ENCODED_LENGTH + 1); + let error = client.verify_encoded(&too_long).unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(ref m) if m.contains("too long")), + "{error}" + ); + assert!(http.requests().is_empty(), "nothing was sent"); + } + + // Redeem. + + #[test] + fn redeem_posts_the_form_without_a_licence_field_when_none_was_given() { + let fixture = Fixture::new(); + let http = FakeHttp::answering(vec![( + 200, + r#"{"context":"verified","signature":"verified"}"#, + )]); + let client = new_client(http.clone()); + let result = client.redeem(&fixture.fod_id, "sealed", None).unwrap(); + assert_eq!(result.context(), ContextOutcome::Verified); + let requests = http.requests(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!(request.method, HttpMethod::Post); + assert_eq!(request.url, format!("{ENDPOINT}id/redeem")); + assert!(!request.url.contains('?'), "no credential in the URL"); + assert_eq!(form_value(request, "resource"), Some(RESOURCE_KEY)); + assert_eq!( + form_value(request, "51did"), + Some(fixture.encoded().as_str()) + ); + assert_eq!(form_value(request, "result"), Some("sealed")); + assert_eq!(form_value(request, "challenge"), Some("")); + assert!( + form_value(request, "license").is_none(), + "no licence key, no field" + ); + assert_eq!(request.form.len(), 4); + assert_eq!(request.user_agent, USER_AGENT); + } + + #[test] + fn redeem_carries_the_licence_key_and_challenge_in_the_form_only() { + let fixture = Fixture::new(); + let http = FakeHttp::answering(vec![(200, r#"{"context":"verified"}"#)]); + let client = new_client_with_licence(http.clone()); + client + .redeem_encoded(&fixture.encoded(), "sealed", Some("nonce-1")) + .unwrap(); + let requests = http.requests(); + let request = &requests[0]; + assert_eq!(form_value(request, "license"), Some("licence-value")); + assert_eq!(form_value(request, "challenge"), Some("nonce-1")); + assert_eq!(request.form.len(), 5); + assert!(!request.url.contains("licence-value")); + } + + #[test] + fn redeem_maps_a_mismatch_and_a_misconfigured_factor() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![( + 200, + r#"{"context":"mismatch","signature":"verified", + "factors":{"device":"mismatch","asn":"misconfigured"}}"#, + )])); + let result = client.redeem(&fixture.fod_id, "sealed", None).unwrap(); + assert_eq!(result.context(), ContextOutcome::Mismatch); + let factors = result.factors().unwrap(); + assert_eq!(factors["device"], crate::FactorOutcome::Mismatch); + assert_eq!(factors["asn"], crate::FactorOutcome::Misconfigured); + } + + #[test] + fn redeem_reads_503_as_unconfirmed() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![(503, "")])); + let result = client.redeem(&fixture.fod_id, "sealed", None).unwrap(); + assert_eq!(result.context(), ContextOutcome::Unconfirmed); + assert_eq!(result.status(), 503); + } + + #[test] + fn redeem_reports_the_service_errors_on_400() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![( + 400, + r#"{"errors":["bad 51did"]}"#, + )])); + let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(ref m) if m == "bad 51did"), + "{error}" + ); + // A 400 with no errors array carries the body as the message. + let client = new_client(FakeHttp::answering(vec![(400, "plain refusal")])); + let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(ref m) if m == "plain refusal"), + "{error}" + ); + } + + #[test] + fn redeem_reports_404_as_not_supported() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![(404, "")])); + let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + assert!( + matches!(error, Error::NotSupported(ref e) if e == ENDPOINT), + "{error}" + ); + } + + #[test] + fn redeem_treats_any_other_status_as_unexpected() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::answering(vec![(502, "gateway")])); + let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + assert!( + matches!( + error, + Error::UnexpectedStatus { + endpoint: "redeem", + status: 502, + .. + } + ), + "{error}" + ); + } + + #[test] + fn redeem_reports_a_transport_failure() { + let fixture = Fixture::new(); + let client = new_client(FakeHttp::failing("timed out")); + let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + assert!(matches!(error, Error::Transport(_)), "{error}"); + } + + // Helpers. + + #[test] + fn escaping_leaves_only_the_unreserved_characters() { + assert_eq!(escape_data_string("AZaz09-_.~"), "AZaz09-_.~"); + assert_eq!(escape_data_string("a+b/c=d e&f"), "a%2Bb%2Fc%3Dd%20e%26f"); + assert_eq!(escape_data_string("é"), "%C3%A9"); + } + + #[test] + fn errors_are_joined_and_non_strings_kept_as_json() { + assert_eq!( + read_errors(r#"{"errors":["a",{"code":1}]}"#).as_deref(), + Some(r#"a {"code":1}"#) + ); + assert!(read_errors(r#"{"errors":[]}"#).is_none()); + assert!(read_errors(r#"{"other":1}"#).is_none()); + assert!(read_errors("nope").is_none()); + } + + #[test] + fn the_client_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} diff --git a/fodid-client/src/error.rs b/fodid-client/src/error.rs new file mode 100644 index 0000000..8385bba --- /dev/null +++ b/fodid-client/src/error.rs @@ -0,0 +1,87 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +//! What can go wrong, and whose problem each thing is. + +/// The result type used throughout this crate. +pub type Result = std::result::Result; + +/// Why a call did not produce an answer. +/// +/// Kept separate from the answers themselves on purpose. A redemption that +/// comes back `mismatch`, or a signature that does not verify, is an answer +/// and is returned as one. These are the cases where there was no answer to +/// return. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The value given is not something this client will send, being empty, + /// too long to be an identifier, or not a 51Did at all. Named for what it + /// is here rather than sent to the service to be refused there. + #[error("invalid argument: {0}")] + InvalidArgument(String), + + /// The request did not complete. The service could not be reached, the + /// connection failed or timed out, or the answer could not be read. + #[error("transport: {0}")] + Transport(String), + + /// The service answered with a status this client did not expect for that + /// endpoint, carrying the status and the start of the body. + #[error("the 51Did {endpoint} endpoint answered {status}: {body}")] + UnexpectedStatus { + /// Which endpoint answered. + endpoint: &'static str, + /// The HTTP status. + status: u16, + /// The start of the body, truncated. + body: String, + }, + + /// The service answered in a shape this client could not read, for + /// example a key list that is not a JSON array. + #[error("protocol: {0}")] + Protocol(String), + + /// The service at this endpoint does not support the 51Did creator + /// context, answering the redeem endpoint with 404. + #[error("the service at {0} does not support the 51Did creator context")] + NotSupported(String), + + /// The signing key published for the identifier's date could not be used + /// to verify, for example because it is not a key this build can read. + #[error("the published signing key could not be used: {0}")] + KeyUnusable(String), +} + +impl Error { + /// Cuts a body down to something that fits in an error message. + pub(crate) fn truncate(body: &str) -> String { + const LIMIT: usize = 200; + if body.chars().count() <= LIMIT { + body.to_string() + } else { + let mut out: String = body.chars().take(LIMIT).collect(); + out.push_str("..."); + out + } + } +} diff --git a/fodid-client/src/http.rs b/fodid-client/src/http.rs new file mode 100644 index 0000000..1319ba7 --- /dev/null +++ b/fodid-client/src/http.rs @@ -0,0 +1,142 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +//! The one HTTP operation the client needs, and the built-in transport. + +use std::time::Duration; + +/// The HTTP method used for a request to the 51Did endpoints. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HttpMethod { + /// An HTTP GET, used for the key and verify endpoints. + Get, + /// An HTTP POST, used for redeem, which reads its parameters from a + /// url-encoded form body so no credential is ever written to an access + /// log. + Post, +} + +/// A single request the client asks a [`DidHttpClient`] to perform. +#[derive(Debug, Clone)] +pub struct DidHttpRequest { + /// The HTTP method. + pub method: HttpMethod, + /// The absolute URL to request. + pub url: String, + /// The url-encoded form fields to send as the POST body, empty for a + /// GET. The transport is responsible for url-encoding these. + pub form: Vec<(String, String)>, + /// The `User-Agent` to send, naming this package and its version. + pub user_agent: String, +} + +/// Whatever the server answered, whatever the status. +#[derive(Debug, Clone)] +pub struct DidHttpResponse { + /// The HTTP status code. + pub status: u16, + /// The response body, read as text. + pub body: String, +} + +/// The transport the client sends through. +/// +/// Implemented so that a test can stand in for the network and a caller can +/// route the client's requests through an HTTP stack of its own. That second +/// case is not hypothetical: this crate has to build for `wasm32-wasip1`, +/// where there is no `reqwest`, and a host such as an edge runtime supplies +/// its own fetch. +/// +/// Implementations MUST be `Send + Sync`, so one client can serve many +/// threads, which is the same rule the cloud request engine's transport +/// carries. +pub trait DidHttpClient: Send + Sync { + /// Sends the request and returns whatever the server answered, whatever + /// the status. + /// + /// Return `Err` with a human readable message ONLY when the request did + /// not complete, being a connection failure, a timeout, or an answer + /// that could not be read. A status the caller did not want is still a + /// completed request and comes back as `Ok`, because the client decides + /// what each status means and says so in its own words. + fn send(&self, request: &DidHttpRequest) -> Result; +} + +/// The built-in [`DidHttpClient`], backed by a blocking [`reqwest`] client. +/// +/// Compiled only with the `reqwest-client` feature, which is off by default +/// so the crate builds for `wasm32-wasip1` and so a caller that supplies its +/// own transport pulls in no HTTP stack it does not want. +#[cfg(feature = "reqwest-client")] +pub struct ReqwestClient { + client: reqwest::blocking::Client, +} + +#[cfg(feature = "reqwest-client")] +impl ReqwestClient { + /// Creates a client with the given request timeout. A zero timeout means + /// no timeout. + pub fn new(timeout: Duration) -> Result { + let mut builder = reqwest::blocking::Client::builder(); + if !timeout.is_zero() { + builder = builder.timeout(timeout); + } + let client = builder + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + Ok(ReqwestClient { client }) + } +} + +#[cfg(feature = "reqwest-client")] +impl Default for ReqwestClient { + /// A client with the default thirty second timeout. + fn default() -> Self { + Self::new(Duration::from_secs(30)).expect("the default HTTP client builds") + } +} + +#[cfg(feature = "reqwest-client")] +impl DidHttpClient for ReqwestClient { + fn send(&self, request: &DidHttpRequest) -> Result { + let builder = match request.method { + HttpMethod::Get => self.client.get(&request.url), + HttpMethod::Post => self.client.post(&request.url).form(&request.form), + }; + let response = builder + .header("User-Agent", &request.user_agent) + .send() + .map_err(|e| format!("failed to send request to '{}': {e}", request.url))?; + let status = response.status().as_u16(); + let body = response + .text() + .map_err(|e| format!("failed to read the answer from '{}': {e}", request.url))?; + Ok(DidHttpResponse { status, body }) + } +} + +// `Duration` is used by the reqwest constructor only, so without that feature +// the import would be dead. Naming it here keeps one import line rather than +// a cfg on the use statement. +#[cfg(not(feature = "reqwest-client"))] +#[allow(dead_code)] +type UnusedDuration = Duration; diff --git a/fodid-client/src/key.rs b/fodid-client/src/key.rs new file mode 100644 index 0000000..31c90e6 --- /dev/null +++ b/fodid-client/src/key.rs @@ -0,0 +1,231 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +//! The published signing keys, and choosing the one an identifier was made +//! under. + +use chrono::{DateTime, Duration, Utc}; + +use crate::error::{Error, Result}; + +/// One published signing key and the moment it comes into force. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DidPublicKey { + starts_at: DateTime, + public_key_pem: String, +} + +impl DidPublicKey { + /// Creates a key entry. + pub fn new(starts_at: DateTime, public_key_pem: impl Into) -> Self { + Self { + starts_at, + public_key_pem: public_key_pem.into(), + } + } + + /// The moment this key comes into force. It stays in force until the next + /// entry starts. + pub fn starts_at(&self) -> DateTime { + self.starts_at + } + + /// The public key in SPKI PEM form, as the OWID verification takes it. + pub fn public_key_pem(&self) -> &str { + &self.public_key_pem + } +} + +/// How far either side of a boundary a creation moment is still treated as +/// belonging to the neighbouring key. +/// +/// A creating and a verifying node do not share a clock, so an identifier made +/// within a few minutes of a boundary can be dated on one side by one and the +/// other side by the other. Trying the neighbour is what stops ordinary skew +/// reading as a bad signature. +pub const BOUNDARY_TOLERANCE_MINUTES: i64 = 15; + +/// The key in force at the given moment, being the entry whose start is latest +/// on or before it, or `None` when the moment precedes the whole schedule. +/// +/// The keys need not be sorted. +pub fn in_force_at(keys: &[DidPublicKey], at: DateTime) -> Option<&DidPublicKey> { + keys.iter() + .filter(|k| k.starts_at <= at) + .max_by_key(|k| k.starts_at) +} + +/// The keys to try for the given moment, best first. +/// +/// That is the key in force at the moment, followed by a neighbouring entry +/// only where the moment sits within [`BOUNDARY_TOLERANCE_MINUTES`] of it. +/// Progressively older keys are NOT tried, because trying every key held would +/// turn a signature made under a key nobody holds into a signature that +/// eventually matches something. +pub fn candidates_for_date(keys: &[DidPublicKey], at: DateTime) -> Vec<&DidPublicKey> { + let tolerance = Duration::minutes(BOUNDARY_TOLERANCE_MINUTES); + let mut out: Vec<&DidPublicKey> = Vec::with_capacity(2); + for candidate in [ + in_force_at(keys, at), + in_force_at(keys, at - tolerance), + in_force_at(keys, at + tolerance), + ] + .into_iter() + .flatten() + { + if !out.iter().any(|k| std::ptr::eq(*k, candidate)) { + out.push(candidate); + } + } + out +} + +/// Reads the key endpoint's answer, which is a JSON array of entries carrying +/// `startsAt` (or `created`, the older spelling) and `publicKey`. +/// +/// The result is sorted by start, so [`in_force_at`] and +/// [`candidates_for_date`] read it in the order they expect however the +/// service happened to order it. +pub fn parse_keys(json: &str) -> Result> { + let value: serde_json::Value = serde_json::from_str(json).map_err(|e| { + Error::Protocol(format!( + "the 51Did key endpoint did not answer with JSON: {e}" + )) + })?; + let array = value.as_array().ok_or_else(|| { + Error::Protocol("the 51Did key endpoint did not answer with a JSON array".to_string()) + })?; + + let mut keys = Vec::with_capacity(array.len()); + for entry in array { + let start = entry + .get("startsAt") + .and_then(|v| v.as_str()) + .or_else(|| entry.get("created").and_then(|v| v.as_str())); + let pem = entry.get("publicKey").and_then(|v| v.as_str()); + match (start, pem) { + (Some(start), Some(pem)) => { + keys.push(DidPublicKey::new(parse_utc(start)?, pem)); + } + _ => { + return Err(Error::Protocol( + "a 51Did key entry lacks its start or its public key".to_string(), + )) + } + } + } + keys.sort_by_key(|k| k.starts_at); + Ok(keys) +} + +/// Reads one of the timestamp forms the key endpoint uses. +pub(crate) fn parse_utc(value: &str) -> Result> { + if let Ok(parsed) = DateTime::parse_from_rfc3339(value) { + return Ok(parsed.with_timezone(&Utc)); + } + // The endpoint has also written a bare "YYYY-MM-DDTHH:MM:SS" with no zone, + // which is UTC by the service's own definition. + chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S") + .map(|naive| naive.and_utc()) + .map_err(|_| Error::Protocol(format!("'{value}' is not a time this client can read"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(day: u32) -> DateTime { + chrono::NaiveDate::from_ymd_opt(2026, 9, day) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + } + + fn schedule() -> Vec { + vec![ + DidPublicKey::new(at(1), "first"), + DidPublicKey::new(at(8), "second"), + DidPublicKey::new(at(15), "third"), + ] + } + + #[test] + fn in_force_takes_the_latest_start_on_or_before() { + let keys = schedule(); + assert_eq!( + in_force_at(&keys, at(10)).unwrap().public_key_pem(), + "second" + ); + assert_eq!( + in_force_at(&keys, at(8)).unwrap().public_key_pem(), + "second" + ); + } + + #[test] + fn a_date_before_the_schedule_has_no_key() { + assert!(in_force_at(&schedule(), at(1) - Duration::days(1)).is_none()); + } + + #[test] + fn a_moment_at_a_boundary_tries_both_sides() { + let keys = schedule(); + let candidates = candidates_for_date(&keys, at(8)); + assert_eq!(candidates.len(), 2, "the neighbour is tried too"); + assert_eq!(candidates[0].public_key_pem(), "second", "best first"); + assert_eq!(candidates[1].public_key_pem(), "first"); + } + + #[test] + fn a_moment_well_inside_a_period_tries_one() { + let keys = schedule(); + assert_eq!(candidates_for_date(&keys, at(10)).len(), 1); + } + + #[test] + fn keys_are_read_and_sorted() { + let keys = parse_keys( + r#"[{"startsAt":"2026-09-08T00:00:00Z","publicKey":"b"}, + {"startsAt":"2026-09-01T00:00:00Z","publicKey":"a"}]"#, + ) + .unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].public_key_pem(), "a", "sorted by start"); + } + + #[test] + fn the_older_created_spelling_is_read() { + let keys = parse_keys(r#"[{"created":"2026-09-01T00:00:00Z","publicKey":"a"}]"#).unwrap(); + assert_eq!(keys[0].starts_at(), at(1)); + } + + #[test] + fn an_entry_missing_its_key_is_refused() { + assert!(parse_keys(r#"[{"startsAt":"2026-09-01T00:00:00Z"}]"#).is_err()); + } + + #[test] + fn an_answer_that_is_not_an_array_is_refused() { + assert!(parse_keys(r#"{"startsAt":"2026-09-01T00:00:00Z"}"#).is_err()); + } +} diff --git a/fodid-client/src/lib.rs b/fodid-client/src/lib.rs new file mode 100644 index 0000000..31d9fea --- /dev/null +++ b/fodid-client/src/lib.rs @@ -0,0 +1,161 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +//! [![51Degrees](https://51degrees.com/img/logo.png?utm_source=docs.rs&utm_medium=docs&utm_campaign=rust&utm_content=fodid-client-lib.rs&utm_term=logo "Data rewards the curious")](https://51degrees.com/?utm_source=docs.rs&utm_medium=docs&utm_campaign=rust&utm_content=fodid-client-lib.rs&utm_term=logo) +//! +//! # 51Degrees identifier (51Did) client +//! +//! The server side of the 51Did two-step verification against the +//! 51Degrees cloud, and the Rust port of the client the .NET, Java, Node, +//! Python and PHP packages already carry. It fetches and caches the +//! published signing keys, verifies a 51Did 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 a 51Did is the [`fodid`] crate's job, and this crate builds on +//! it. Creating one is not part of either, because a 51Did is created from +//! the browser through the cloud `json` endpoint, since the identifier +//! describes the browser's own connection. +//! +//! ## The two steps +//! +//! A 51Did carries a creator context, being a record of the connection it +//! was created on. Checking that the identifier is being presented from +//! that same connection takes two steps, and the split exists so that the +//! account's licence key never reaches the browser. +//! +//! 1. **The browser verifies.** The page calls the cloud's `verify-context` +//! (or `verify-full`) endpoint from the browser, so the cloud sees the +//! browser's own connection and compares it with the context inside the +//! identifier. The cloud answers with a sealed result, which the browser +//! cannot read or alter, and the page relays that result to its own +//! server. +//! 2. **The server redeems.** The server calls [`DidClient::redeem`] with +//! the identifier it knows independently, the sealed result the browser +//! relayed, and the licence key only the server holds. The cloud opens +//! the seal, confirms the result is for that identifier, is fresh and +//! has not been redeemed before, and answers with a [`RedeemResult`] +//! carrying the [`ContextOutcome`] and, for a mismatch, the +//! [`FactorOutcome`] of each factor. +//! +//! One rule matters more than the rest, and every 51Did package applies it. +//! A factor of `misconfigured` is read on its own as +//! [`FactorOutcome::Misconfigured`] and never falls through to a mismatch, +//! because it says the checking service could not determine that factor, +//! and reading it as a mismatch would report a replay indicator for +//! something the identifier says nothing about. +//! +//! ## Signature checks without the cloud +//! +//! The cloud publishes the schedule of signing keys, each in force from its +//! start until the next one starts. [`DidClient::verify_signature`] fetches +//! that schedule once a day, keeps it in a per-instance cache, and checks an +//! identifier's signature against the key in force at its creation time +//! without a cloud call. [`DidClient::verify_signature_detailed`] says why a +//! check did not pass, as a [`SignatureCheck`], and only +//! [`SignatureCheck::Invalid`] means the identifier should be distrusted. +//! +//! ## Transport +//! +//! Every request goes through the [`DidHttpClient`] trait. The crate builds +//! without any network stack by default, so it compiles for +//! `wasm32-wasip1` and a host such as an edge runtime supplies its own +//! transport through [`DidClientBuilder::http_client`]. The `reqwest-client` +//! feature turns on the built-in `ReqwestClient`, a blocking `reqwest` +//! client, which the builder uses when no transport is given. +//! +//! Credentials never travel in a URL. The resource key is part of the +//! route, as the endpoints accept, and the licence key travels only in the +//! redeem form body, because a query string is written to access logs. +//! +//! ## Example +//! +//! ```no_run +//! use std::sync::Arc; +//! use fodid::FodId; +//! use fodid_client::{ContextOutcome, DidClient, DidHttpClient}; +//! +//! # fn run( +//! # transport: Arc, +//! # encoded_51did: &str, +//! # sealed_result: &str, +//! # ) -> Result<(), Box> { +//! // One client for the process. The licence key stays on the server. +//! let client = DidClient::builder("your-resource-key") +//! .licence_key("your-licence-key") +//! .http_client(transport) +//! .build()?; +//! +//! // The identifier the server knows independently, for example from a +//! // cookie it set when the identifier was created. +//! let fod_id = FodId::from_base64(encoded_51did)?; +//! +//! // Step one happened in the browser. Step two is the redemption. +//! let outcome = client.redeem(&fod_id, sealed_result, None)?; +//! match outcome.context() { +//! ContextOutcome::Verified => { /* same connection as at creation */ } +//! ContextOutcome::Mismatch => { +//! // outcome.factors() names the factors that differ. +//! } +//! ContextOutcome::Misconfigured => { +//! // The checking service, not the identifier, is at fault. +//! } +//! other => { /* see ContextOutcome for the rest */ let _ = other; } +//! } +//! # Ok(()) +//! # } +//! ``` + +#![warn(missing_docs)] + +mod client; +mod error; +mod http; +mod key; +mod outcome; +mod redeem; + +pub use client::{ + DidClient, DidClientBuilder, DEFAULT_ENDPOINT, ENDPOINT_ENVIRONMENT_VARIABLE, + KEY_CACHE_LIFETIME, MAXIMUM_ENCODED_LENGTH, USER_AGENT, +}; +pub use error::{Error, Result}; +pub use http::{DidHttpClient, DidHttpRequest, DidHttpResponse, HttpMethod}; +pub use key::{ + candidates_for_date, in_force_at, parse_keys, DidPublicKey, BOUNDARY_TOLERANCE_MINUTES, +}; +pub use outcome::{ContextOutcome, FactorOutcome, SignatureCheck, SignatureOutcome}; +pub use redeem::RedeemResult; + +#[cfg(feature = "reqwest-client")] +pub use http::ReqwestClient; + +// The 51Did reader this client builds on, re-exported so a caller can name +// `FodId` without adding the dependency itself. +pub use fodid; + +/// The examples in the README are compiled as documentation tests, so the +/// documented way to use this crate cannot quietly stop working. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeExamples; diff --git a/fodid-client/src/outcome.rs b/fodid-client/src/outcome.rs new file mode 100644 index 0000000..481b948 --- /dev/null +++ b/fodid-client/src/outcome.rs @@ -0,0 +1,208 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +//! The words a redemption answers with, and what each one means. + +/// The creator context verdict a redemption reports, mapped from the +/// `context` string the cloud sends. +/// +/// Some describe the identifier, one describes the service that checked it, +/// and the rest describe the redemption itself, being why no verdict could be +/// read this time. A string this build does not know maps to +/// [`ContextOutcome::Unreadable`], failing closed, and the raw value is kept +/// on [`RedeemResult::context_value`](crate::RedeemResult::context_value). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ContextOutcome { + /// Every factor matched the connection the identifier was verified on. + Verified, + /// At least one factor did not match, and + /// [`RedeemResult::factors`](crate::RedeemResult::factors) says which. An + /// identifier whose signature verifies is still genuine, so this reports + /// a moved identifier rather than a bad one. + Mismatch, + /// The identifier carries no creator context at all. + NoContext, + /// No longer reported by the service, and kept only because it has been + /// part of this vocabulary since the other packages gained it. What used + /// to give this answer now gives [`ContextOutcome::Misconfigured`] where + /// the service is at fault, or [`ContextOutcome::InvalidDate`] where the + /// identifier could not have been created. + NotCheckable, + /// The sealed result was presented after the service's freshness window + /// closed. + Expired, + /// The sealed result had already been redeemed on this service instance. + Replayed, + /// The sealed result could not be read. Every cryptographic failure gives + /// this one answer by design, so nothing finer is available, and a + /// `context` string this client does not recognise maps here too. + Unreadable, + /// The service could not confirm first use of the sealed result and + /// answered 503. Not a verdict. The caller may retry. + Unconfirmed, + /// The service that checked the identifier could not complete the check, + /// and the reason is that service rather than the identifier. Either it + /// compared nothing, or it compared some factors and reports at least one + /// as [`FactorOutcome::Misconfigured`]. + /// + /// Nothing a caller sends can produce this, so it is a signal about the + /// deployment. Against 51Degrees public cloud it should not occur. + /// Against a self-hosted service it means that service is not reading the + /// client's own connection, or is missing an engine it needs, and its own + /// logs name the setting to change. + Misconfigured, + /// The identifier's creation date is one the scheme could not have + /// produced, being in the future or before the creator context scheme + /// began. Nothing can be created in the future and nothing existed before + /// the first key, so this says the identifier is fabricated rather than + /// that anything is wrong with the service. + InvalidDate, +} + +impl ContextOutcome { + /// Maps the cloud's `context` string, answering + /// [`ContextOutcome::Unreadable`] for anything not known, including an + /// absent value, so an answer this client does not understand fails + /// closed. + pub fn from_cloud(value: Option<&str>) -> Self { + match value { + Some("verified") => Self::Verified, + Some("mismatch") => Self::Mismatch, + Some("nocontext") => Self::NoContext, + Some("notcheckable") => Self::NotCheckable, + Some("misconfigured") => Self::Misconfigured, + Some("invaliddate") => Self::InvalidDate, + Some("expired") => Self::Expired, + Some("replayed") => Self::Replayed, + Some("unconfirmed") => Self::Unconfirmed, + _ => Self::Unreadable, + } + } + + /// The word the cloud uses for this outcome, the inverse of + /// [`ContextOutcome::from_cloud`]. + pub fn as_cloud(self) -> &'static str { + match self { + Self::Verified => "verified", + Self::Mismatch => "mismatch", + Self::NoContext => "nocontext", + Self::NotCheckable => "notcheckable", + Self::Misconfigured => "misconfigured", + Self::InvalidDate => "invaliddate", + Self::Expired => "expired", + Self::Replayed => "replayed", + Self::Unconfirmed => "unconfirmed", + Self::Unreadable => "unreadable", + } + } +} + +/// The outcome of one creator context factor, reported when the context is +/// [`ContextOutcome::Mismatch`] or [`ContextOutcome::Misconfigured`]. +/// +/// The factor names are `transport`, `device`, `browserip`, `connectionip`, +/// `asn` and `browser`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FactorOutcome { + /// The factor matched the verifying connection. + Verified, + /// The factor did not match the verifying connection. + Mismatch, + /// The service that checked the identifier is not configured to determine + /// this factor, so it could not have checked it for any request. + /// + /// This is NOT a mismatch and must not be read as one, since the + /// identifier says nothing about it either way. Nothing a caller sends + /// can produce it. + Misconfigured, +} + +impl FactorOutcome { + /// Maps the cloud's factor string. + /// + /// `misconfigured` is read on its own, because it is the one value that + /// must NOT fall through to a mismatch. It says the checking service + /// could not determine that factor, so reading it as a mismatch would + /// report a replay indicator for something the identifier says nothing + /// about. Everything else that is not the one word `verified` is a + /// mismatch, so an unexpected value never reads as a pass. + pub fn from_cloud(value: Option<&str>) -> Self { + match value { + Some("verified") => Self::Verified, + Some("misconfigured") => Self::Misconfigured, + _ => Self::Mismatch, + } + } + + /// The word the cloud uses for this outcome. + pub fn as_cloud(self) -> &'static str { + match self { + Self::Verified => "verified", + Self::Mismatch => "mismatch", + Self::Misconfigured => "misconfigured", + } + } +} + +/// The signature outcome of a redemption, reported in the `signature` field. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SignatureOutcome { + /// The identifier's signature is genuine. + Verified, + /// The signature did not verify. + Invalid, + /// The cloud did not report the signature, as on an expired result. + Unknown, +} + +impl SignatureOutcome { + /// Maps the cloud's `signature` string, answering + /// [`SignatureOutcome::Unknown`] for anything not known. + pub fn from_cloud(value: Option<&str>) -> Self { + match value { + Some("verified") => Self::Verified, + Some("invalid") => Self::Invalid, + _ => Self::Unknown, + } + } +} + +/// Why an offline signature check answered as it did. +/// +/// Separated from a plain boolean because "no key covers this date" and "the +/// signature does not match" are different problems with different remedies, +/// and only the second says anything about the identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SignatureCheck { + /// The signature is genuine under the key in force when the identifier + /// was created. + Verified, + /// The signature did not match. The one answer that means the identifier + /// should be distrusted. + Invalid, + /// No published key covers the identifier's creation time, so nothing was + /// checked. An operational matter to log, never a fraud signal. + NoKey, + /// A key was found and could not be used, for example because the + /// published value is not a key this build can read. + KeyUnusable, +} diff --git a/fodid-client/src/redeem.rs b/fodid-client/src/redeem.rs new file mode 100644 index 0000000..12e7179 --- /dev/null +++ b/fodid-client/src/redeem.rs @@ -0,0 +1,344 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +//! The typed answer from the redeem endpoint. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; + +use crate::key::parse_utc; +use crate::outcome::{ContextOutcome, FactorOutcome, SignatureOutcome}; + +/// The typed answer from the cloud's redeem endpoint, built by +/// [`DidClient::redeem`](crate::DidClient::redeem) from the JSON body. +/// +/// [`RedeemResult::body`] keeps the body as received and +/// [`RedeemResult::status`] the HTTP status, so nothing the cloud said is +/// lost in the mapping. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RedeemResult { + context: ContextOutcome, + context_value: Option, + signature: SignatureOutcome, + factors: Option>, + verified_at: Option>, + seconds_since_verified: Option, + status: u16, + body: String, +} + +impl RedeemResult { + /// Creates a result from its parts. Callers normally get one from + /// [`RedeemResult::from_response`] rather than building one. + #[allow(clippy::too_many_arguments)] + pub fn new( + context: ContextOutcome, + context_value: Option, + signature: SignatureOutcome, + factors: Option>, + verified_at: Option>, + seconds_since_verified: Option, + status: u16, + body: impl Into, + ) -> Self { + Self { + context, + context_value, + signature, + factors, + verified_at, + seconds_since_verified, + status, + body: body.into(), + } + } + + /// Builds a result from a redeem response. + /// + /// A 503 is [`ContextOutcome::Unconfirmed`] whatever the body says, + /// because the status is the service's own statement that it could not + /// confirm first use. Otherwise the body is read as a JSON object, and a + /// body that is not one, or carries no `context`, gives + /// [`ContextOutcome::Unreadable`] with the body kept in + /// [`RedeemResult::body`]. Factor values are read with + /// [`FactorOutcome::from_cloud`], so `misconfigured` never falls through + /// to a mismatch. + pub fn from_response(status: u16, body: &str) -> Self { + let value: serde_json::Value = match serde_json::from_str(body) { + Ok(value) => value, + Err(_) => return Self::unreadable(status, body), + }; + let Some(root) = value.as_object() else { + return Self::unreadable(status, body); + }; + let context_value = read_string(root, "context"); + let context = if status == 503 { + ContextOutcome::Unconfirmed + } else { + ContextOutcome::from_cloud(context_value) + }; + let factors = root + .get("factors") + .and_then(|f| f.as_object()) + .map(|object| { + object + .iter() + .map(|(name, value)| (name.clone(), FactorOutcome::from_cloud(value.as_str()))) + .collect::>() + }); + let verified_at = read_string(root, "verifiedAt").and_then(|v| parse_utc(v).ok()); + let seconds_since_verified = root.get("secondsSinceVerified").and_then(|v| v.as_i64()); + Self { + context, + context_value: context_value.map(str::to_owned), + signature: SignatureOutcome::from_cloud(read_string(root, "signature")), + factors, + verified_at, + seconds_since_verified, + status, + body: body.to_owned(), + } + } + + fn unreadable(status: u16, body: &str) -> Self { + Self { + context: if status == 503 { + ContextOutcome::Unconfirmed + } else { + ContextOutcome::Unreadable + }, + context_value: None, + signature: SignatureOutcome::Unknown, + factors: None, + verified_at: None, + seconds_since_verified: None, + status, + body: body.to_owned(), + } + } + + /// The creator context verdict, mapped from the `context` string. A + /// string this client does not recognise maps to + /// [`ContextOutcome::Unreadable`], so an unexpected answer never reads + /// as a pass. + pub fn context(&self) -> ContextOutcome { + self.context + } + + /// The `context` string exactly as the cloud sent it, or `None` when + /// the body carried none. + pub fn context_value(&self) -> Option<&str> { + self.context_value.as_deref() + } + + /// The signature outcome, mapped from the `signature` string. + /// [`SignatureOutcome::Unknown`] when the field is absent, which it is + /// on every outcome other than a redeemed verdict. + pub fn signature(&self) -> SignatureOutcome { + self.signature + } + + /// The outcome of each creator context factor by name (`transport`, + /// `device`, `browserip`, `connectionip`, `asn`, `browser`), present + /// only when the cloud sent `factors`, which it does for a + /// [`ContextOutcome::Mismatch`] and for a + /// [`ContextOutcome::Misconfigured`] where some factors were compared. + pub fn factors(&self) -> Option<&HashMap> { + self.factors.as_ref() + } + + /// When the verify endpoint checked the context and sealed the result, + /// UTC. Present on the redeemed and expired outcomes. + pub fn verified_at(&self) -> Option> { + self.verified_at + } + + /// How long before this redemption the verification happened, in whole + /// seconds by the cloud's clock. Present on the redeemed and expired + /// outcomes. + pub fn seconds_since_verified(&self) -> Option { + self.seconds_since_verified + } + + /// The HTTP status the cloud answered with, 200 for every verdict and + /// 503 for [`ContextOutcome::Unconfirmed`]. + pub fn status(&self) -> u16 { + self.status + } + + /// The response body as received. + pub fn body(&self) -> &str { + &self.body + } +} + +fn read_string<'a>( + object: &'a serde_json::Map, + name: &str, +) -> Option<&'a str> { + object.get(name).and_then(|v| v.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_verified_answer_is_read_in_full() { + let result = RedeemResult::from_response( + 200, + r#"{"context":"verified","signature":"verified", + "verifiedAt":"2026-09-03T10:15:30Z","secondsSinceVerified":12}"#, + ); + assert_eq!(result.context(), ContextOutcome::Verified); + assert_eq!(result.context_value(), Some("verified")); + assert_eq!(result.signature(), SignatureOutcome::Verified); + assert!(result.factors().is_none()); + assert_eq!( + result.verified_at().map(|d| d.to_rfc3339()), + Some("2026-09-03T10:15:30+00:00".to_string()) + ); + assert_eq!(result.seconds_since_verified(), Some(12)); + assert_eq!(result.status(), 200); + assert!(result.body().contains("verified")); + } + + #[test] + fn a_mismatch_names_its_factors() { + let result = RedeemResult::from_response( + 200, + r#"{"context":"mismatch","signature":"verified", + "factors":{"transport":"verified","device":"mismatch", + "browserip":"verified"}}"#, + ); + assert_eq!(result.context(), ContextOutcome::Mismatch); + let factors = result.factors().expect("factors are present"); + assert_eq!(factors.len(), 3); + assert_eq!(factors["transport"], FactorOutcome::Verified); + assert_eq!(factors["device"], FactorOutcome::Mismatch); + } + + #[test] + fn a_misconfigured_factor_is_not_a_mismatch() { + let result = RedeemResult::from_response( + 200, + r#"{"context":"misconfigured", + "factors":{"transport":"misconfigured","device":"verified"}}"#, + ); + assert_eq!(result.context(), ContextOutcome::Misconfigured); + let factors = result.factors().expect("factors are present"); + assert_eq!( + factors["transport"], + FactorOutcome::Misconfigured, + "the checking service could not determine this factor, which \ + says nothing about the identifier" + ); + assert_ne!(factors["transport"], FactorOutcome::Mismatch); + assert_eq!(factors["device"], FactorOutcome::Verified); + assert!( + !factors.values().any(|f| *f == FactorOutcome::Mismatch), + "nothing here is a replay indicator" + ); + } + + #[test] + fn an_unknown_factor_value_is_a_mismatch_not_a_pass() { + let result = RedeemResult::from_response( + 200, + r#"{"context":"mismatch","factors":{"asn":"something-new"}}"#, + ); + assert_eq!(result.factors().unwrap()["asn"], FactorOutcome::Mismatch); + } + + #[test] + fn invaliddate_is_read_and_carries_no_factors() { + let result = RedeemResult::from_response(200, r#"{"context":"invaliddate"}"#); + assert_eq!(result.context(), ContextOutcome::InvalidDate); + assert_eq!(result.context_value(), Some("invaliddate")); + assert!(result.factors().is_none()); + assert_eq!(result.signature(), SignatureOutcome::Unknown); + assert!(result.verified_at().is_none()); + assert!(result.seconds_since_verified().is_none()); + } + + #[test] + fn a_503_is_unconfirmed() { + let result = RedeemResult::from_response(503, r#"{"context":"unconfirmed"}"#); + assert_eq!(result.context(), ContextOutcome::Unconfirmed); + assert_eq!(result.status(), 503); + + let empty = RedeemResult::from_response(503, ""); + assert_eq!(empty.context(), ContextOutcome::Unconfirmed); + assert_eq!(empty.body(), ""); + } + + #[test] + fn unreadable_json_is_unreadable() { + let result = RedeemResult::from_response(200, "not json"); + assert_eq!(result.context(), ContextOutcome::Unreadable); + assert!(result.context_value().is_none()); + assert_eq!(result.body(), "not json"); + + let array = RedeemResult::from_response(200, "[1,2,3]"); + assert_eq!(array.context(), ContextOutcome::Unreadable); + } + + #[test] + fn a_missing_or_unknown_context_is_unreadable() { + let missing = RedeemResult::from_response(200, r#"{"signature":"verified"}"#); + assert_eq!(missing.context(), ContextOutcome::Unreadable); + assert!(missing.context_value().is_none()); + assert_eq!(missing.signature(), SignatureOutcome::Verified); + + let unknown = RedeemResult::from_response(200, r#"{"context":"brand-new"}"#); + assert_eq!(unknown.context(), ContextOutcome::Unreadable); + assert_eq!( + unknown.context_value(), + Some("brand-new"), + "the raw word is kept" + ); + } + + #[test] + fn expired_carries_when_and_how_long_ago() { + let result = RedeemResult::from_response( + 200, + r#"{"context":"expired","verifiedAt":"2026-09-03T10:15:30Z", + "secondsSinceVerified":900}"#, + ); + assert_eq!(result.context(), ContextOutcome::Expired); + assert!(result.verified_at().is_some()); + assert_eq!(result.seconds_since_verified(), Some(900)); + } + + #[test] + fn a_verified_at_that_cannot_be_read_is_absent() { + let result = RedeemResult::from_response( + 200, + r#"{"context":"verified","verifiedAt":"yesterday", + "secondsSinceVerified":"soon"}"#, + ); + assert!(result.verified_at().is_none()); + assert!(result.seconds_since_verified().is_none()); + } +} From 247e4cf22fa3791b0be8db0d2255945b0eabf7a8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 6 Sep 2026 21:32:22 +0100 Subject: [PATCH 2/2] FEAT: Make the 51Did client asynchronous with a non-Send transport 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. --- Cargo.lock | 1 + fodid-client/Cargo.toml | 18 +- fodid-client/README.md | 54 ++-- fodid-client/src/client.rs | 559 ++++++++++++++++++++++++++++--------- fodid-client/src/http.rs | 109 ++++++-- fodid-client/src/lib.rs | 29 +- 6 files changed, 574 insertions(+), 196 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6fe661b..6a29ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -785,6 +785,7 @@ dependencies = [ "reqwest", "serde_json", "thiserror", + "tokio", ] [[package]] diff --git a/fodid-client/Cargo.toml b/fodid-client/Cargo.toml index 7d34d66..8acab45 100644 --- a/fodid-client/Cargo.toml +++ b/fodid-client/Cargo.toml @@ -12,10 +12,10 @@ repository.workspace = true homepage.workspace = true [features] -# Compile the built-in blocking reqwest transport (ReqwestClient). Off by -# default so the crate builds without reqwest, and so for wasm32-wasip1 where -# an edge runtime supplies its own DidHttpClient. Native consumers enable this -# to use the built-in client. +# Compile the built-in asynchronous reqwest transport (ReqwestClient). Off +# by default so the crate builds without reqwest, and so for wasm32-wasip1 +# where an edge runtime supplies its own DidHttpClient. Native consumers +# enable this to use the built-in client from a tokio runtime. reqwest-client = ["dep:reqwest"] [dependencies] @@ -25,17 +25,21 @@ fodid = { path = "../fodid", version = "4.5" } chrono.workspace = true serde_json.workspace = true thiserror.workspace = true -# Blocking HTTP client for the built-in transport. rustls avoids a system +# Asynchronous HTTP client for the built-in transport. rustls avoids a system # OpenSSL dependency on Windows, and the form feature carries the url-encoded # redeem body. Optional and gated behind the `reqwest-client` feature, because -# reqwest::blocking needs native sockets and threads and so does not build for +# reqwest needs native sockets and a tokio runtime and so does not build for # targets such as wasm32-wasip1, exactly as in the cloud request engine. -reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls", "form"], optional = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "form"], optional = true } [dev-dependencies] # The tests create a real signed 51Did to stand in for the cloud, which needs # the OWID creator types fodid exposes under its creator feature. fodid = { path = "../fodid", version = "4.5", features = ["creator"] } +# The tests await the client on a current-thread runtime, which is what +# proves the futures need not be Send. The crate itself has no async runtime +# dependency. +tokio = { workspace = true, features = ["macros", "rt"] } [package.metadata.docs.rs] # Build the documentation on docs.rs with every feature enabled, so the diff --git a/fodid-client/README.md b/fodid-client/README.md index 7a2e1bb..a2bcba9 100644 --- a/fodid-client/README.md +++ b/fodid-client/README.md @@ -46,7 +46,7 @@ licence key never reaches the browser. ## Usage Add the crate, turning on the built-in transport where the program runs on a -native host. +native host with a tokio runtime. ```toml [dependencies] @@ -60,6 +60,12 @@ take the transport as a parameter so they read the same either way. On a native host, `Arc::new(fodid_client::ReqwestClient::default())` is the transport to pass, or leave `http_client` out and the builder creates one. +Every method that may reach the network is `async` and is awaited. The crate +carries no async runtime of its own, so the futures run on whatever runtime +the host has, and they are not required to be `Send`, so a single-threaded +host can await them. The built-in `reqwest` transport runs on a tokio +runtime, so a program that uses it awaits the client from inside one. + ### Step two, redeeming on the server ```rust,no_run @@ -67,7 +73,7 @@ use std::sync::Arc; use fodid::FodId; use fodid_client::{ContextOutcome, DidClient, DidHttpClient, FactorOutcome}; -fn redeem( +async fn redeem( transport: Arc, encoded_51did: &str, sealed_result: &str, @@ -86,7 +92,7 @@ fn redeem( // about its signature, which the redemption reports separately. let fod_id = FodId::from_base64(encoded_51did)?; - let outcome = client.redeem(&fod_id, sealed_result, challenge)?; + let outcome = client.redeem(&fod_id, sealed_result, challenge).await?; match outcome.context() { ContextOutcome::Verified => { // Presented from the connection it was created on. @@ -143,14 +149,15 @@ is not a 51Did is refused locally, before any call is made. The cloud publishes the schedule of signing keys, each in force from its start until the next one starts. The client fetches that schedule on first use and again when it is a day old, when no key covers the identifier's date, -or when the date is later than the newest start it holds. +or when the date is later than the newest start it holds. Concurrent callers +that each find the schedule needs fetching share one fetch. ```rust,no_run use std::sync::Arc; use fodid::FodId; use fodid_client::{DidClient, DidHttpClient, SignatureCheck}; -fn check( +async fn check( transport: Arc, encoded_51did: &str, ) -> Result<(), Box> { @@ -160,7 +167,7 @@ fn check( let fod_id = FodId::from_base64(encoded_51did)?; // Once the keys are cached this makes no network call. - match client.verify_signature_detailed(&fod_id)? { + match client.verify_signature_detailed(&fod_id).await? { SignatureCheck::Verified => println!("genuine"), SignatureCheck::Invalid => println!("distrust this identifier"), SignatureCheck::NoKey => println!("no published key covers its date"), @@ -169,7 +176,7 @@ fn check( // The same check through the cloud, which costs one use and needs no // licence key. - let genuine_by_cloud: bool = client.verify(&fod_id)?; + let genuine_by_cloud: bool = client.verify(&fod_id).await?; let _ = genuine_by_cloud; Ok(()) } @@ -181,24 +188,33 @@ log rather than a fraud signal. ### Supplying a transport -Every request goes through the `DidHttpClient` trait, one blocking `send` -that returns whatever the server answered, whatever the status. A host with -its own HTTP stack implements it and hands the client an `Arc` of it. A -transport returns `Err` only when the request did not complete, because the -client decides what each status means. +Every request goes through the `DidHttpClient` trait, one awaitable `send` +that resolves to whatever the server answered, whatever the status. It +returns a `LocalBoxFuture`, a boxed future that is not required to be +`Send`, so a host whose request or response types cannot cross threads can +still implement it. A host with its own HTTP stack implements the trait and +hands the client an `Arc` of it. A transport resolves to `Err` only when the +request did not complete, because the client decides what each status means. ```rust -use fodid_client::{DidHttpClient, DidHttpRequest, DidHttpResponse, HttpMethod}; +use fodid_client::{ + DidHttpClient, DidHttpRequest, DidHttpResponse, HttpMethod, LocalBoxFuture, +}; struct HostTransport; impl DidHttpClient for HostTransport { - fn send(&self, request: &DidHttpRequest) -> Result { - // Hand request.url, request.form (url-encoded for a POST) and - // request.user_agent to the host's own fetch, then return the - // status and body it answered with. - let _ = (request.method == HttpMethod::Post, &request.url); - Err("not connected in this example".to_string()) + fn send<'a>( + &'a self, + request: &'a DidHttpRequest, + ) -> LocalBoxFuture<'a, Result> { + Box::pin(async move { + // Hand request.url, request.form (url-encoded for a POST) and + // request.user_agent to the host's own fetch, await it, then + // return the status and body it answered with. + let _ = (request.method == HttpMethod::Post, &request.url); + Err("not connected in this example".to_string()) + }) } } ``` diff --git a/fodid-client/src/client.rs b/fodid-client/src/client.rs index 62109ff..b9e893f 100644 --- a/fodid-client/src/client.rs +++ b/fodid-client/src/client.rs @@ -23,6 +23,9 @@ //! The client, being everything a server does with a 51Did against the //! 51Degrees cloud. +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll, Waker}; use std::sync::{Arc, Mutex, MutexGuard}; use chrono::{DateTime, Duration, Utc}; @@ -66,10 +69,23 @@ pub const MAXIMUM_ENCODED_LENGTH: usize = 4096; /// time on without waiting. type Clock = Arc DateTime + Send + Sync>; -/// The cached key schedule and when it was fetched. +/// The cached key schedule, when it was fetched, and the fetch in flight. +/// +/// The lock around this is only ever held between awaits, never across +/// one, so a slow fetch blocks no other caller. A caller that finds a fetch +/// already in flight waits for that one to land instead of starting a +/// second, which is what keeps concurrent lookups down to one request. struct KeyCache { keys: Option>, fetched_at: DateTime, + /// Counts the fetches that have landed, so a caller that waited on + /// another caller's fetch can tell whether one did. + generation: u64, + /// Whether a fetch is in flight. + fetching: bool, + /// The callers waiting for the fetch in flight to finish, woken when it + /// lands or fails. + waiters: Vec, } /// Everything a server does with a 51Did against the 51Degrees cloud: fetch @@ -90,9 +106,14 @@ struct KeyCache { /// as the endpoints accept, and the licence key travels only in a POST form /// body, because a query string is written to access logs. /// -/// The key cache is per instance and safe to share across threads, so -/// create one client for the process and reuse it. Every call blocks until -/// the transport answers. +/// Every method that may reach the network is `async` and is awaited. The +/// futures are driven by whatever runtime the host has, because the crate +/// carries none of its own, and they are not required to be `Send`, so a +/// single-threaded host such as a `wasm32-wasip1` edge runtime can await +/// them. The key cache is per instance and safe to share across threads, so +/// create one client for the process and reuse it. Concurrent callers that +/// each find the cache needs fetching share one fetch rather than each +/// making their own. pub struct DidClient { http: Arc, resource_key: String, @@ -187,6 +208,9 @@ impl DidClientBuilder { cache: Mutex::new(KeyCache { keys: None, fetched_at, + generation: 0, + fetching: false, + waiters: Vec::new(), }), }) } @@ -277,12 +301,8 @@ impl DidClient { /// [`Error::Transport`] when the cloud cannot be reached, and /// [`Error::UnexpectedStatus`] when it answers with a status other than /// 200. - pub fn public_keys(&self) -> Result> { - let mut cache = self.lock_cache(); - if cache.keys.is_none() { - self.refresh_keys_locked(&mut cache)?; - } - Ok(cache.keys.clone().unwrap_or_default()) + pub async fn public_keys(&self) -> Result> { + self.keys_where(|cache| cache.keys.is_none()).await } /// The key in force when the identifier was created, being the entry @@ -297,9 +317,9 @@ impl DidClient { /// /// [`Error::Transport`] and [`Error::UnexpectedStatus`] when a fetch was /// needed and did not answer with 200. - pub fn public_key_for(&self, fod_id: &FodId) -> Result> { + pub async fn public_key_for(&self, fod_id: &FodId) -> Result> { let date = fod_id.date(); - let keys = self.keys_covering(date)?; + let keys = self.keys_covering(date).await?; Ok(in_force_at(&keys, date).cloned()) } @@ -309,8 +329,8 @@ impl DidClient { /// True only when the signature verifies under a key in force at the /// identifier's date. See [`DidClient::verify_signature_detailed`] for /// why a check did not pass. - pub fn verify_signature(&self, fod_id: &FodId) -> Result { - Ok(self.verify_signature_detailed(fod_id)? == SignatureCheck::Verified) + pub async fn verify_signature(&self, fod_id: &FodId) -> Result { + Ok(self.verify_signature_detailed(fod_id).await? == SignatureCheck::Verified) } /// Verifies the identifier's signature offline and says why when the @@ -326,9 +346,9 @@ impl DidClient { /// /// [`Error::Transport`] and [`Error::UnexpectedStatus`] when a key fetch /// was needed and did not answer with 200. - pub fn verify_signature_detailed(&self, fod_id: &FodId) -> Result { + pub async fn verify_signature_detailed(&self, fod_id: &FodId) -> Result { let date = fod_id.date(); - let keys = self.keys_covering(date)?; + let keys = self.keys_covering(date).await?; let candidates = candidates_for_date(&keys, date); if candidates.is_empty() { return Ok(SignatureCheck::NoKey); @@ -357,13 +377,13 @@ impl DidClient { /// refused the value, [`Error::Transport`] when the cloud cannot be /// reached, and [`Error::UnexpectedStatus`] when it answers with a /// status this client does not expect. - pub fn verify(&self, fod_id: &FodId) -> Result { + pub async fn verify(&self, fod_id: &FodId) -> Result { // A parsed identifier is already known to be a 51Did, so the string // surface's local check is not repeated. let encoded = fod_id .as_base64() .map_err(|e| Error::InvalidArgument(format!("the 51Did could not be encoded: {e}")))?; - self.verify_encoded_unchecked(&encoded) + self.verify_encoded_unchecked(&encoded).await } /// Verifies a 51Did string's signature through the cloud's verify @@ -378,12 +398,12 @@ impl DidClient { /// cloud refused it. [`Error::Transport`] when the cloud cannot be /// reached, and [`Error::UnexpectedStatus`] when it answers with a /// status this client does not expect. - pub fn verify_encoded(&self, fod_id: &str) -> Result { + pub async fn verify_encoded(&self, fod_id: &str) -> Result { validate_encoded_value(fod_id)?; - self.verify_encoded_unchecked(fod_id) + self.verify_encoded_unchecked(fod_id).await } - fn verify_encoded_unchecked(&self, fod_id: &str) -> Result { + async fn verify_encoded_unchecked(&self, fod_id: &str) -> Result { // The documented parameter is 51did. The same value is sent again as // owid, the name the verify endpoint first went live under, which a // service that predates the 51did name reads and a current one @@ -394,7 +414,7 @@ impl DidClient { self.endpoint, escape_data_string(&self.resource_key) ); - let response = self.send(HttpMethod::Get, url, Vec::new())?; + let response = self.send(HttpMethod::Get, url, Vec::new()).await?; if response.status == 200 || response.status == 400 { if let Some(valid) = read_valid(&response.body) { return Ok(valid); @@ -422,7 +442,7 @@ impl DidClient { /// so does not offer the creator context, [`Error::Transport`] when the /// cloud cannot be reached, and [`Error::UnexpectedStatus`] for any /// other status. - pub fn redeem( + pub async fn redeem( &self, fod_id: &FodId, result: &str, @@ -434,6 +454,7 @@ impl DidClient { .as_base64() .map_err(|e| Error::InvalidArgument(format!("the 51Did could not be encoded: {e}")))?; self.redeem_encoded_unchecked(&encoded, result, challenge) + .await } /// Redeems a sealed creator context result against a 51Did string. See @@ -443,7 +464,7 @@ impl DidClient { /// /// [`Error::InvalidArgument`] when the value is not a 51Did, refused /// here before any call is made, and otherwise as [`DidClient::redeem`]. - pub fn redeem_encoded( + pub async fn redeem_encoded( &self, fod_id: &str, result: &str, @@ -451,9 +472,10 @@ impl DidClient { ) -> Result { validate_encoded_value(fod_id)?; self.redeem_encoded_unchecked(fod_id, result, challenge) + .await } - fn redeem_encoded_unchecked( + async fn redeem_encoded_unchecked( &self, fod_id: &str, result: &str, @@ -476,7 +498,7 @@ impl DidClient { form.push(("license".to_string(), licence_key.clone())); } let url = format!("{}id/redeem", self.endpoint); - let response = self.send(HttpMethod::Post, url, form)?; + let response = self.send(HttpMethod::Post, url, form).await?; match response.status { 200 | 503 => Ok(RedeemResult::from_response(response.status, &response.body)), 400 => Err(Error::InvalidArgument( @@ -489,19 +511,52 @@ impl DidClient { /// The cached keys, fetched again first when /// [`DidClient::public_key_for`] says a fetch is due for the date. - fn keys_covering(&self, date: DateTime) -> Result> { - let mut cache = self.lock_cache(); - let refresh = match &cache.keys { + async fn keys_covering(&self, date: DateTime) -> Result> { + self.keys_where(|cache| match &cache.keys { None => true, - Some(keys) => self.needs_refresh_locked(keys, cache.fetched_at, date), - }; - if refresh { - self.refresh_keys_locked(&mut cache)?; + Some(keys) => self.needs_refresh(keys, cache.fetched_at, date), + }) + .await + } + + /// The cached keys, fetched first when `stale` says the cache as it + /// stands will not do. + /// + /// When another caller's fetch is already in flight this one waits for + /// that fetch instead of making a second request, and answers from the + /// keys that fetch landed. Only when the other fetch failed does this + /// caller make a request of its own, so an answer here is always backed + /// by at most one request made on this caller's behalf. + async fn keys_where(&self, stale: impl Fn(&KeyCache) -> bool) -> Result> { + loop { + // Everything under the lock is a plain read or a flag write, and + // the lock is dropped before anything is awaited. + let (generation, fetch) = { + let mut cache = self.lock_cache(); + if !stale(&cache) { + return Ok(cache.keys.clone().unwrap_or_default()); + } + if cache.fetching { + (cache.generation, false) + } else { + cache.fetching = true; + (cache.generation, true) + } + }; + if fetch { + return self.fetch_keys().await; + } + FetchFinished { client: self }.await; + let cache = self.lock_cache(); + if cache.generation != generation { + return Ok(cache.keys.clone().unwrap_or_default()); + } + // The fetch waited on did not land, so this caller goes round + // again and, finding nothing in flight, makes its own. } - Ok(cache.keys.clone().unwrap_or_default()) } - fn needs_refresh_locked( + fn needs_refresh( &self, keys: &[DidPublicKey], fetched_at: DateTime, @@ -517,19 +572,29 @@ impl DidClient { newest.is_none_or(|newest| date > newest) } - fn refresh_keys_locked(&self, cache: &mut MutexGuard<'_, KeyCache>) -> Result<()> { + /// Fetches the key list and stores it. Called only by the caller that + /// set the in-flight flag, and clears that flag however it ends, the + /// future being dropped before it finishes included, so no waiter is + /// left waiting on a fetch that will never land. + async fn fetch_keys(&self) -> Result> { + let _finished = FetchFinishes { client: self }; let url = format!( "{}id/key/{}", self.endpoint, escape_data_string(&self.resource_key) ); - let response = self.send(HttpMethod::Get, url, Vec::new())?; + let response = self.send(HttpMethod::Get, url, Vec::new()).await?; if response.status != 200 { return Err(unexpected("key", &response)); } - cache.keys = Some(parse_keys(&response.body)?); - cache.fetched_at = (self.clock)(); - Ok(()) + let keys = parse_keys(&response.body)?; + { + let mut cache = self.lock_cache(); + cache.keys = Some(keys.clone()); + cache.fetched_at = (self.clock)(); + cache.generation += 1; + } + Ok(keys) } fn lock_cache(&self) -> MutexGuard<'_, KeyCache> { @@ -541,7 +606,7 @@ impl DidClient { .unwrap_or_else(|poisoned| poisoned.into_inner()) } - fn send( + async fn send( &self, method: HttpMethod, url: String, @@ -553,7 +618,45 @@ impl DidClient { form, user_agent: USER_AGENT.to_string(), }; - self.http.send(&request).map_err(Error::Transport) + self.http.send(&request).await.map_err(Error::Transport) + } +} + +/// Resolves once no key fetch is in flight. A caller that found one in +/// flight awaits this rather than making a second request. +struct FetchFinished<'a> { + client: &'a DidClient, +} + +impl Future for FetchFinished<'_> { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + let mut cache = self.client.lock_cache(); + if !cache.fetching { + return Poll::Ready(()); + } + // The same caller polled again registers once. + if !cache.waiters.iter().any(|w| w.will_wake(cx.waker())) { + cache.waiters.push(cx.waker().clone()); + } + Poll::Pending + } +} + +/// Clears the in-flight flag and wakes every waiter when dropped, which is +/// when the fetch that set the flag ends, however it ends. +struct FetchFinishes<'a> { + client: &'a DidClient, +} + +impl Drop for FetchFinishes<'_> { + fn drop(&mut self) { + let mut cache = self.client.lock_cache(); + cache.fetching = false; + for waker in cache.waiters.drain(..) { + waker.wake(); + } } } @@ -632,16 +735,38 @@ fn read_errors(body: &str) -> Option { #[cfg(test)] mod tests { use std::collections::VecDeque; + use std::rc::Rc; use std::sync::Mutex; use fodid::{Creator, Crypto}; use super::*; + use crate::http::LocalBoxFuture; use crate::outcome::ContextOutcome; const RESOURCE_KEY: &str = "AQS5HKcy-resource"; const ENDPOINT: &str = "https://example.test/api/v4/"; + /// Returns pending once, waking itself, and is ready on the next poll. + /// Every stub transport yields through this before answering, so that + /// a second caller can reach the client while the first is still + /// waiting on the network, which is how the shared fetch is exercised. + struct YieldOnce(bool); + + impl Future for YieldOnce { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + if self.0 { + Poll::Ready(()) + } else { + self.0 = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + } + } + /// Stands in for the network, recording every request and answering /// canned responses in order. #[derive(Default)] @@ -680,13 +805,44 @@ mod tests { } impl DidHttpClient for FakeHttp { - fn send(&self, request: &DidHttpRequest) -> std::result::Result { - self.requests.lock().unwrap().push(request.clone()); - self.responses - .lock() - .unwrap() - .pop_front() - .unwrap_or_else(|| Err("no canned response left for this request".to_string())) + fn send<'a>( + &'a self, + request: &'a DidHttpRequest, + ) -> LocalBoxFuture<'a, std::result::Result> { + Box::pin(async move { + YieldOnce(false).await; + self.requests.lock().unwrap().push(request.clone()); + self.responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| Err("no canned response left for this request".to_string())) + }) + } + } + + /// A transport whose future holds an `Rc` across an await. An `Rc` + /// cannot cross threads, so this compiles only because the trait does + /// not require the future to be `Send`, which is the point of the test + /// that uses it. + struct RcHolding { + body: String, + } + + impl DidHttpClient for RcHolding { + fn send<'a>( + &'a self, + request: &'a DidHttpRequest, + ) -> LocalBoxFuture<'a, std::result::Result> { + Box::pin(async move { + let held = Rc::new(request.url.clone()); + YieldOnce(false).await; + assert!(held.ends_with(&escape_data_string(RESOURCE_KEY))); + Ok(DidHttpResponse { + status: 200, + body: self.body.clone(), + }) + }) } } @@ -731,7 +887,7 @@ mod tests { } } - fn new_client(http: Arc) -> DidClient { + fn new_client(http: Arc) -> DidClient { DidClient::builder(RESOURCE_KEY) .endpoint(ENDPOINT) .http_client(http) @@ -827,12 +983,12 @@ mod tests { // Keys and the cache. - #[test] - fn keys_are_fetched_from_the_key_endpoint_with_the_user_agent() { + #[tokio::test] + async fn keys_are_fetched_from_the_key_endpoint_with_the_user_agent() { let fixture = Fixture::new(); let http = FakeHttp::answering(vec![(200, &fixture.keys_json())]); let client = new_client(http.clone()); - let keys = client.public_keys().unwrap(); + let keys = client.public_keys().await.unwrap(); assert_eq!(keys.len(), 2); let requests = http.requests(); assert_eq!(requests.len(), 1); @@ -849,10 +1005,10 @@ mod tests { ); } - #[test] - fn a_key_answer_other_than_200_is_unexpected() { + #[tokio::test] + async fn a_key_answer_other_than_200_is_unexpected() { let http = FakeHttp::answering(vec![(500, "down")]); - let error = new_client(http).public_keys().unwrap_err(); + let error = new_client(http).public_keys().await.unwrap_err(); match error { Error::UnexpectedStatus { endpoint, status, .. @@ -864,10 +1020,11 @@ mod tests { } } - #[test] - fn a_transport_failure_is_reported_as_one() { + #[tokio::test] + async fn a_transport_failure_is_reported_as_one() { let error = new_client(FakeHttp::failing("no route")) .public_keys() + .await .unwrap_err(); assert!( matches!(error, Error::Transport(ref m) if m == "no route"), @@ -875,19 +1032,27 @@ mod tests { ); } - #[test] - fn a_fresh_cache_inside_the_schedule_is_not_fetched_again() { + #[tokio::test] + async fn a_fresh_cache_inside_the_schedule_is_not_fetched_again() { let fixture = Fixture::new(); let http = FakeHttp::answering(vec![(200, &fixture.keys_json())]); let client = new_client(http.clone()); - assert!(client.public_key_for(&fixture.fod_id).unwrap().is_some()); - assert!(client.public_key_for(&fixture.fod_id).unwrap().is_some()); - assert!(client.verify_signature(&fixture.fod_id).unwrap()); + assert!(client + .public_key_for(&fixture.fod_id) + .await + .unwrap() + .is_some()); + assert!(client + .public_key_for(&fixture.fod_id) + .await + .unwrap() + .is_some()); + assert!(client.verify_signature(&fixture.fod_id).await.unwrap()); assert_eq!(http.requests().len(), 1, "one fetch serves every lookup"); } - #[test] - fn a_cache_older_than_a_day_is_fetched_again() { + #[tokio::test] + async fn a_cache_older_than_a_day_is_fetched_again() { let fixture = Fixture::new(); let keys = fixture.keys_json(); let http = FakeHttp::answering(vec![(200, &keys), (200, &keys)]); @@ -899,17 +1064,17 @@ mod tests { .clock(move || *clock_now.lock().unwrap()) .build() .unwrap(); - client.public_key_for(&fixture.fod_id).unwrap(); + client.public_key_for(&fixture.fod_id).await.unwrap(); *now.lock().unwrap() += KEY_CACHE_LIFETIME - Duration::minutes(1); - client.public_key_for(&fixture.fod_id).unwrap(); + client.public_key_for(&fixture.fod_id).await.unwrap(); assert_eq!(http.requests().len(), 1, "still inside the lifetime"); *now.lock().unwrap() += Duration::minutes(2); - client.public_key_for(&fixture.fod_id).unwrap(); + client.public_key_for(&fixture.fod_id).await.unwrap(); assert_eq!(http.requests().len(), 2, "stale, so fetched again"); } - #[test] - fn a_date_before_every_key_held_is_fetched_again() { + #[tokio::test] + async fn a_date_before_every_key_held_is_fetched_again() { let fixture = Fixture::new(); // A schedule that only starts tomorrow does not cover an identifier // created now, so the client looks again before answering. @@ -917,15 +1082,26 @@ mod tests { let later = format!(r#"[{{"startsAt":"{tomorrow}","publicKey":"x"}}]"#); let http = FakeHttp::answering(vec![(200, &later), (200, &later), (200, &later)]); let client = new_client(http.clone()); - assert!(client.public_key_for(&fixture.fod_id).unwrap().is_none()); - assert!(client.public_key_for(&fixture.fod_id).unwrap().is_none()); + assert!(client + .public_key_for(&fixture.fod_id) + .await + .unwrap() + .is_none()); + assert!(client + .public_key_for(&fixture.fod_id) + .await + .unwrap() + .is_none()); assert_eq!( http.requests().len(), 2, "each lookup fetched, none covered" ); assert_eq!( - client.verify_signature_detailed(&fixture.fod_id).unwrap(), + client + .verify_signature_detailed(&fixture.fod_id) + .await + .unwrap(), SignatureCheck::NoKey ); assert_eq!( @@ -935,8 +1111,8 @@ mod tests { ); } - #[test] - fn a_date_after_the_newest_start_held_is_fetched_again() { + #[tokio::test] + async fn a_date_after_the_newest_start_held_is_fetched_again() { let fixture = Fixture::new(); // A schedule with no key published ahead: the newest start is // yesterday, and an identifier created now is later than it, so the @@ -946,26 +1122,115 @@ mod tests { let json = format!(r#"[{{"startsAt":"{yesterday}","publicKey":"{escaped}"}}]"#); let http = FakeHttp::answering(vec![(200, &json), (200, &json)]); let client = new_client(http.clone()); - assert!(client.public_key_for(&fixture.fod_id).unwrap().is_some()); - assert!(client.public_key_for(&fixture.fod_id).unwrap().is_some()); + assert!(client + .public_key_for(&fixture.fod_id) + .await + .unwrap() + .is_some()); + assert!(client + .public_key_for(&fixture.fod_id) + .await + .unwrap() + .is_some()); assert_eq!(http.requests().len(), 2); } + #[tokio::test] + async fn concurrent_lookups_share_one_fetch() { + let fixture = Fixture::new(); + // One canned answer only, so a second request would fail with no + // response left and show up as an error below. + let http = FakeHttp::answering(vec![(200, &fixture.keys_json())]); + let client = new_client(http.clone()); + // Both futures start before either finishes. The stub yields before + // answering, so the second lookup finds the first one's fetch in + // flight and waits for it rather than sending its own. + let (first, second, third) = tokio::join!( + client.public_keys(), + client.public_key_for(&fixture.fod_id), + client.verify_signature(&fixture.fod_id), + ); + assert_eq!(first.unwrap().len(), 2); + assert!(second.unwrap().is_some()); + assert!(third.unwrap()); + assert_eq!(http.requests().len(), 1, "one request served all three"); + } + + #[tokio::test] + async fn a_waiter_fetches_for_itself_when_the_shared_fetch_fails() { + let fixture = Fixture::new(); + let http = FakeHttp::answering(vec![(500, "down"), (200, &fixture.keys_json())]); + let client = new_client(http.clone()); + let (first, second) = tokio::join!(client.public_keys(), client.public_keys()); + assert!( + matches!(first, Err(Error::UnexpectedStatus { status: 500, .. })), + "the caller that fetched sees the failure" + ); + assert_eq!( + second.unwrap().len(), + 2, + "the caller that waited fetched again and got the keys" + ); + assert_eq!(http.requests().len(), 2); + } + + #[tokio::test] + async fn a_dropped_fetch_does_not_leave_waiters_stranded() { + let fixture = Fixture::new(); + let http = FakeHttp::answering(vec![(200, &fixture.keys_json())]); + let client = new_client(http.clone()); + // Poll a fetch far enough to mark it in flight, then drop it before + // it lands. The in-flight mark must go with it. + { + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + let mut fetch = Box::pin(client.public_keys()); + assert!(fetch.as_mut().poll(&mut cx).is_pending()); + assert!(client.lock_cache().fetching, "the fetch is in flight"); + } + assert!( + !client.lock_cache().fetching, + "the dropped fetch cleared the mark" + ); + // The stub recorded nothing, because the dropped future never got + // past its first yield, so the canned answer is still there for + // this lookup. + assert_eq!(client.public_keys().await.unwrap().len(), 2); + assert_eq!(http.requests().len(), 1); + } + + #[tokio::test] + async fn the_transport_future_need_not_be_send() { + // The stub holds an Rc across an await inside its send future. That + // future is not Send, and the test compiles and passes because the + // trait never asks it to be. + let fixture = Fixture::new(); + let http: Arc = Arc::new(RcHolding { + body: fixture.keys_json(), + }); + let client = new_client(http); + assert_eq!(client.public_keys().await.unwrap().len(), 2); + assert!(client.verify_signature(&fixture.fod_id).await.unwrap()); + } + // Offline signature checking. - #[test] - fn a_genuine_signature_verifies_under_the_key_in_force() { + #[tokio::test] + async fn a_genuine_signature_verifies_under_the_key_in_force() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![(200, &fixture.keys_json())])); assert_eq!( - client.verify_signature_detailed(&fixture.fod_id).unwrap(), + client + .verify_signature_detailed(&fixture.fod_id) + .await + .unwrap(), SignatureCheck::Verified ); - assert!(client.verify_signature(&fixture.fod_id).unwrap()); + assert!(client.verify_signature(&fixture.fod_id).await.unwrap()); } - #[test] - fn a_signature_under_another_key_is_invalid() { + #[tokio::test] + async fn a_signature_under_another_key_is_invalid() { let fixture = Fixture::new(); let other = Crypto::new().public_key_pem().unwrap(); let client = new_client(FakeHttp::answering(vec![( @@ -973,33 +1238,39 @@ mod tests { &fixture.keys_json_with(&other), )])); assert_eq!( - client.verify_signature_detailed(&fixture.fod_id).unwrap(), + client + .verify_signature_detailed(&fixture.fod_id) + .await + .unwrap(), SignatureCheck::Invalid ); - assert!(!client.verify_signature(&fixture.fod_id).unwrap()); + assert!(!client.verify_signature(&fixture.fod_id).await.unwrap()); } - #[test] - fn a_key_that_cannot_be_read_is_unusable_not_invalid() { + #[tokio::test] + async fn a_key_that_cannot_be_read_is_unusable_not_invalid() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![( 200, &fixture.keys_json_with("not a PEM"), )])); assert_eq!( - client.verify_signature_detailed(&fixture.fod_id).unwrap(), + client + .verify_signature_detailed(&fixture.fod_id) + .await + .unwrap(), SignatureCheck::KeyUnusable ); } // The online verify call. - #[test] - fn verify_gets_the_verify_route_with_both_parameter_names() { + #[tokio::test] + async fn verify_gets_the_verify_route_with_both_parameter_names() { let fixture = Fixture::new(); let http = FakeHttp::answering(vec![(200, r#"{"valid":true}"#)]); let client = new_client(http.clone()); - assert!(client.verify(&fixture.fod_id).unwrap()); + assert!(client.verify(&fixture.fod_id).await.unwrap()); let requests = http.requests(); assert_eq!(requests.len(), 1); let encoded = escape_data_string(&fixture.encoded()); @@ -1020,32 +1291,32 @@ mod tests { assert_eq!(requests[0].user_agent, USER_AGENT); } - #[test] - fn verify_reads_a_false_answer() { + #[tokio::test] + async fn verify_reads_a_false_answer() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![(200, r#"{"valid":false}"#)])); - assert!(!client.verify_encoded(&fixture.encoded()).unwrap()); + assert!(!client.verify_encoded(&fixture.encoded()).await.unwrap()); } - #[test] - fn verify_reports_the_service_errors_on_400() { + #[tokio::test] + async fn verify_reports_the_service_errors_on_400() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![( 400, r#"{"errors":["first problem","second problem"]}"#, )])); - let error = client.verify_encoded(&fixture.encoded()).unwrap_err(); + let error = client.verify_encoded(&fixture.encoded()).await.unwrap_err(); assert!( matches!(error, Error::InvalidArgument(ref m) if m == "first problem second problem"), "{error}" ); } - #[test] - fn verify_treats_any_other_answer_as_unexpected() { + #[tokio::test] + async fn verify_treats_any_other_answer_as_unexpected() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![(500, "oops")])); - let error = client.verify(&fixture.fod_id).unwrap_err(); + let error = client.verify(&fixture.fod_id).await.unwrap_err(); assert!( matches!( error, @@ -1058,28 +1329,31 @@ mod tests { "{error}" ); let client = new_client_with_licence(FakeHttp::answering(vec![(200, "not json")])); - let error = client.verify(&fixture.fod_id).unwrap_err(); + let error = client.verify(&fixture.fod_id).await.unwrap_err(); assert!(matches!(error, Error::UnexpectedStatus { .. }), "{error}"); } - #[test] - fn a_value_that_is_not_a_51did_is_refused_before_any_call() { + #[tokio::test] + async fn a_value_that_is_not_a_51did_is_refused_before_any_call() { let http = FakeHttp::answering(vec![]); let client = new_client(http.clone()); for value in ["", " ", "not base 64!", "AAAA"] { - let error = client.verify_encoded(value).unwrap_err(); + let error = client.verify_encoded(value).await.unwrap_err(); assert!( matches!(error, Error::InvalidArgument(_)), "{value:?}: {error}" ); - let error = client.redeem_encoded(value, "sealed", None).unwrap_err(); + let error = client + .redeem_encoded(value, "sealed", None) + .await + .unwrap_err(); assert!( matches!(error, Error::InvalidArgument(_)), "{value:?}: {error}" ); } let too_long = "A".repeat(MAXIMUM_ENCODED_LENGTH + 1); - let error = client.verify_encoded(&too_long).unwrap_err(); + let error = client.verify_encoded(&too_long).await.unwrap_err(); assert!( matches!(error, Error::InvalidArgument(ref m) if m.contains("too long")), "{error}" @@ -1089,15 +1363,18 @@ mod tests { // Redeem. - #[test] - fn redeem_posts_the_form_without_a_licence_field_when_none_was_given() { + #[tokio::test] + async fn redeem_posts_the_form_without_a_licence_field_when_none_was_given() { let fixture = Fixture::new(); let http = FakeHttp::answering(vec![( 200, r#"{"context":"verified","signature":"verified"}"#, )]); let client = new_client(http.clone()); - let result = client.redeem(&fixture.fod_id, "sealed", None).unwrap(); + let result = client + .redeem(&fixture.fod_id, "sealed", None) + .await + .unwrap(); assert_eq!(result.context(), ContextOutcome::Verified); let requests = http.requests(); assert_eq!(requests.len(), 1); @@ -1120,13 +1397,14 @@ mod tests { assert_eq!(request.user_agent, USER_AGENT); } - #[test] - fn redeem_carries_the_licence_key_and_challenge_in_the_form_only() { + #[tokio::test] + async fn redeem_carries_the_licence_key_and_challenge_in_the_form_only() { let fixture = Fixture::new(); let http = FakeHttp::answering(vec![(200, r#"{"context":"verified"}"#)]); let client = new_client_with_licence(http.clone()); client .redeem_encoded(&fixture.encoded(), "sealed", Some("nonce-1")) + .await .unwrap(); let requests = http.requests(); let request = &requests[0]; @@ -1136,67 +1414,85 @@ mod tests { assert!(!request.url.contains("licence-value")); } - #[test] - fn redeem_maps_a_mismatch_and_a_misconfigured_factor() { + #[tokio::test] + async fn redeem_maps_a_mismatch_and_a_misconfigured_factor() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![( 200, r#"{"context":"mismatch","signature":"verified", "factors":{"device":"mismatch","asn":"misconfigured"}}"#, )])); - let result = client.redeem(&fixture.fod_id, "sealed", None).unwrap(); + let result = client + .redeem(&fixture.fod_id, "sealed", None) + .await + .unwrap(); assert_eq!(result.context(), ContextOutcome::Mismatch); let factors = result.factors().unwrap(); assert_eq!(factors["device"], crate::FactorOutcome::Mismatch); assert_eq!(factors["asn"], crate::FactorOutcome::Misconfigured); } - #[test] - fn redeem_reads_503_as_unconfirmed() { + #[tokio::test] + async fn redeem_reads_503_as_unconfirmed() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![(503, "")])); - let result = client.redeem(&fixture.fod_id, "sealed", None).unwrap(); + let result = client + .redeem(&fixture.fod_id, "sealed", None) + .await + .unwrap(); assert_eq!(result.context(), ContextOutcome::Unconfirmed); assert_eq!(result.status(), 503); } - #[test] - fn redeem_reports_the_service_errors_on_400() { + #[tokio::test] + async fn redeem_reports_the_service_errors_on_400() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![( 400, r#"{"errors":["bad 51did"]}"#, )])); - let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + let error = client + .redeem(&fixture.fod_id, "sealed", None) + .await + .unwrap_err(); assert!( matches!(error, Error::InvalidArgument(ref m) if m == "bad 51did"), "{error}" ); // A 400 with no errors array carries the body as the message. let client = new_client(FakeHttp::answering(vec![(400, "plain refusal")])); - let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + let error = client + .redeem(&fixture.fod_id, "sealed", None) + .await + .unwrap_err(); assert!( matches!(error, Error::InvalidArgument(ref m) if m == "plain refusal"), "{error}" ); } - #[test] - fn redeem_reports_404_as_not_supported() { + #[tokio::test] + async fn redeem_reports_404_as_not_supported() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![(404, "")])); - let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + let error = client + .redeem(&fixture.fod_id, "sealed", None) + .await + .unwrap_err(); assert!( matches!(error, Error::NotSupported(ref e) if e == ENDPOINT), "{error}" ); } - #[test] - fn redeem_treats_any_other_status_as_unexpected() { + #[tokio::test] + async fn redeem_treats_any_other_status_as_unexpected() { let fixture = Fixture::new(); let client = new_client(FakeHttp::answering(vec![(502, "gateway")])); - let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + let error = client + .redeem(&fixture.fod_id, "sealed", None) + .await + .unwrap_err(); assert!( matches!( error, @@ -1210,11 +1506,14 @@ mod tests { ); } - #[test] - fn redeem_reports_a_transport_failure() { + #[tokio::test] + async fn redeem_reports_a_transport_failure() { let fixture = Fixture::new(); let client = new_client(FakeHttp::failing("timed out")); - let error = client.redeem(&fixture.fod_id, "sealed", None).unwrap_err(); + let error = client + .redeem(&fixture.fod_id, "sealed", None) + .await + .unwrap_err(); assert!(matches!(error, Error::Transport(_)), "{error}"); } diff --git a/fodid-client/src/http.rs b/fodid-client/src/http.rs index 1319ba7..e66244c 100644 --- a/fodid-client/src/http.rs +++ b/fodid-client/src/http.rs @@ -22,8 +22,20 @@ //! The one HTTP operation the client needs, and the built-in transport. +use core::future::Future; +use core::pin::Pin; +#[cfg(feature = "reqwest-client")] use std::time::Duration; +/// A boxed future that borrows for `'a` and is not required to be `Send`. +/// +/// Every awaitable operation in this crate resolves through this type, so +/// the crate needs no async runtime of its own and no future it returns has +/// to cross threads. That is what lets a host such as a `wasm32-wasip1` +/// edge runtime, whose request and response types cannot leave the thread +/// they were made on, implement [`DidHttpClient`] and await the client. +pub type LocalBoxFuture<'a, T> = Pin + 'a>>; + /// The HTTP method used for a request to the 51Did endpoints. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HttpMethod { @@ -68,27 +80,62 @@ pub struct DidHttpResponse { /// /// Implementations MUST be `Send + Sync`, so one client can serve many /// threads, which is the same rule the cloud request engine's transport -/// carries. +/// carries. The future `send` returns is a [`LocalBoxFuture`] and is +/// deliberately not required to be `Send`, so a host whose request or +/// response types cannot cross threads can still implement it. Write `send` +/// by hand, as in the example, boxing the body with `Box::pin`. +/// +/// # Example +/// +/// ``` +/// use fodid_client::{ +/// DidHttpClient, DidHttpRequest, DidHttpResponse, HttpMethod, LocalBoxFuture, +/// }; +/// +/// struct HostTransport; +/// +/// impl DidHttpClient for HostTransport { +/// fn send<'a>( +/// &'a self, +/// request: &'a DidHttpRequest, +/// ) -> LocalBoxFuture<'a, Result> { +/// Box::pin(async move { +/// // Hand request.url, request.form (url-encoded for a POST) +/// // and request.user_agent to the host's own fetch, await it, +/// // then return the status and body it answered with. +/// let _ = (request.method == HttpMethod::Post, &request.url); +/// Err("not connected in this example".to_string()) +/// }) +/// } +/// } +/// ``` pub trait DidHttpClient: Send + Sync { - /// Sends the request and returns whatever the server answered, whatever - /// the status. + /// Sends the request and resolves to whatever the server answered, + /// whatever the status. The future borrows the request and the + /// transport for `'a`. /// - /// Return `Err` with a human readable message ONLY when the request did - /// not complete, being a connection failure, a timeout, or an answer + /// Resolve to `Err` with a human readable message ONLY when the request + /// did not complete, being a connection failure, a timeout, or an answer /// that could not be read. A status the caller did not want is still a /// completed request and comes back as `Ok`, because the client decides /// what each status means and says so in its own words. - fn send(&self, request: &DidHttpRequest) -> Result; + fn send<'a>( + &'a self, + request: &'a DidHttpRequest, + ) -> LocalBoxFuture<'a, Result>; } -/// The built-in [`DidHttpClient`], backed by a blocking [`reqwest`] client. +/// The built-in [`DidHttpClient`], backed by an asynchronous [`reqwest`] +/// client with rustls. /// /// Compiled only with the `reqwest-client` feature, which is off by default /// so the crate builds for `wasm32-wasip1` and so a caller that supplies its -/// own transport pulls in no HTTP stack it does not want. +/// own transport pulls in no HTTP stack it does not want. The reqwest client +/// runs on a tokio runtime, so a call through this transport is awaited from +/// inside one. #[cfg(feature = "reqwest-client")] pub struct ReqwestClient { - client: reqwest::blocking::Client, + client: reqwest::Client, } #[cfg(feature = "reqwest-client")] @@ -96,7 +143,7 @@ impl ReqwestClient { /// Creates a client with the given request timeout. A zero timeout means /// no timeout. pub fn new(timeout: Duration) -> Result { - let mut builder = reqwest::blocking::Client::builder(); + let mut builder = reqwest::Client::builder(); if !timeout.is_zero() { builder = builder.timeout(timeout); } @@ -117,26 +164,26 @@ impl Default for ReqwestClient { #[cfg(feature = "reqwest-client")] impl DidHttpClient for ReqwestClient { - fn send(&self, request: &DidHttpRequest) -> Result { - let builder = match request.method { - HttpMethod::Get => self.client.get(&request.url), - HttpMethod::Post => self.client.post(&request.url).form(&request.form), - }; - let response = builder - .header("User-Agent", &request.user_agent) - .send() - .map_err(|e| format!("failed to send request to '{}': {e}", request.url))?; - let status = response.status().as_u16(); - let body = response - .text() - .map_err(|e| format!("failed to read the answer from '{}': {e}", request.url))?; - Ok(DidHttpResponse { status, body }) + fn send<'a>( + &'a self, + request: &'a DidHttpRequest, + ) -> LocalBoxFuture<'a, Result> { + Box::pin(async move { + let builder = match request.method { + HttpMethod::Get => self.client.get(&request.url), + HttpMethod::Post => self.client.post(&request.url).form(&request.form), + }; + let response = builder + .header("User-Agent", &request.user_agent) + .send() + .await + .map_err(|e| format!("failed to send request to '{}': {e}", request.url))?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|e| format!("failed to read the answer from '{}': {e}", request.url))?; + Ok(DidHttpResponse { status, body }) + }) } } - -// `Duration` is used by the reqwest constructor only, so without that feature -// the import would be dead. Naming it here keeps one import line rather than -// a cfg on the use statement. -#[cfg(not(feature = "reqwest-client"))] -#[allow(dead_code)] -type UnusedDuration = Duration; diff --git a/fodid-client/src/lib.rs b/fodid-client/src/lib.rs index 31d9fea..56290fa 100644 --- a/fodid-client/src/lib.rs +++ b/fodid-client/src/lib.rs @@ -75,14 +75,25 @@ //! check did not pass, as a [`SignatureCheck`], and only //! [`SignatureCheck::Invalid`] means the identifier should be distrusted. //! +//! ## Awaiting the client +//! +//! Every method that may reach the network is `async` and is awaited. The +//! crate carries no async runtime of its own, so the futures run on +//! whatever runtime the host has, and they are not required to be `Send`, +//! so a single-threaded host such as a `wasm32-wasip1` edge runtime can +//! await them. Concurrent callers that each find the key cache needs +//! fetching share one fetch rather than each making their own. +//! //! ## Transport //! -//! Every request goes through the [`DidHttpClient`] trait. The crate builds -//! without any network stack by default, so it compiles for -//! `wasm32-wasip1` and a host such as an edge runtime supplies its own -//! transport through [`DidClientBuilder::http_client`]. The `reqwest-client` -//! feature turns on the built-in `ReqwestClient`, a blocking `reqwest` -//! client, which the builder uses when no transport is given. +//! Every request goes through the [`DidHttpClient`] trait, whose one +//! method returns a [`LocalBoxFuture`]. The crate builds without any +//! network stack by default, so it compiles for `wasm32-wasip1` and a host +//! such as an edge runtime supplies its own transport through +//! [`DidClientBuilder::http_client`]. The `reqwest-client` feature turns on +//! the built-in `ReqwestClient`, an asynchronous `reqwest` client with +//! rustls that runs on a tokio runtime, which the builder uses when no +//! transport is given. //! //! Credentials never travel in a URL. The resource key is part of the //! route, as the endpoints accept, and the licence key travels only in the @@ -95,7 +106,7 @@ //! use fodid::FodId; //! use fodid_client::{ContextOutcome, DidClient, DidHttpClient}; //! -//! # fn run( +//! # async fn run( //! # transport: Arc, //! # encoded_51did: &str, //! # sealed_result: &str, @@ -111,7 +122,7 @@ //! let fod_id = FodId::from_base64(encoded_51did)?; //! //! // Step one happened in the browser. Step two is the redemption. -//! let outcome = client.redeem(&fod_id, sealed_result, None)?; +//! let outcome = client.redeem(&fod_id, sealed_result, None).await?; //! match outcome.context() { //! ContextOutcome::Verified => { /* same connection as at creation */ } //! ContextOutcome::Mismatch => { @@ -140,7 +151,7 @@ pub use client::{ KEY_CACHE_LIFETIME, MAXIMUM_ENCODED_LENGTH, USER_AGENT, }; pub use error::{Error, Result}; -pub use http::{DidHttpClient, DidHttpRequest, DidHttpResponse, HttpMethod}; +pub use http::{DidHttpClient, DidHttpRequest, DidHttpResponse, HttpMethod, LocalBoxFuture}; pub use key::{ candidates_for_date, in_force_at, parse_keys, DidPublicKey, BOUNDARY_TOLERANCE_MINUTES, };