diff --git a/Cargo.lock b/Cargo.lock index 16014bd..6a29ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,18 @@ dependencies = [ "ureq", ] +[[package]] +name = "fodid-client" +version = "4.5.0" +dependencies = [ + "chrono", + "fodid", + "reqwest", + "serde_json", + "thiserror", + "tokio", +] + [[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..8acab45 --- /dev/null +++ b/fodid-client/Cargo.toml @@ -0,0 +1,47 @@ +[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 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] +# 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 +# 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 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 = ["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 +# 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..a2bcba9 --- /dev/null +++ b/fodid-client/README.md @@ -0,0 +1,250 @@ +# 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 with a tokio runtime. + +```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. + +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 +use std::sync::Arc; +use fodid::FodId; +use fodid_client::{ContextOutcome, DidClient, DidHttpClient, FactorOutcome}; + +async 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).await?; + 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. 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}; + +async 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).await? { + 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).await?; + 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 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, 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()) + }) + } +} +``` + +### 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..b9e893f --- /dev/null +++ b/fodid-client/src/client.rs @@ -0,0 +1,1545 @@ +/* ********************************************************************* + * 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 core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll, Waker}; +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, 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 +/// 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. +/// +/// 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, + 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, + generation: 0, + fetching: false, + waiters: Vec::new(), + }), + }) + } +} + +#[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 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 + /// 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 async fn public_key_for(&self, fod_id: &FodId) -> Result> { + let date = fod_id.date(); + let keys = self.keys_covering(date).await?; + 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 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 + /// 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 async fn verify_signature_detailed(&self, fod_id: &FodId) -> Result { + let date = fod_id.date(); + let keys = self.keys_covering(date).await?; + 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 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).await + } + + /// 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 async fn verify_encoded(&self, fod_id: &str) -> Result { + validate_encoded_value(fod_id)?; + self.verify_encoded_unchecked(fod_id).await + } + + 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 + // 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()).await?; + 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 async 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) + .await + } + + /// 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 async 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) + .await + } + + async 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).await?; + 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. + async fn keys_covering(&self, date: DateTime) -> Result> { + self.keys_where(|cache| match &cache.keys { + None => true, + 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. + } + } + + fn needs_refresh( + &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) + } + + /// 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()).await?; + if response.status != 200 { + return Err(unexpected("key", &response)); + } + 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> { + // 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()) + } + + async 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).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(); + } + } +} + +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::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)] + 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<'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(), + }) + }) + } + } + + /// 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. + + #[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().await.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")) + ); + } + + #[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().await.unwrap_err(); + match error { + Error::UnexpectedStatus { + endpoint, status, .. + } => { + assert_eq!(endpoint, "key"); + assert_eq!(status, 500); + } + other => panic!("expected UnexpectedStatus, got {other}"), + } + } + + #[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"), + "{error}" + ); + } + + #[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) + .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"); + } + + #[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)]); + 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).await.unwrap(); + *now.lock().unwrap() += KEY_CACHE_LIFETIME - Duration::minutes(1); + 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).await.unwrap(); + assert_eq!(http.requests().len(), 2, "stale, so 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. + 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) + .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) + .await + .unwrap(), + SignatureCheck::NoKey + ); + assert_eq!( + http.requests().len(), + 3, + "the signature check looked again too" + ); + } + + #[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 + // 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) + .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. + + #[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) + .await + .unwrap(), + SignatureCheck::Verified + ); + assert!(client.verify_signature(&fixture.fod_id).await.unwrap()); + } + + #[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![( + 200, + &fixture.keys_json_with(&other), + )])); + assert_eq!( + client + .verify_signature_detailed(&fixture.fod_id) + .await + .unwrap(), + SignatureCheck::Invalid + ); + assert!(!client.verify_signature(&fixture.fod_id).await.unwrap()); + } + + #[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) + .await + .unwrap(), + SignatureCheck::KeyUnusable + ); + } + + // The online verify call. + + #[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).await.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); + } + + #[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()).await.unwrap()); + } + + #[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()).await.unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(ref m) if m == "first problem second problem"), + "{error}" + ); + } + + #[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).await.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).await.unwrap_err(); + assert!(matches!(error, Error::UnexpectedStatus { .. }), "{error}"); + } + + #[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).await.unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(_)), + "{value:?}: {error}" + ); + 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).await.unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(ref m) if m.contains("too long")), + "{error}" + ); + assert!(http.requests().is_empty(), "nothing was sent"); + } + + // Redeem. + + #[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) + .await + .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); + } + + #[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]; + 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")); + } + + #[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) + .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); + } + + #[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) + .await + .unwrap(); + assert_eq!(result.context(), ContextOutcome::Unconfirmed); + assert_eq!(result.status(), 503); + } + + #[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) + .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) + .await + .unwrap_err(); + assert!( + matches!(error, Error::InvalidArgument(ref m) if m == "plain refusal"), + "{error}" + ); + } + + #[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) + .await + .unwrap_err(); + assert!( + matches!(error, Error::NotSupported(ref e) if e == ENDPOINT), + "{error}" + ); + } + + #[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) + .await + .unwrap_err(); + assert!( + matches!( + error, + Error::UnexpectedStatus { + endpoint: "redeem", + status: 502, + .. + } + ), + "{error}" + ); + } + + #[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) + .await + .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..e66244c --- /dev/null +++ b/fodid-client/src/http.rs @@ -0,0 +1,189 @@ +/* ********************************************************************* + * 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 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 { + /// 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. 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 resolves to whatever the server answered, + /// whatever the status. The future borrows the request and the + /// transport for `'a`. + /// + /// 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<'a>( + &'a self, + request: &'a DidHttpRequest, + ) -> LocalBoxFuture<'a, Result>; +} + +/// 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. 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::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::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<'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 }) + }) + } +} 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..56290fa --- /dev/null +++ b/fodid-client/src/lib.rs @@ -0,0 +1,172 @@ +/* ********************************************************************* + * 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. +//! +//! ## 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, 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 +//! 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}; +//! +//! # async 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).await?; +//! 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, LocalBoxFuture}; +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()); + } +}