diff --git a/README.md b/README.md
index dd76b1cf1..900f74bc4 100644
--- a/README.md
+++ b/README.md
@@ -131,8 +131,15 @@ and on our [website](https://51degrees.com/documentation/_examples__device_detec
| pipeline.developer-examples.onpremise-engine | Shows how to modify SimpleFlowElement to make use of the 'engine' functionality and use a custom data file to map dates to star signs rather than relying on hard coded data. |
| pipeline.developer-examples.clientside-element | Shows how to modify SimpleFlowElement to request the data of birth from the user using client-side JavaScript. |
| pipeline.developer-examples.clientside-element-mvc | An example project showing how to use the code from SimpleClientSideElement in a Java web application using the Model-View-Controller Pattern. |
-| pipeline.developer-examples.cloud-engine | Shows how to modify SimpleFlowElement to perform the star sign lookup via a cloud service rather than locally. |
+| pipeline.developer-examples.cloud-engine | Shows how to modify SimpleFlowElement to perform the star sign lookup via a cloud service rather than locally. The endpoint and resource key come from `FOD_CLOUD_API_URL` and `_51DEGREES_RESOURCE_KEY` when set, otherwise the star sign service the example was written for. |
| pipeline.developer-examples.usage-sharing | Shows how to share usage with 51Degrees. This helps us to keep our products up to date and accurate. |
+| pipeline.developer-examples.fodid | Shows how to read a 51Did, and, in a web demo, how a browser creates and verifies one against the 51Degrees cloud and the server redeems the encrypted result, so that the creator context proves the identifier is being presented by the browser it was created on. The module README describes the flow, the environment variables, what a run costs and the redeem call to copy into your own server. |
+
+### Pointing the cloud examples at another host
+
+Every example that calls the 51Degrees cloud takes its endpoint from the `FOD_CLOUD_API_URL` environment variable, which is the cloud API base including the `/api/v4/` segment and defaults to `https://cloud.51degrees.com/api/v4/` (the cloud-engine example defaults to the star sign service it was written for, as its row above says). This is the same variable the cloud request engine in this repository honours, so setting it once points every example, and any pipeline built with the engine, at the same place. The resource key comes from `_51DEGREES_RESOURCE_KEY`, or the older `RESOURCE_KEY`.
+
+A host other than cloud.51degrees.com would be used to (a) use an on premise web server, or (b) use a privately hosted version of the 51Degrees cloud for performance reasons. This is the private hosting option of the cloud service. Both run the same service, so the examples work unchanged.
diff --git a/pipeline.developer-examples/pipeline.developer-examples.cloud-engine/src/main/java/pipeline/developerexamples/cloudengine/Main.java b/pipeline.developer-examples/pipeline.developer-examples.cloud-engine/src/main/java/pipeline/developerexamples/cloudengine/Main.java
index 4dce38cf9..819bcee46 100644
--- a/pipeline.developer-examples/pipeline.developer-examples.cloud-engine/src/main/java/pipeline/developerexamples/cloudengine/Main.java
+++ b/pipeline.developer-examples/pipeline.developer-examples.cloud-engine/src/main/java/pipeline/developerexamples/cloudengine/Main.java
@@ -38,13 +38,66 @@ public class Main {
private static final ILoggerFactory loggerFactory = LoggerFactory.getILoggerFactory();
private static final HttpClient httpClient = new HttpClientDefault();
+ /**
+ * The star sign service this example was written for, and its
+ * resource key, used when nothing else is set.
+ */
+ private static final String STAR_SIGN_ENDPOINT =
+ "https://51degrees.com/starsign/api/";
+ private static final String STAR_SIGN_RESOURCE_KEY = "cloudexample";
+
+ /**
+ * The cloud endpoint, taken from FOD_CLOUD_API_URL when that is set,
+ * which is the variable every 51Degrees cloud example honours and the
+ * one the cloud request engine builder reads by itself when no
+ * endpoint is given. A host other than cloud.51degrees.com would be
+ * used to (a) use an on premise web server, or (b) use a privately
+ * hosted version of the 51Degrees cloud for performance reasons,
+ * which is the private hosting option of the cloud service. Both run
+ * the same service, so the example works unchanged. Normalised to
+ * end in one slash so the builder appends its three paths to it
+ * directly.
+ */
+ static String endpoint() {
+ String value = env("FOD_CLOUD_API_URL");
+ if (value == null) {
+ return STAR_SIGN_ENDPOINT;
+ }
+ while (value.endsWith("/")) {
+ value = value.substring(0, value.length() - 1);
+ }
+ return value + "/";
+ }
+
+ /**
+ * The resource key from _51DEGREES_RESOURCE_KEY, or the older
+ * RESOURCE_KEY, so the example can be pointed at a host whose keys
+ * are its own. Otherwise the key of the star sign service.
+ */
+ static String resourceKey() {
+ String value = env("_51DEGREES_RESOURCE_KEY");
+ if (value == null) {
+ value = env("RESOURCE_KEY");
+ }
+ return value == null ? STAR_SIGN_RESOURCE_KEY : value;
+ }
+
+ static String env(String name) {
+ String value = System.getenv(name);
+ return value == null || value.trim().isEmpty() ? null : value;
+ }
+
public static class Example {
public void run() throws Exception {
//! [usage]
+ // The endpoint comes from FOD_CLOUD_API_URL when it is set, so
+ // the example can be pointed at an on premise web server or a
+ // privately hosted version of the 51Degrees cloud, and
+ // otherwise at the star sign service it was written for.
CloudRequestEngine cloudRequestEngine =
new CloudRequestEngineBuilder(loggerFactory, httpClient)
- .setEndpoint("http://51degrees.com/starsign/api/")
- .setResourceKey("cloudexample")
+ .setEndpoint(endpoint())
+ .setResourceKey(resourceKey())
.build();
SimpleCloudEngine ageElement =
@@ -64,10 +117,22 @@ public void run() throws Exception {
.addEvidence("cookie.date-of-birth", dob)
.process();
- System.out.println("With a date of birth of " +
- dob +
- ", your star sign is " +
- flowData.getFromElement(ageElement).getStarSign() + ".");
+ String starSign =
+ flowData.getFromElement(ageElement).getStarSign();
+ if (starSign == null) {
+ // The service answered but offers no star sign
+ // product, which is what the 51Degrees cloud says,
+ // because star signs are only served by the example
+ // service. The connection, the property and evidence
+ // key negotiation and the request itself all worked.
+ System.out.println("The cloud service at " + endpoint()
+ + " does not offer the star sign product, so no "
+ + "star sign is available for a date of birth of "
+ + dob + ".");
+ } else {
+ System.out.println("With a date of birth of " + dob
+ + ", your star sign is " + starSign + ".");
+ }
}
//! [usage]
}
diff --git a/pipeline.developer-examples/pipeline.developer-examples.cloud-engine/src/main/java/pipeline/developerexamples/cloudengine/flowelements/SimpleCloudEngine.java b/pipeline.developer-examples/pipeline.developer-examples.cloud-engine/src/main/java/pipeline/developerexamples/cloudengine/flowelements/SimpleCloudEngine.java
index 3a8a008f3..89cd49481 100644
--- a/pipeline.developer-examples/pipeline.developer-examples.cloud-engine/src/main/java/pipeline/developerexamples/cloudengine/flowelements/SimpleCloudEngine.java
+++ b/pipeline.developer-examples/pipeline.developer-examples.cloud-engine/src/main/java/pipeline/developerexamples/cloudengine/flowelements/SimpleCloudEngine.java
@@ -90,11 +90,19 @@ protected void processEngine(FlowData data, StarSignData aspectData) {
CloudRequestData requestData = data.getFromElement(cloudRequestEngine);
String json = requestData.getJsonResponse();
- // Extract data from json to the aspectData instance.
+ // Extract data from json to the aspectData instance. A cloud
+ // service answers only for the products the resource key covers,
+ // so a response without the star sign product leaves the value
+ // unset rather than failing the request, in the same way the
+ // 51Degrees cloud engines report a property the key does not
+ // cover as not available.
JSONObject jsonObj = new JSONObject(json);
- JSONObject deviceObj = jsonObj.getJSONObject("starsign");
-
- starSignData.setStarSign(deviceObj.getString("starsign"));
+ if (jsonObj.has("starsign")) {
+ JSONObject starSignObj = jsonObj.getJSONObject("starsign");
+ starSignData.setStarSign(starSignObj.optString("starsign", null));
+ } else {
+ starSignData.setStarSign(null);
+ }
}
@Override
diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/README.md b/pipeline.developer-examples/pipeline.developer-examples.fodid/README.md
new file mode 100644
index 000000000..e7cb93bf7
--- /dev/null
+++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/README.md
@@ -0,0 +1,161 @@
+# 51Did examples
+
+This module holds the developer examples for the 51Did package
+(`pipeline.did`). There are two programs, both in the package
+`pipeline.developerexamples.fodid`.
+
+| Program | What it shows |
+| --- | --- |
+| `Main` | Reads a 51Did offline. Builds a sample identifier in process, parses it back with `FodId` and shows that the value is stable while the envelope changes on every issue. Needs no cloud access. |
+| `CreatorContextDemoServer` | Serves a small web page that creates a 51Did in the browser, verifies it from the browser, and redeems the encrypted creator context result on this server with `DidClient`, which is the only place the licence key lives. |
+
+## Creator context
+
+Every 51Did the 51Degrees cloud issues carries a creator context, which
+binds the identifier to the browser and connection it was created on.
+The creator context only makes sense from a browser, because a program
+verifying its own connection checks itself against itself, so the demo
+is a web page and a server. The flow against the cloud has three steps:
+
+1. **Create** a 51Did by calling the `json` endpoint, which issues an
+ identifier for the calling connection.
+2. **Verify** it with `verify-full`, which returns both the signature
+ outcome and the creator context verdict only as an encrypted
+ `result` that the caller cannot read or forge. (A deployment
+ holding no context secret answers in the open instead.)
+3. **Redeem** the encrypted result with `redeem`, presenting the 51Did,
+ the encrypted result and the account's licence key, and receive the
+ true creator context verdict, when the verification happened
+ (`verifiedAt`) and how long ago that was (`secondsSinceVerified`).
+
+Steps 1 and 2 run in the visitor's browser, so the cloud observes the
+browser's live connection, and the page relays the encrypted result to
+your server. Step 3 runs on your server, which is the party holding
+the licence key. A single-use `challenge` issued by the server per page
+load is bound through both steps by the cloud.
+
+The demo server uses the web server that ships with the JDK, the
+`pipeline.did` package from this repository for the 51Did handling, and
+`org.json` to build the JSON it answers the page with.
+
+### What you copy into your own server
+
+The only server-side part of the flow is the redeem call, which adds
+the licence key the browser never sees. The `redeem` method in
+`CreatorContextDemoServer.java` is that call, and these are its
+essential lines. `DidClient` is built once at start-up from the
+resource key and the licence key (it reads `FOD_CLOUD_API_URL` itself
+and falls back to the public cloud), and `did`, `result` and
+`challenge` are what the page passed on from `verify-full`:
+
+```java
+DidClient client = new DidClient(resourceKey, licenceKey);
+```
+
+```java
+// The 51Did arrives from the page in the URL-safe base64 alphabet.
+FodId fodId = FodId.fromBase64(did);
+
+// Offline, against the cloud's published key for the identifier's date.
+// No use is charged, because the client holds the keys.
+boolean genuine = client.verifySignature(fodId);
+
+// Server side, with the licence key. One use.
+RedeemResult redeemed = client.redeem(fodId, result, challenge);
+RedeemResult.Context context = redeemed.getContext();
+```
+
+The handler answers the page in the cloud's own shape, `signature`,
+`context`, `factors` when present, `verifiedAt` and
+`secondsSinceVerified`, built from the typed result, with one field
+added, `serverSignature`, being the offline check. A malformed 51Did
+answers 400, a host without the creator context answers 404 with a
+text body, and an unreachable cloud answers 502 with `{ "error": ... }`.
+A production server would also remember the challenge it issued and
+reject a redemption carrying any other, which the demo keeps out of
+scope.
+
+### Environment variables
+
+| Variable | Meaning |
+| --- | --- |
+| `_51DEGREES_RESOURCE_KEY` | Required. The resource key of the page, public by nature. The older name `RESOURCE_KEY` is read when this one is not set. |
+| `_51DEGREES_LICENSE_KEY` | Optional. A licence key of the same account, server side only. The older name `LICENSE_KEY` is read when this one is not set. Only an account that holds licence keys needs one to redeem, because the licence key is what keeps redemption to the acting party's own servers, so an account holding none redeems without one. |
+| `FOD_CLOUD_API_URL` | Optional. The cloud API base including the `/api/v4/` segment, defaulting to `https://cloud.51degrees.com/api/v4/`. This is the same variable the cloud request engine in this repository honours, so setting it once points every 51Degrees example at the same place. A trailing slash is added where missing. A host other than cloud.51degrees.com would be used to (a) use an on premise web server, or (b) use a privately hosted version of the 51Degrees cloud for performance reasons, which is the private hosting option of the cloud service. Both run the same service, so the examples work unchanged. |
+| `PORT` | The port to listen on, defaulting to `5100`. |
+
+### How to run
+
+With Maven, from the root of the repository, build the module and run
+the demo server by its class name:
+
+```sh
+mvn -pl pipeline.developer-examples/pipeline.developer-examples.fodid -am -DskipTests compile
+mvn -pl pipeline.developer-examples/pipeline.developer-examples.fodid exec:java -Dexec.mainClass=pipeline.developerexamples.fodid.CreatorContextDemoServer
+```
+
+The module depends on `pipeline.did` from this repository, so the
+first command builds that package too.
+
+The demo server prints the address to open, `http://localhost:5100/`
+by default. A creator context verdict of `nocontext` is a normal
+outcome rather than an error, because a self-hosted container may be
+configured not to emit the creator context, so an identifier it issued
+has none to check, and the page shows it as the verdict. A 404 from
+`verify-full` or `redeem` means the host answering does not support
+the creator context at all, and the page reports the check as not
+supported by this host. Any other status outside 2xx, or a body that
+is not JSON, is shown on the page as a failure naming the status and
+what the service said.
+
+### What a run costs
+
+Every call the demo makes to the cloud is one use against the
+subscription behind the resource key. Checking a 51Did from the
+browser makes two, verify-full from the page and redeem from the
+server, so a browser-based context check is two uses every time.
+Checking only the signature with `verify` is one use. The server's
+offline signature check costs nothing beyond the one fetch of the
+public keys the client makes on first use.
+
+### The web demo, and the copy-and-paste proof
+
+The demo server serves `page.html`, injecting a fresh challenge per
+page load, and holds the licence key. The browser creates the 51Did
+and calls `verify-full`, the first verification step, so the cloud
+observes the browser's live connection, then the page hands the
+encrypted result to its own server, which redeems it with the licence
+key as the second step.
+
+The creation call requests every 51Did identifier in one request, and
+the page shows all six in a table: the probabilistic pair
+(`IdProbGlobal` and `IdProbLic`) derived from the connection, the
+deterministic hashed-email pair (`IdHemGlobal` and `IdHemLic`) derived
+from email evidence supplied as `id.email` (the demo sends
+`demo@51did.example`, so the pair is the same on every device that
+email appears on), and the random pair (`IdRandGlobal` and
+`IdRandLic`). Global identifiers are shared across customers, licensed
+ones are scoped to the licence key. The verification and creator
+context flow then carries the licensed probabilistic identifier through
+both steps, or the global one where the account holds no licence keys.
+
+Once the 51Did has fully validated, the page shows a **copy-and-paste
+section** with a link carrying the same 51Did, and an explanation of
+what will happen next. Open that link in a **different browser** and
+the same page loads with the same identifier: the signature still
+verifies and the identifier unpacks, because it is genuine, but the
+creator context does **not** validate, because the context binds the
+identifier to the browser and connection it was created on. That
+visible failure is the demonstration that matters, a copied or stolen
+identifier caught at presentation with nothing stored server side.
+Opening the link in the same browser is not the demonstration, since
+the same browser presents the same context and may still verify.
+
+To demonstrate across two devices, serve on an address both can reach
+and open the copied link on the second device.
+
+### The stylesheet
+
+The vendored `examples-main.min.css` beside `page.html` under
+`src/main/resources/fodid/creator-context/` is the design system build
+and is refreshed by the `update-example-assets` step of common-ci.
diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml b/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml
index 466b3d5ec..1f943c66a 100644
--- a/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml
+++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml
@@ -41,6 +41,13 @@
${project.version}pipeline.did
+
+
+ org.json
+ json
+ org.junit.jupiterjunit-jupiter
diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/CreatorContextDemoServer.java b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/CreatorContextDemoServer.java
new file mode 100644
index 000000000..22a06bbc6
--- /dev/null
+++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/CreatorContextDemoServer.java
@@ -0,0 +1,413 @@
+/* *********************************************************************
+ * 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.
+ * ********************************************************************* */
+
+package pipeline.developerexamples.fodid;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import com.swancommunity.owid.OwidException;
+import fiftyone.pipeline.did.DidClient;
+import fiftyone.pipeline.did.DidHttpException;
+import fiftyone.pipeline.did.DidNotSupportedException;
+import fiftyone.pipeline.did.FodId;
+import fiftyone.pipeline.did.RedeemResult;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 51Did creator context demo server. Serves a page that runs the 51Did
+ * flow the way production does, and redeems the encrypted result server
+ * side with {@link DidClient}, adding the licence key the browser never
+ * sees.
+ *
+ * Every 51Did the 51Degrees cloud issues carries a creator context, which
+ * binds the identifier to the browser and connection it was created on.
+ * The flow has three steps:
+ *
+ *
Create a 51Did by calling the {@code json} endpoint, which
+ * issues an identifier for the calling connection.
+ *
Verify it with {@code verify-full}, which returns both the
+ * signature outcome and the creator context verdict only as an encrypted
+ * {@code result} that the caller cannot read or forge. (A deployment
+ * holding no context secret answers in the open instead.)
+ *
Redeem the encrypted result with {@code redeem}, presenting
+ * the 51Did, the encrypted result and the account's licence key, and
+ * receive the true creator context verdict, when the verification
+ * happened ({@code verifiedAt}) and how long ago that was
+ * ({@code secondsSinceVerified}).
+ *
+ * The browser creates the 51Did and calls {@code verify-full}, so the
+ * cloud observes the browser's live connection, then the page hands the
+ * encrypted result to this server, which redeems it with the licence key.
+ * A fresh challenge is issued per page load and bound through both steps
+ * by the cloud. A production server would also remember the value it
+ * issued and reject a redemption carrying any other, which this demo
+ * keeps out of scope. Once the 51Did has validated, the page offers a link
+ * carrying the same identifier. Opened in a different browser, the
+ * signature still verifies but the creator context does not validate,
+ * which is a copied or stolen identifier caught at presentation with
+ * nothing stored server side.
+ *
+ * What a run costs. Every call made to the cloud is one use against
+ * the subscription behind the resource key. A browser check of a 51Did
+ * makes two, verify-full from the page and redeem from this server, so
+ * each browser-based context check is two uses. The offline signature
+ * check this server also makes costs nothing, because the client fetches
+ * the cloud's public keys once and holds them.
+ *
+ * The web server is the one that ships with the JDK. The page and the
+ * stylesheet are read from the classpath, under
+ * {@code fodid/creator-context/} in this module's resources. Environment
+ * variables:
+ *
+ *
{@code _51DEGREES_RESOURCE_KEY}, or the older {@code RESOURCE_KEY},
+ * required. The resource key of the page, public by nature.
+ *
{@code _51DEGREES_LICENSE_KEY}, or the older {@code LICENSE_KEY},
+ * optional. A licence key of the same account, server side only.
+ *
{@code FOD_CLOUD_API_URL}, optional. The cloud API base including
+ * the {@code /api/v4/} segment, defaulting to
+ * {@code https://cloud.51degrees.com/api/v4/}. This is the same variable
+ * the cloud request engine of this package honours.
+ *
{@code PORT}, optional, defaulting to {@code 5100}.
+ *
+ * Then open {@code http://localhost:5100/}.
+ */
+public class CreatorContextDemoServer {
+
+ /** Where the page and stylesheet live on the classpath. */
+ static final String RESOURCES = "/fodid/creator-context/";
+
+ static final String RESOURCE =
+ env("_51DEGREES_RESOURCE_KEY", "RESOURCE_KEY");
+ static final String LICENCE =
+ env("_51DEGREES_LICENSE_KEY", "LICENSE_KEY");
+
+ /**
+ * The one client, built at start-up and shared by every request,
+ * because it holds the cloud's public keys.
+ */
+ static DidClient client;
+
+ public static void main(String[] args) throws Exception {
+ if (RESOURCE == null) {
+ System.err.println("Set _51DEGREES_RESOURCE_KEY (or the older "
+ + "RESOURCE_KEY) to the resource key of the page.");
+ System.exit(1);
+ }
+ if (LICENCE == null) {
+ // Only an account that holds licence keys needs one to
+ // redeem, because the licence key is what keeps redemption to
+ // the acting party's own servers. An account holding none has
+ // nothing to check against, so the demo runs without it.
+ // Saying so here means an account that DOES hold licence
+ // keys, run without one, is diagnosed at start-up rather than
+ // by an unreadable verdict three steps later that looks like
+ // a cryptographic failure.
+ System.out.println("No _51DEGREES_LICENSE_KEY set. Redemption "
+ + "will work where the account holds no licence keys, and "
+ + "will report the context unreadable where it holds "
+ + "some.");
+ }
+ // The client reads FOD_CLOUD_API_URL itself when no endpoint is
+ // given, and falls back to the public cloud. A host other than
+ // cloud.51degrees.com would be used to (a) use an on premise web
+ // server, or (b) use a privately hosted version of the 51Degrees
+ // cloud for performance reasons, which is the private hosting
+ // option of the cloud service. Both run the same service, so the
+ // example works unchanged.
+ client = new DidClient(RESOURCE, LICENCE);
+ // Read once here only to fail fast when either resource is
+ // missing from the classpath. The stylesheet is the design system
+ // build, vendored beside the page exactly as the other 51Degrees
+ // web examples vendor it.
+ resource("page.html");
+ resource("examples-main.min.css");
+ int port = Integer.parseInt(envOr("PORT", "5100"));
+ HttpServer server = HttpServer.create(
+ new InetSocketAddress(port), 0);
+ server.createContext("/", CreatorContextDemoServer::servePage);
+ server.createContext("/examples-main.min.css", exchange -> {
+ byte[] css = resource("examples-main.min.css");
+ exchange.getResponseHeaders().set("Content-Type", "text/css");
+ exchange.sendResponseHeaders(200, css.length);
+ exchange.getResponseBody().write(css);
+ exchange.close();
+ });
+ server.createContext("/redeem", CreatorContextDemoServer::redeem);
+ server.start();
+ System.out.println("51Did demo on http://localhost:" + port + "/");
+ }
+
+ static void servePage(HttpExchange exchange) throws IOException {
+ if (!exchange.getRequestURI().getPath().equals("/")) {
+ exchange.sendResponseHeaders(404, -1);
+ exchange.close();
+ return;
+ }
+ byte[] random = new byte[16];
+ new SecureRandom().nextBytes(random);
+ String page = new String(
+ resource("page.html"), StandardCharsets.UTF_8);
+ byte[] body = page
+ .replace("__RESOURCE__", RESOURCE)
+ .replace("__CHALLENGE__", hex(random))
+ .replace("__API__", client.getEndpoint())
+ .getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set(
+ "Content-Type", "text/html; charset=utf-8");
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ }
+
+ static void redeem(HttpExchange exchange) throws IOException {
+ Map query = parse(
+ exchange.getRequestURI().getRawQuery());
+ Answer answer = redeem(
+ client,
+ decode(valueOr(query, "51did")),
+ decode(valueOr(query, "result")),
+ decode(valueOr(query, "challenge")));
+ exchange.getResponseHeaders().set("Content-Type", answer.type);
+ exchange.sendResponseHeaders(answer.status, answer.body.length);
+ exchange.getResponseBody().write(answer.body);
+ exchange.close();
+ }
+
+ /**
+ * The server-side step, and the lines a developer copies into their
+ * own server. The 51Did arrives from the page in the URL-safe base64
+ * alphabet, which {@link FodId#fromBase64(String)} accepts. The
+ * signature is checked offline first, against the cloud's published
+ * key for the identifier's date, then the encrypted result is redeemed
+ * with the licence key, which is added by the client here and only
+ * here, so the browser never sees it.
+ *
+ * The page is answered in the cloud's own shape ({@code signature},
+ * {@code context}, {@code factors} when present, {@code verifiedAt},
+ * {@code secondsSinceVerified}) with one field added,
+ * {@code serverSignature}, being this server's own offline check. The
+ * page ignores fields it does not know.
+ */
+ static Answer redeem(
+ DidClient client, String did, String result, String challenge) {
+ FodId fodId;
+ try {
+ fodId = FodId.fromBase64(did);
+ } catch (OwidException notA51Did) {
+ return Answer.json(400, errors(
+ "'" + did + "' is not a valid Base64-encoded 51Did."));
+ } catch (IllegalArgumentException notA51Did) {
+ return Answer.json(400, errors(
+ "'" + did + "' is not a valid Base64-encoded 51Did."));
+ }
+ try {
+ String serverSignature = client.verifySignature(fodId)
+ ? "verified" : "invalid";
+ RedeemResult redeemed = client.redeem(fodId, result, challenge);
+ return Answer.json(
+ redeemed.getStatusCode(), toJson(redeemed, serverSignature));
+ } catch (DidNotSupportedException unsupported) {
+ // The host does not offer the creator context. The same status
+ // and a text body, which the page reports as not supported by
+ // this host.
+ return Answer.text(404, unsupported.getBody());
+ } catch (IllegalArgumentException malformed) {
+ return Answer.json(400, errors(malformed.getMessage()));
+ } catch (DidHttpException other) {
+ // Relayed as received, so the page sees what the cloud said.
+ return Answer.text(other.getStatusCode(), other.getBody());
+ } catch (IOException unreachable) {
+ return Answer.json(502, new JSONObject()
+ .put("error", String.valueOf(unreachable.getMessage())));
+ }
+ }
+
+ /** The cloud's own shape, plus {@code serverSignature}. */
+ static JSONObject toJson(RedeemResult redeemed, String serverSignature) {
+ JSONObject json = new JSONObject();
+ if (redeemed.getSignature() != RedeemResult.Signature.UNKNOWN) {
+ json.put("signature",
+ redeemed.getSignature() == RedeemResult.Signature.VERIFIED
+ ? "verified" : "invalid");
+ }
+ json.put("context", redeemed.getContextValue());
+ if (redeemed.hasFactors()) {
+ JSONObject factors = new JSONObject();
+ for (Map.Entry factor
+ : redeemed.getFactors().entrySet()) {
+ factors.put(factor.getKey(),
+ factor.getValue() == RedeemResult.Factor.VERIFIED
+ ? "verified" : "mismatch");
+ }
+ json.put("factors", factors);
+ }
+ if (redeemed.getVerifiedAt() != null) {
+ json.put("verifiedAt", DateTimeFormatter.ISO_INSTANT
+ .format(redeemed.getVerifiedAt()));
+ }
+ if (redeemed.getSecondsSinceVerified() != null) {
+ json.put("secondsSinceVerified",
+ redeemed.getSecondsSinceVerified().intValue());
+ }
+ json.put("serverSignature", serverSignature);
+ return json;
+ }
+
+ static JSONObject errors(String message) {
+ return new JSONObject().put("errors", new JSONArray().put(message));
+ }
+
+ /** What the route answers the page with. */
+ static final class Answer {
+
+ final int status;
+ final String type;
+ final byte[] body;
+
+ private Answer(int status, String type, byte[] body) {
+ this.status = status;
+ this.type = type;
+ this.body = body;
+ }
+
+ static Answer json(int status, JSONObject body) {
+ return new Answer(status, "application/json",
+ body.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ static Answer text(int status, String body) {
+ return new Answer(status, "text/plain; charset=utf-8",
+ body.getBytes(StandardCharsets.UTF_8));
+ }
+
+ String bodyText() {
+ return new String(body, StandardCharsets.UTF_8);
+ }
+ }
+
+ /**
+ * A page or stylesheet from the classpath. Read per request rather
+ * than once at start-up, so a demo left running while its page is
+ * rebuilt serves the new copy rather than the version it started
+ * with, which would look exactly like an edit that did not work.
+ * The cost is one small read per request, which is nothing at demo
+ * scale.
+ */
+ static byte[] resource(String name) throws IOException {
+ InputStream stream = CreatorContextDemoServer.class
+ .getResourceAsStream(RESOURCES + name);
+ if (stream == null) {
+ throw new IOException("Resource " + RESOURCES + name
+ + " is missing from the classpath.");
+ }
+ return readAll(stream);
+ }
+
+ /** Reads a stream to its end and closes it. Null reads as empty. */
+ static byte[] readAll(InputStream stream) throws IOException {
+ if (stream == null) {
+ return new byte[0];
+ }
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = stream.read(buffer)) > 0) {
+ out.write(buffer, 0, read);
+ }
+ return out.toByteArray();
+ } finally {
+ stream.close();
+ }
+ }
+
+ /** Splits a raw query string, keeping values percent-encoded. */
+ static Map parse(String rawQuery) {
+ Map query = new HashMap<>();
+ if (rawQuery == null) {
+ return query;
+ }
+ for (String pair : rawQuery.split("&")) {
+ int at = pair.indexOf('=');
+ if (at > 0) {
+ query.put(pair.substring(0, at), pair.substring(at + 1));
+ }
+ }
+ return query;
+ }
+
+ static String valueOr(Map query, String name) {
+ String value = query.get(name);
+ return value == null ? "" : value;
+ }
+
+ /** Lower case hex of the bytes, two characters each. */
+ static String hex(byte[] bytes) {
+ StringBuilder builder = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ builder.append(String.format("%02x", b & 0xFF));
+ }
+ return builder.toString();
+ }
+
+ static String decode(String value) {
+ try {
+ return URLDecoder.decode(value, "UTF-8");
+ } catch (UnsupportedEncodingException impossible) {
+ // UTF-8 is part of every Java runtime. The checked exception
+ // is a formality of the Java 8 signature.
+ throw new IllegalStateException(impossible);
+ }
+ }
+
+ /**
+ * The first of the named environment variables that is set to a
+ * non-blank value, or null when none is.
+ */
+ static String env(String... names) {
+ for (String name : names) {
+ String value = System.getenv(name);
+ if (value != null && value.trim().isEmpty() == false) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ static String envOr(String name, String fallback) {
+ String value = env(name);
+ return value == null ? fallback : value;
+ }
+}
diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/resources/fodid/creator-context/examples-main.min.css b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/resources/fodid/creator-context/examples-main.min.css
new file mode 100644
index 000000000..8007642a3
--- /dev/null
+++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/resources/fodid/creator-context/examples-main.min.css
@@ -0,0 +1 @@
+/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}.a-size--10{width:.1615055829rem;height:.1615055829rem}.a-size--9{width:.1938066995rem;height:.1938066995rem}.a-size--8{width:.2325680394rem;height:.2325680394rem}.a-size--7{width:.2790816472rem;height:.2790816472rem}.a-size--6{width:.3348979767rem;height:.3348979767rem}.a-size--5{width:.401877572rem;height:.401877572rem}.a-size--4{width:.4822530864rem;height:.4822530864rem}.a-size--3{width:.5787037037rem;height:.5787037037rem}.a-size--2{width:.6944444444rem;height:.6944444444rem}.a-size--1{width:.8333333333rem;height:.8333333333rem}.a-size-0{width:1rem;height:1rem}.a-size-1{width:1.2rem;height:1.2rem}.a-size-2{width:1.44rem;height:1.44rem}.a-size-3{width:1.728rem;height:1.728rem}.a-size-4{width:2.0736rem;height:2.0736rem}.a-size-5{width:2.48832rem;height:2.48832rem}.a-size-6{width:2.985984rem;height:2.985984rem}.a-size-7{width:3.5831808rem;height:3.5831808rem}.a-size-8{width:4.29981696rem;height:4.29981696rem}.a-size-9{width:5.159780352rem;height:5.159780352rem}.a-size-10{width:6.1917364224rem;height:6.1917364224rem}.a-size-11{width:7.4300837069rem;height:7.4300837069rem}.a-size-12{width:8.9161004483rem;height:8.9161004483rem}.a-size-13{width:10.6993205379rem;height:10.6993205379rem}.a-size-14{width:12.8391846455rem;height:12.8391846455rem}.a-size-15{width:15.4070215746rem;height:15.4070215746rem}.a-size-16{width:18.4884258895rem;height:18.4884258895rem}.a-size-17{width:22.1861110674rem;height:22.1861110674rem}.a-size-18{width:26.6233332809rem;height:26.6233332809rem}.a-size-19{width:31.9479999371rem;height:31.9479999371rem}.a-size-20{width:38.3375999245rem;height:38.3375999245rem}.a-font-size--10{font-size:.1615055829rem}.a-font-size--9{font-size:.1938066995rem}.a-font-size--8{font-size:.2325680394rem}.a-font-size--7{font-size:.2790816472rem}.a-font-size--6{font-size:.3348979767rem}.a-font-size--5{font-size:.401877572rem}.a-font-size--4{font-size:.4822530864rem}.a-font-size--3{font-size:.5787037037rem}.a-font-size--2{font-size:.6944444444rem}.a-font-size--1{font-size:.8333333333rem}.a-font-size-0{font-size:1rem}.a-font-size-1{font-size:1.2rem}.a-font-size-2{font-size:1.44rem}.a-font-size-3{font-size:1.728rem}.a-font-size-4{font-size:2.0736rem}.a-font-size-5{font-size:2.48832rem}.a-font-size-6{font-size:2.985984rem}.a-font-size-7{font-size:3.5831808rem}.a-font-size-8{font-size:4.29981696rem}.a-font-size-9{font-size:5.159780352rem}.a-font-size-10{font-size:6.1917364224rem}.a-font-size-11{font-size:7.4300837069rem}.a-font-size-12{font-size:8.9161004483rem}.a-font-size-13{font-size:10.6993205379rem}.a-font-size-14{font-size:12.8391846455rem}.a-font-size-15{font-size:15.4070215746rem}.a-font-size-16{font-size:18.4884258895rem}.a-font-size-17{font-size:22.1861110674rem}.a-font-size-18{font-size:26.6233332809rem}.a-font-size-19{font-size:31.9479999371rem}.a-font-size-20{font-size:38.3375999245rem}.a-font--default{font-family:Arial,sans-serif}.a-font--code{font-family:Consolas,monospace}.b-text--hidden{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.b-text--default{font-family:Arial,sans-serif;font-size:1rem;color:#252525}.b-text--count{font-family:Arial,sans-serif;font-size:.6944444444rem;color:#9b9b9b}.b-text--count-pill{font-family:Arial,sans-serif;font-size:.6944444444rem;color:#9b9b9b;border:1px solid #BAC82A;border-radius:28px;padding:.093463879rem .4822530864rem;color:#252525;background-color:#fff;display:inline}.b-text--property-pill{font-family:Arial,sans-serif;font-size:.8333333333rem;color:#646060}.b-text--heading-1{font-family:Arial,sans-serif;font-weight:700;font-size:1.44rem;color:#252525;margin:0 0 1.728rem}.b-text--heading-2{font-family:Arial,sans-serif;font-weight:700;font-size:1.2rem;color:#252525;margin:0 0 1.44rem}.b-text--heading-3{font-family:Arial,sans-serif;font-weight:700;font-size:1rem;color:#252525;margin:0 0 1.2rem}.b-text--code{font-family:Consolas,monospace;font-size:.8333333333rem;color:#646060}.b-text--code-snip{font-family:Consolas,monospace;font-size:.8333333333rem;color:#646060;background-color:#fff;border-radius:5px;border:1px solid #EAEAEA;padding:0 .2325680394rem}.b-text--heading-caps{font-family:Arial,sans-serif;font-size:.6944444444rem;color:#252525;font-weight:400;text-transform:uppercase;letter-spacing:.5px}.b-text--copyright{font-family:Arial,sans-serif;font-size:.6944444444rem;color:#252525}.b-text--progress{font-family:Arial,sans-serif;font-size:.8333333333rem}@media(min-width:769px){.b-text--progress{font-size:1rem}}.b-text--progress{color:#252525;letter-spacing:.7px;text-transform:uppercase;font-weight:700}.b-text--red{color:#cc2b27}body{font-family:Arial,sans-serif;font-size:1rem;color:#252525;margin:0;line-height:1.5}*{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;box-sizing:border-box}.b-link{transition:color .3s ease;color:#252525;text-decoration:underline}.b-link:active,.b-link:hover,.b-link:focus{color:#f5841f;text-decoration:underline}.b-link{display:inline-flex;align-items:center}.b-link img{margin:0 .5787037037rem}.b-link--property{font-family:Arial,sans-serif;font-size:.8333333333rem;color:#646060;color:#9b9b9b;border-bottom:2px dotted #EAEAEA;text-decoration:none}.b-link--property:active,.b-link--property:hover,.b-link--property:focus{color:#646060;border-bottom:2px dotted #646060;text-decoration:none}.b-link--dotted{color:#9b9b9b;border-bottom:2px dotted #EAEAEA;text-decoration:none}.b-link--dotted:active,.b-link--dotted:hover,.b-link--dotted:focus{color:#646060;border-bottom:2px dotted #646060;text-decoration:none}.b-link--unstyled{color:inherit;text-decoration:none}.b-link--unstyled:active,.b-link--unstyled:hover,.b-link--unstyled:focus{color:inherit;text-decoration:none}.b-link--docs{transition:color .3s ease;color:#00aeef;text-decoration:none;font-weight:700}.b-link--docs:active,.b-link--docs:hover,.b-link--docs:focus{text-decoration:underline}a.anchor{scroll-margin-top:128px}.b-btn{font-family:Arial,sans-serif;font-size:1rem;color:#252525;color:inherit;text-decoration:none}.b-btn:active,.b-btn:hover,.b-btn:focus{color:inherit;text-decoration:none}.b-btn{display:inline-flex;justify-content:center;align-items:center;padding:.8333333333rem 1.728rem;border:1px solid #BAC82A;background-color:#cbdb2a;cursor:pointer}.b-btn:active,.b-btn:hover,.b-btn:focus{background-color:#daed1a;border:1px solid #CBDB2A;text-decoration:none}.b-btn.b-btn--disabled,.b-btn:disabled{background-color:#f8f8f8;border:1px solid #EAEAEA;color:#646060;cursor:not-allowed;pointer-events:all!important}@media(min-width:769px){.b-btn--large{padding:1.2rem 2.48832rem}}.b-btn--secondary{background-color:#fff}.b-btn--secondary:active,.b-btn--secondary:hover,.b-btn--secondary:focus{background-color:#fff}.b-btn--block{width:100%}.b-btn__icon{margin-right:1rem}.b-btn__icon--after{margin-right:0;margin-left:1rem}input[type=text]{-webkit-appearance:none;-moz-appearance:none;appearance:none}.b-label{color:#646060;font-size:.8333333333rem;margin-bottom:.4822530864rem;width:100%}.b-input-group{display:flex;flex-direction:column}@media(min-width:769px){.b-input-group{flex-direction:row}}.b-input{font-family:Arial,sans-serif;font-size:1rem;color:#252525;border:solid 1px #646060;padding:.8333333333rem;line-height:1.2}.b-form-group--with-icon .b-input{padding-left:46px}.b-input::placeholder{color:#bfbfbf;opacity:1}.b-input,.b-input--block{width:100%}.b-form-group{display:flex;flex-direction:column}.b-form-group .b-btn{margin-top:1rem}@media(min-width:769px){.b-form-group .b-btn{margin-top:0;margin-left:.8333333333rem}}.b-form-group--with-icon{position:relative}.b-form-group--with-icon .b-icon{position:absolute;width:20px;height:20px;top:14px;left:10px}.c-eg-page{max-width:800px;margin:0 auto;padding:1.44rem 1.2rem}.c-eg-page__title{font-family:Arial,sans-serif;font-weight:700;font-size:1.2rem;color:#252525;margin:0 0 1.44rem}.c-eg-page__heading{font-family:Arial,sans-serif;font-weight:700;font-size:1rem;color:#252525;margin:1.728rem 0 1.2rem}.c-eg-page__lead{margin-bottom:1.44rem}.c-eg-section{margin-bottom:1.728rem}.c-eg-alert{font-family:Arial,sans-serif;font-size:1rem;color:#252525;border:1px solid #CC2B27;border-radius:5px;background-color:#fef1f9;padding:.8333333333rem 1rem;margin:1.2rem 0}.c-eg-alert a{color:#cc2b27;font-weight:700}.c-eg-table{font-family:Arial,sans-serif;font-size:1rem;color:#252525;width:100%;max-width:800px;font-size:.8333333333rem;background-color:#fff;border-collapse:collapse;margin-bottom:1.2rem}.c-eg-table__head .c-eg-table__cell{font-weight:700;border-bottom:3px solid #EAEAEA}.c-eg-table__cell{padding:.5787037037rem .6944444444rem;text-align:left;vertical-align:top;border-bottom:1px solid #EAEAEA}.c-eg-table__cell--key{font-weight:700;white-space:nowrap}.c-eg-table__cell ul{margin:0;padding-left:1rem}.c-eg-table__row--used{background-color:#f9fbe9}.c-eg-table__row--present,.c-eg-table__row--alt{background-color:#f8f8f8}.c-eg-table__action{padding:.4822530864rem .6944444444rem;font-size:.6944444444rem;white-space:nowrap;width:auto}.c-eg-legend{font-size:.6944444444rem;color:#646060;margin-bottom:.6944444444rem}.c-eg-legend__swatch{padding:0 .4822530864rem;border-radius:5px}.c-eg-legend__swatch--used{background-color:#f9fbe9}.c-eg-legend__swatch--present{background-color:#f8f8f8}.c-eg-form{margin-bottom:1.44rem}.c-eg-form__row{display:flex;flex-direction:column}@media(min-width:769px){.c-eg-form__row{flex-direction:row;align-items:flex-end}}.c-eg-form .b-btn{margin-top:.8333333333rem}@media(min-width:769px){.c-eg-form .b-btn{margin-top:0;margin-left:.8333333333rem}}.c-eg-map{margin:1.44rem 0}.c-eg-map__title{font-family:Arial,sans-serif;font-weight:700;font-size:1rem;color:#252525;margin:0 0 1.2rem}.c-eg-map__canvas{height:400px;width:100%;border:1px solid #EAEAEA;border-radius:5px}.c-eg-columns{display:grid;grid-template-columns:1fr;gap:1.44rem}@media(min-width:993px){.c-eg-columns{grid-template-columns:1fr 1fr}}.c-eg-details{margin:1.2rem 0 1.44rem;border:1px solid #EAEAEA;border-radius:5px;padding:0 1rem}.c-eg-details>summary{cursor:pointer;padding:.6944444444rem 0;font-weight:700}.c-eg-button-row{margin:1.2rem 0 1.44rem}.c-eg-message{display:flex;flex-direction:column;align-items:flex-start;gap:.8333333333rem;margin-top:2.0736rem;padding:1rem 1.2rem;background:#f8f8f8;border:1px solid #EAEAEA;border-radius:5px}@media(min-width:769px){.c-eg-message{flex-direction:row;align-items:center;justify-content:space-between;gap:1.2rem}}.c-eg-message__text{margin:0}.c-eg-message__cta{flex-shrink:0}.c-eg-status{font-family:Arial,sans-serif;font-size:1rem;color:#252525;font-weight:700}.c-eg-status--pass{color:#9ba90d}.c-eg-status--fail{color:#cc2b27}.c-eg-status--part{color:#f5841f}.c-eg-status--none{color:#646060;font-weight:400}.c-eg-value{font-family:Consolas,monospace;font-size:.8333333333rem;color:#646060;word-break:break-all}
diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/resources/fodid/creator-context/page.html b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/resources/fodid/creator-context/page.html
new file mode 100644
index 000000000..a6148891d
--- /dev/null
+++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/resources/fodid/creator-context/page.html
@@ -0,0 +1,453 @@
+
+
+
+
+
+
+51Did creator context demo
+
+
+
+
+
+
🔐 51Did creator context demo
+
This page creates a 51Did in your browser,
+ verifies it from your browser, and redeems the encrypted creator
+ context result on its own server, which is the only place the licence
+ key lives.
+
+
Result
+
+
+
+
Step
+
Outcome
+
+
+
+
+
Identifier
+
working…
+
+
+
Signature
+
waiting…
+
+
+
Creator
+ context
+
waiting…
+
+
+
+
+
+
Why
+
+
+
+
+
Check
+
Result
+
+
+
+
+
+
+
+
Your 51Did identifiers
+
One request created every kind at once.
+ The probabilistic pair derives from this connection, the
+ deterministic pair derives from an email address (this demo uses
+ demo@51did.example) so it is the
+ same on every device the same email appears on, and the random pair
+ is created fresh. Global identifiers are shared across customers,
+ licensed ones are scoped to the licence key. The rest of this page
+ carries the licensed probabilistic identifier through
+ verification.
+
+
+
+
Kind
+
Scope
+
Identifier
+
Test
+
+
+
+
+
+
+
+
+
🔗 Prove creator context verification. Copy the
+ link below and open it in a different browser (not just a
+ new tab, a genuinely different browser, or another device). The
+ other browser loads this same page with the same 51Did.
+ The signature will still verify and the identifier will unpack,
+ because it is genuine. But the creator context will not
+ validate, because the context binds the identifier to the browser
+ and connection it was created on, and the other browser is
+ neither. A transplanted 51Degrees identifier contains everything
+ needed to tell if it is being used on a different device or
+ network.
+
+
Another device?
+ This link names localhost, which
+ on another device means that device, so it will not reach this
+ demo. Another browser on this machine is fine. For a second
+ device, open this page by this machine's network address instead
+ and copy the link again.
+
+
+
+
+
+
Transport mismatches will not be identified. This page is
+ calling the service over plain HTTP, so there is no TLS handshake
+ for the service to read, and the transport factor carries its
+ default value at creation and at verification, which always compare
+ as verified. Every other factor is checked as normal. To see the
+ transport factor at work, point the demo at an HTTPS address of the
+ service (the endpoint environment variable in the README) and open
+ the page again.
+
+
+
+
🎯 Detection demonstrated. This page received a
+ 51Did created in a different browser. The signature verified,
+ so the identifier is genuine, and it unpacked normally. The creator
+ context did not validate, because this browser and connection
+ are not the ones the identifier was created on. A copied, shared or
+ stolen identifier is caught the moment it is presented, with nothing
+ stored server side.
+
+
+
+
+
+
diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java
index 8a7d2c226..0ee2b7433 100644
--- a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java
+++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java
@@ -22,10 +22,55 @@
package pipeline.developerexamples.fodid;
+import com.swancommunity.owid.Creator;
+import com.swancommunity.owid.Crypto;
+import com.swancommunity.owid.Owid;
+import fiftyone.pipeline.did.DidClient;
+import fiftyone.pipeline.did.FodId;
+import fiftyone.pipeline.did.HttpTransport;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
public class ExampleTests {
+ private static final String ENDPOINT = "https://example.test/api/v4/";
+
+ private Crypto crypto;
+ private String did;
+ private FakeTransport transport;
+ private DidClient client;
+
+ @BeforeEach
+ public void init() throws Exception {
+ crypto = Crypto.generate();
+ Creator creator = Creator.create("51degrees.com", crypto);
+ Owid owid = new Owid("51degrees.com", Instant.now(), samplePayload());
+ creator.sign(owid);
+ // As the page sends it, in the URL-safe alphabet without padding.
+ did = FodId.fromOwid(owid).asBase64Url();
+ transport = new FakeTransport();
+ client = DidClient.builder("resource")
+ .licenceKey("licence")
+ .endpoint(ENDPOINT)
+ .transport(transport)
+ .build();
+ }
+
/**
* The 51Did example is fully offline, so unlike the cloud examples it must
* complete without throwing. {@code run()} also self-checks the
@@ -36,4 +81,199 @@ public class ExampleTests {
public void FodId_Example_Test() throws Exception {
new Main.Example().run();
}
+
+ @Test
+ public void Redeem_Route_Answers_In_The_Clouds_Shape_With_ServerSignature() {
+ transport.queue(200, keyList(crypto));
+ transport.queue(200, "{\"signature\":\"verified\","
+ + "\"context\":\"verified\","
+ + "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ + "\"secondsSinceVerified\":2}");
+
+ CreatorContextDemoServer.Answer answer =
+ CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
+
+ assertEquals(200, answer.status);
+ assertEquals("application/json", answer.type);
+ JSONObject json = new JSONObject(answer.bodyText());
+ assertEquals("verified", json.getString("signature"));
+ assertEquals("verified", json.getString("context"));
+ assertEquals("2026-08-07T09:15:32Z", json.getString("verifiedAt"));
+ assertEquals(2, json.getInt("secondsSinceVerified"));
+ assertEquals("verified", json.getString("serverSignature"));
+ assertFalse(json.has("factors"));
+
+ // The keys were fetched once and the redeem was a POST to the bare
+ // path, carrying the resource and licence keys in the form, never
+ // in the URL.
+ assertEquals(2, transport.requests.size());
+ HttpTransport.Request redeem = transport.requests.get(1);
+ assertEquals("POST", redeem.getMethod());
+ assertEquals(ENDPOINT + "id/redeem", redeem.getUrl());
+ String form = new String(redeem.getBody(), StandardCharsets.UTF_8);
+ assertTrue(form.startsWith("resource=resource&51did="));
+ assertTrue(form.contains("&result=sealed"));
+ assertTrue(form.contains("&challenge=abc"));
+ assertTrue(form.contains("&license=licence"));
+ }
+
+ @Test
+ public void Redeem_Route_Relays_Factors_On_A_Mismatch() {
+ transport.queue(200, keyList(crypto));
+ transport.queue(200, "{\"signature\":\"verified\","
+ + "\"context\":\"mismatch\","
+ + "\"factors\":{\"transport\":\"verified\",\"device\":\"mismatch\","
+ + "\"browserip\":\"verified\",\"connectionip\":\"verified\","
+ + "\"asn\":\"verified\",\"browser\":\"verified\"},"
+ + "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ + "\"secondsSinceVerified\":3}");
+
+ CreatorContextDemoServer.Answer answer =
+ CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
+
+ JSONObject json = new JSONObject(answer.bodyText());
+ assertEquals("mismatch", json.getString("context"));
+ assertEquals("mismatch",
+ json.getJSONObject("factors").getString("device"));
+ assertEquals("verified",
+ json.getJSONObject("factors").getString("transport"));
+ }
+
+ @Test
+ public void Redeem_Route_Reports_Its_Own_Signature_Check() throws Exception {
+ // The published key is not the one that signed the identifier, so
+ // the server's own check says invalid whatever the cloud says.
+ transport.queue(200, keyList(Crypto.generate()));
+ transport.queue(200, "{\"signature\":\"verified\","
+ + "\"context\":\"verified\","
+ + "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ + "\"secondsSinceVerified\":2}");
+
+ CreatorContextDemoServer.Answer answer =
+ CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
+
+ JSONObject json = new JSONObject(answer.bodyText());
+ assertEquals("verified", json.getString("signature"));
+ assertEquals("invalid", json.getString("serverSignature"));
+ }
+
+ @Test
+ public void Redeem_Route_Relays_503_Unconfirmed() {
+ transport.queue(200, keyList(crypto));
+ transport.queue(503, "{\"context\":\"unconfirmed\"}");
+
+ CreatorContextDemoServer.Answer answer =
+ CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
+
+ assertEquals(503, answer.status);
+ JSONObject json = new JSONObject(answer.bodyText());
+ assertEquals("unconfirmed", json.getString("context"));
+ assertFalse(json.has("signature"));
+ assertEquals("verified", json.getString("serverSignature"));
+ }
+
+ @Test
+ public void Redeem_Route_Answers_404_As_Text_For_A_Host_Without_The_Feature() {
+ transport.queue(200, keyList(crypto));
+ transport.queue(404, "Not Found");
+
+ CreatorContextDemoServer.Answer answer =
+ CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
+
+ assertEquals(404, answer.status);
+ assertTrue(answer.type.startsWith("text/plain"));
+ assertEquals("Not Found", answer.bodyText());
+ }
+
+ @Test
+ public void Redeem_Route_Answers_502_When_The_Cloud_Is_Unreachable() {
+ // Nothing queued, so the first request fails as the network would.
+ CreatorContextDemoServer.Answer answer =
+ CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
+
+ assertEquals(502, answer.status);
+ JSONObject json = new JSONObject(answer.bodyText());
+ assertTrue(json.has("error"));
+ }
+
+ @Test
+ public void Redeem_Route_Answers_400_For_A_Malformed_51Did() {
+ CreatorContextDemoServer.Answer answer =
+ CreatorContextDemoServer.redeem(client, "not a 51did", "s", "c");
+
+ assertEquals(400, answer.status);
+ JSONObject json = new JSONObject(answer.bodyText());
+ assertTrue(json.getJSONArray("errors").getString(0)
+ .contains("not a valid"));
+ assertEquals(0, transport.requests.size());
+ }
+
+ @Test
+ public void Redeem_Route_Answers_400_When_The_Cloud_Refuses_The_51Did() {
+ transport.queue(200, keyList(crypto));
+ transport.queue(400, "{\"errors\":[\"'x' is not a valid "
+ + "Base64-encoded 51Did.\"]}");
+
+ CreatorContextDemoServer.Answer answer =
+ CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
+
+ assertEquals(400, answer.status);
+ JSONObject json = new JSONObject(answer.bodyText());
+ assertTrue(json.getJSONArray("errors").getString(0)
+ .contains("not a valid"));
+ }
+
+ // ----- Helpers -----
+
+ /**
+ * A one-entry key list in force since yesterday, so the identifier
+ * signed a moment ago falls inside its period.
+ */
+ private static String keyList(Crypto crypto) {
+ try {
+ JSONObject entry = new JSONObject();
+ entry.put("startsAt",
+ Instant.now().minus(Duration.ofDays(1)).toString());
+ entry.put("publicKey", crypto.publicKeyPem());
+ return new JSONArray().put(entry).toString();
+ } catch (Exception impossible) {
+ throw new IllegalStateException(impossible);
+ }
+ }
+
+ /**
+ * A canonical 37-byte Probabilistic payload: flags 0x00, License Id
+ * 0x12345678 (little-endian) and a 32-byte value 0x20..0x3F.
+ */
+ private static byte[] samplePayload() {
+ byte[] payload = new byte[FodId.PAYLOAD_LENGTH];
+ payload[FodId.LICENSE_ID_OFFSET] = 0x78;
+ payload[FodId.LICENSE_ID_OFFSET + 1] = 0x56;
+ payload[FodId.LICENSE_ID_OFFSET + 2] = 0x34;
+ payload[FodId.LICENSE_ID_OFFSET + 3] = 0x12;
+ for (int i = 0; i < FodId.HASH_LENGTH; i++) {
+ payload[FodId.HASH_OFFSET + i] = (byte) (0x20 + i);
+ }
+ return payload;
+ }
+
+ /** Records every request and answers from a queue. */
+ static final class FakeTransport implements HttpTransport {
+
+ final List requests = new ArrayList();
+ private final Deque responses = new ArrayDeque();
+
+ void queue(int status, String body) {
+ responses.add(new Response(status, body));
+ }
+
+ @Override
+ public Response send(Request request) throws IOException {
+ requests.add(request);
+ if (responses.isEmpty()) {
+ throw new IOException("Nothing queued for " + request.getUrl());
+ }
+ return responses.removeFirst();
+ }
+ }
}
diff --git a/pipeline.did/README.md b/pipeline.did/README.md
index 2b580bfa3..d3f95e7e3 100644
--- a/pipeline.did/README.md
+++ b/pipeline.did/README.md
@@ -1,7 +1,8 @@
# pipeline.did
-Strongly typed Java reader for the 51Did (51Degrees Identifier) returned by
-the 51Degrees Cloud service. Mirrors the .NET `FiftyOne.Did` package.
+Strongly typed Java reader and cloud client for the 51Did (51Degrees
+Identifier) returned by the 51Degrees Cloud service. Mirrors the .NET
+`FiftyOne.Did` package.
## Terminology
@@ -29,6 +30,7 @@ type and the length of the value that follows.
| 0 | 1 | Flags | uint8: bits 0-2 usage, bits 6-7 identifier type |
| 1 | 4 | LicenseId | uint32 (little-endian) |
| 5 | 16/32 | Value | SHA-256 (Probabilistic, HashedEmail) or GUID (Random) |
+| after | any | Context | Optional creator context section, readable only by 51Degrees |
| Bits 7-6 | `IdType` | Value length | Minimum payload |
|---------:|-----------------|-------------:|----------------:|
@@ -40,6 +42,11 @@ type and the length of the value that follows.
Identifiers issued before the type tag existed have bits 6-7 zeroed and decode
as `PROBABILISTIC`.
+An identifier carrying a creator context is longer than the minimum, and on
+such an identifier the four License Id bytes hold an encrypted value that
+only 51Degrees can turn back into a licence identifier, so `getLicenseId()`
+is the field's raw value and identifies nothing outside 51Degrees.
+
## OWID dependency
`FodId` builds on the OWID envelope library
@@ -54,6 +61,9 @@ repository root (`owid-java/`, mirroring how `pipeline-dotnet` carries the
there is no separate runtime dependency. The vendored OWID sources keep their
Apache-2.0 headers; the 51Did sources are EUPL-1.2.
+The cloud client reads JSON with `org.json:json`, at the version the
+pipeline's cloud request engine already uses.
+
### Bundled third-party licence
Because the OWID (`com.swancommunity.owid.*`) code is compiled into
@@ -79,10 +89,17 @@ byte[] hash = fodId.getHash(); // SHA-256 or GUID bytes, see type
// Delegated OWID-level fields and operations.
String domain = fodId.getDomain();
+long minutes = fodId.getDateMinutes(); // the envelope's own date field
boolean verified = fodId.verify(publicKeyPem);
-String base64 = fodId.asBase64();
+String base64 = fodId.asBase64(); // standard alphabet, padded
+String forUrl = fodId.asBase64Url(); // URL-safe alphabet, no padding
```
+`fromBase64` accepts the standard alphabet the cloud issues (`+`, `/`,
+padded) and the URL-safe alphabet a page puts in a link (`-`, `_`, padding
+optional). `asBase64Url()` gives the URL-safe form back, so an identifier
+can go into a URL without any conversion by the caller.
+
## Comparing two 51Dids
```java
@@ -96,9 +113,98 @@ boolean sameValue = java.util.Arrays.equals(a.getHash(), b.getHash());
Use `getHash()` as the cache / dedup key.
+## Verifying on your server
+
+`DidClient` handles every manipulation of a 51Did a server needs against the
+51Degrees cloud, so server code never hand-writes HTTP or key handling.
+Build one at start-up and share it, because it holds the cloud's published
+signing keys in memory.
+
+```java
+import fiftyone.pipeline.did.DidClient;
+import fiftyone.pipeline.did.FodId;
+import fiftyone.pipeline.did.RedeemResult;
+
+// The resource key is the page's and public by nature. The licence key is
+// server side only and is needed to redeem where the account holds licence
+// keys. The endpoint defaults to https://cloud.51degrees.com/api/v4/, or
+// the FOD_CLOUD_API_URL environment variable where that is set.
+DidClient client = new DidClient(resourceKey, licenceKey);
+```
+
+In the order a server uses them:
+
+1. **Parse.** The identifier arrives from a page in the URL-safe alphabet.
+
+ ```java
+ FodId fodId = FodId.fromBase64(fromThePage);
+ ```
+
+2. **Verify offline.** The client fetches the cloud's signing keys once,
+ holds them, and checks the signature against the key in force when the
+ identifier was created. No use is charged.
+
+ ```java
+ boolean genuine = client.verifySignature(fodId);
+ // or, to learn why not:
+ DidClient.SignatureCheck check = client.verifySignatureDetailed(fodId);
+ ```
+
+ `publicKeys()` returns the held list and `publicKeyFor(fodId)` the key in
+ force at the identifier's date. The list is refetched, once, when it has
+ no key for the date, when the date is later than the newest start held,
+ or when the list is more than a day old.
+
+3. **Verify through the cloud.** The open verify endpoint, one use against
+ the resource key, needing no licence key.
+
+ ```java
+ boolean genuine = client.verify(fodId);
+ ```
+
+4. **Redeem.** A page checks the creator context from the browser with
+ `verify-full` or `verify-context` and relays the sealed `result` to your
+ server. Your server redeems it, with the licence key, against the
+ identifier it knows independently. One use against the resource key.
+
+ ```java
+ RedeemResult redeemed = client.redeem(fodId, result, challenge);
+ switch (redeemed.getContext()) {
+ case VERIFIED: // presented from where it was created
+ case MISMATCH: // redeemed.getFactors() says which factor differs
+ case NO_CONTEXT: // the identifier carries no creator context
+ case NOT_CHECKABLE: // the cloud could not check it
+ case EXPIRED: // redeemed outside the freshness window
+ case REPLAYED: // already redeemed
+ case UNREADABLE: // tampered, wrong identifier, challenge or key
+ case UNCONFIRMED: // answered 503, retry
+ }
+ redeemed.getSignature(); // VERIFIED, INVALID or UNKNOWN
+ redeemed.getVerifiedAt(); // when the cloud sealed the result
+ redeemed.getSecondsSinceVerified(); // how long before this redemption
+ ```
+
+ A malformed identifier raises `IllegalArgumentException` with the
+ cloud's message, a host without the creator context raises
+ `DidNotSupportedException`, any other status raises `DidHttpException`
+ carrying the status and body, and an unreachable cloud raises
+ `IOException`. Every cryptographic failure comes back as the one word
+ `unreadable`, by design, so the client does not try to distinguish them
+ either.
+
+`verify-context` and `verify-full` are browser calls rather than client
+methods, because the creator context describes the browser's own
+connection, so only the browser being judged can make that call. Creating a
+51Did is likewise not part of this client: creation is the cloud `json`
+endpoint through the cloud request engine and pipeline.
+
+The `pipeline.developer-examples.fodid` module holds a web example whose
+`/redeem` route is these calls in a running server.
+
## Non-goals
- **No signature verification on construction.** Constructing a `FodId` does
- not check the signature. Call `verify(publicKeyPem)` when needed.
-- **No creation of new 51Dids.** This is a parser; new 51Dids are issued by the
- 51Degrees cloud / on-premise hashing engines.
+ not check the signature. Call `verify(publicKeyPem)` or
+ `DidClient.verifySignature(fodId)` when needed.
+- **No creation of new 51Dids.** This is a parser and a verifier; new 51Dids
+ are issued by the 51Degrees cloud / on-premise hashing engines.
diff --git a/pipeline.did/pom.xml b/pipeline.did/pom.xml
index 53bc84181..76f140c31 100644
--- a/pipeline.did/pom.xml
+++ b/pipeline.did/pom.xml
@@ -51,6 +51,14 @@
+
+
+ org.json
+ json
+ junitjunit
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java
new file mode 100644
index 000000000..7d26a577a
--- /dev/null
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java
@@ -0,0 +1,846 @@
+/* *********************************************************************
+ * 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.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+import com.swancommunity.owid.OwidException;
+import com.swancommunity.owid.Version;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * The client for everything a server does with a 51Did against the
+ * 51Degrees cloud, so that server code never hand-writes HTTP or key
+ * handling:
+ *
+ *
{@link #publicKeys()} and {@link #publicKeyFor(FodId)} fetch the
+ * cloud's published signing keys once, keep them, and pick the key in force
+ * when a given 51Did was created.
+ *
{@link #verifySignature(FodId)} checks a 51Did's signature offline
+ * against that key.
+ *
{@link #verify(FodId)} checks a 51Did's signature through the cloud's
+ * verify endpoint.
+ *
{@link #redeem(FodId, String, String)} redeems a sealed creator
+ * context result, with the licence key, and returns a typed
+ * {@link RedeemResult}.
+ *
+ * Creating a 51Did is not part of this client. Creation is the cloud
+ * {@code 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 {@code verify-context} and
+ * {@code verify-full} endpoints are browser calls for the same reason.
+ *
+ * Credentials never appear in a query string, because a query string is
+ * written to access logs. The resource key travels in the route of the GET
+ * calls ({@code id/key/{resource}}, {@code id/verify/{resource}}) and in
+ * the form body of the POST to {@code id/redeem}, whose route has no
+ * resource segment, and the licence key travels only in that form body.
+ *
+ * One instance is safe to share across threads. The key list is kept per
+ * instance, so share the instance rather than creating one per request.
+ */
+public final class DidClient {
+
+ /** The public cloud's API base, used when no other is given. */
+ public static final String DEFAULT_ENDPOINT =
+ "https://cloud.51degrees.com/api/v4/";
+
+ /**
+ * The environment variable read for the API base when the constructor
+ * is given none, the same one the cloud request engine honours.
+ */
+ public static final String ENDPOINT_VARIABLE = "FOD_CLOUD_API_URL";
+
+ /**
+ * How old the held key list may be before a request for a key refetches
+ * it.
+ */
+ public static final Duration KEY_LIST_MAX_AGE = Duration.ofDays(1);
+
+ /**
+ * How far outside its own period a key may be used, at either end,
+ * matching the cloud's own verification. A key belongs to its own
+ * period; this allowance exists only for the small ways a creation time
+ * can land a moment outside the period whose key made it.
+ */
+ static final Duration BOUNDARY_TOLERANCE = Duration.ofMinutes(15);
+
+ private static final String USER_AGENT = "pipeline.did/" + version();
+
+ /**
+ * Longest encoded identifier this client will take from a caller. The
+ * figure is arbitrary and deliberately generous, far above anything the
+ * cloud issues, because its only job is to turn away obviously
+ * malformed input before the client decodes it, fetches a key or calls
+ * the cloud. It says nothing about how long a 51Did is, and the cloud
+ * remains the judge of that.
+ */
+ private static final int MAXIMUM_ENCODED_LENGTH = 4096;
+
+ /** The outcome of an offline signature check, in detail. */
+ public enum SignatureCheck {
+ /** The signature verifies with the key in force at its date. */
+ VERIFIED,
+ /** No candidate key verifies the signature. */
+ INVALID,
+ /** The identifier's date precedes every key the cloud publishes. */
+ NO_KEY_COVERS_DATE,
+ /** The envelope version is not 3. */
+ UNSUPPORTED_VERSION,
+ /** The payload is shorter than the base length for its type. */
+ MALFORMED_PAYLOAD,
+ }
+
+ private final String resourceKey;
+ private final String licenceKey;
+ private final String endpoint;
+ private final HttpTransport transport;
+ private final Clock clock;
+
+ private final Object lock = new Object();
+ private List keys;
+ private Instant keysFetchedAt;
+
+ /**
+ * A client for the public cloud, or the host named by
+ * {@value #ENDPOINT_VARIABLE}, with no licence key.
+ *
+ * @param resourceKey the page's resource key, public by nature
+ */
+ public DidClient(String resourceKey) {
+ this(builder(resourceKey));
+ }
+
+ /**
+ * A client for the public cloud, or the host named by
+ * {@value #ENDPOINT_VARIABLE}, with a licence key.
+ *
+ * @param resourceKey the page's resource key, public by nature
+ * @param licenceKey a licence key of the same account, server side
+ * only, needed to redeem where the account holds
+ * licence keys, or null
+ */
+ public DidClient(String resourceKey, String licenceKey) {
+ this(builder(resourceKey).licenceKey(licenceKey));
+ }
+
+ /**
+ * A client for the given host.
+ *
+ * @param resourceKey the page's resource key, public by nature
+ * @param licenceKey a licence key of the same account, or null
+ * @param endpoint the API base including {@code /api/v4/}, or null
+ * to read {@value #ENDPOINT_VARIABLE} and fall back
+ * to {@link #DEFAULT_ENDPOINT}. A trailing slash is
+ * added where missing.
+ */
+ public DidClient(String resourceKey, String licenceKey, String endpoint) {
+ this(builder(resourceKey).licenceKey(licenceKey).endpoint(endpoint));
+ }
+
+ private DidClient(Builder builder) {
+ this.resourceKey = builder.resourceKey;
+ this.licenceKey = blankToNull(builder.licenceKey);
+ this.endpoint = resolveEndpoint(builder.endpoint);
+ this.transport = builder.transport == null
+ ? new UrlConnectionTransport()
+ : builder.transport;
+ this.clock = builder.clock == null ? Clock.systemUTC() : builder.clock;
+ }
+
+ /**
+ * Starts a builder for the cases the constructors do not cover, being
+ * an HTTP transport of the caller's own or, in tests, a clock.
+ *
+ * @param resourceKey the page's resource key, public by nature
+ * @return the builder
+ */
+ public static Builder builder(String resourceKey) {
+ return new Builder(resourceKey);
+ }
+
+ /** Builds a {@link DidClient}. */
+ public static final class Builder {
+
+ private final String resourceKey;
+ private String licenceKey;
+ private String endpoint;
+ private HttpTransport transport;
+ private Clock clock;
+
+ private Builder(String resourceKey) {
+ if (resourceKey == null || resourceKey.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "A resource key is required.");
+ }
+ this.resourceKey = resourceKey.trim();
+ }
+
+ /**
+ * @param licenceKey a licence key of the account, or null
+ * @return this builder
+ */
+ public Builder licenceKey(String licenceKey) {
+ this.licenceKey = licenceKey;
+ return this;
+ }
+
+ /**
+ * @param endpoint the API base including {@code /api/v4/}, or null
+ * to read {@value #ENDPOINT_VARIABLE} and fall back
+ * to {@link #DEFAULT_ENDPOINT}
+ * @return this builder
+ */
+ public Builder endpoint(String endpoint) {
+ this.endpoint = endpoint;
+ return this;
+ }
+
+ /**
+ * @param transport the HTTP transport to send through, or null for
+ * the default {@link java.net.HttpURLConnection}
+ * one
+ * @return this builder
+ */
+ public Builder transport(HttpTransport transport) {
+ this.transport = transport;
+ return this;
+ }
+
+ /**
+ * @param clock the clock the key list's age is measured by, or null
+ * for the system clock
+ * @return this builder
+ */
+ public Builder clock(Clock clock) {
+ this.clock = clock;
+ return this;
+ }
+
+ /** @return the client */
+ public DidClient build() {
+ return new DidClient(this);
+ }
+ }
+
+ /** @return the resource key the client sends */
+ public String getResourceKey() {
+ return resourceKey;
+ }
+
+ /** @return the API base every request is made under, ending in a slash */
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ /** @return whether the client was given a licence key */
+ public boolean hasLicenceKey() {
+ return licenceKey != null;
+ }
+
+ // ----- Public keys -----
+
+ /**
+ * The cloud's published signing keys, fetched on first use and then
+ * held, in order of start. Keys are published ahead of their start, so
+ * the list normally reaches months into the future.
+ *
+ * @return the keys, read only
+ * @throws IOException if the list is not held and cannot be fetched
+ */
+ public List publicKeys() throws IOException {
+ synchronized (lock) {
+ if (keys == null) {
+ fetchKeys();
+ }
+ return keys;
+ }
+ }
+
+ /**
+ * The key in force when the identifier was created, being the entry
+ * whose start is latest on or before the identifier's date. A held list
+ * is refetched, once, before answering when it has no entry on or
+ * before the date, when the date is later than the newest start held,
+ * or when the list is more than {@link #KEY_LIST_MAX_AGE} old.
+ * Otherwise the answer comes from the held list. A list fetched for
+ * this very call is not fetched again, because it cannot get better.
+ *
+ * @param fodId the identifier
+ * @return the key in force, or null when the date precedes every key
+ * @throws IOException if the required key list cannot be fetched
+ */
+ public SigningKey publicKeyFor(FodId fodId) throws IOException {
+ Objects.requireNonNull(fodId, "fodId");
+ Instant date = fodId.getDate();
+ return inForceAt(keysFor(date), date);
+ }
+
+ /**
+ * Fetches the key list and records when. A failure propagates to the
+ * caller and leaves whatever was held in place.
+ */
+ private void fetchKeys() throws IOException {
+ String url = endpoint + "id/key/" + encode(resourceKey);
+ HttpTransport.Response response = send("GET", url, null);
+ if (response.getStatusCode() != 200) {
+ throw httpError("The public key list", response);
+ }
+ List fetched;
+ try {
+ fetched = parseKeys(response.getBody());
+ } catch (JSONException unreadable) {
+ throw new DidHttpException(
+ "The public key list could not be read: "
+ + unreadable.getMessage(),
+ response.getStatusCode(), response.getBody());
+ } catch (DateTimeException unreadable) {
+ throw new DidHttpException(
+ "The public key list could not be read: "
+ + unreadable.getMessage(),
+ response.getStatusCode(), response.getBody());
+ }
+ keys = fetched;
+ keysFetchedAt = clock.instant();
+ }
+
+ /**
+ * Reads the key list the cloud answers with. Each entry carries
+ * {@code startsAt} and {@code publicKey}. Where {@code startsAt} is
+ * absent, the compatibility field {@code created} is read instead.
+ * {@code weekStart} is ignored.
+ */
+ static List parseKeys(String body) {
+ JSONArray array = new JSONArray(body);
+ List parsed = new ArrayList(array.length());
+ for (int i = 0; i < array.length(); i++) {
+ JSONObject entry = array.getJSONObject(i);
+ String startsAt = entry.optString("startsAt", null);
+ if (startsAt == null) {
+ startsAt = entry.optString("created", null);
+ }
+ String publicKey = entry.optString("publicKey", null);
+ if (startsAt == null || publicKey == null) {
+ throw new JSONException(
+ "entry " + i + " has no start or no publicKey");
+ }
+ parsed.add(new SigningKey(parseInstant(startsAt), publicKey));
+ }
+ Collections.sort(parsed, new Comparator() {
+ @Override
+ public int compare(SigningKey a, SigningKey b) {
+ return a.getStartsAt().compareTo(b.getStartsAt());
+ }
+ });
+ return Collections.unmodifiableList(parsed);
+ }
+
+ /** An ISO 8601 date and time with a zone, {@code Z} or an offset. */
+ private static Instant parseInstant(String value) {
+ return Instant.from(DateTimeFormatter.ISO_DATE_TIME.parse(value));
+ }
+
+ /**
+ * The key list to answer a question about the given date from,
+ * refetching once first where the held list may not have the answer. A
+ * failed fetch propagates because the held list may not contain the key
+ * needed for that date.
+ */
+ private List keysFor(Instant date) throws IOException {
+ synchronized (lock) {
+ if (keys == null) {
+ fetchKeys();
+ } else if (needsRefetch(date)) {
+ fetchKeys();
+ }
+ return keys;
+ }
+ }
+
+ /** Called under the lock with a list held. */
+ private boolean needsRefetch(Instant date) {
+ boolean stale = Duration.between(keysFetchedAt, clock.instant())
+ .compareTo(KEY_LIST_MAX_AGE) > 0;
+ boolean uncovered = inForceAt(keys, date) == null;
+ boolean beyond = keys.isEmpty() == false
+ && date.isAfter(keys.get(keys.size() - 1).getStartsAt());
+ return stale || uncovered || beyond;
+ }
+
+ /**
+ * The entry in force at the moment, being the newest whose start has
+ * passed, or null when the moment precedes every entry. Because an entry
+ * is in force until the next one starts, this can only be null before
+ * the schedule begins.
+ */
+ static SigningKey inForceAt(List entries, Instant at) {
+ SigningKey best = null;
+ for (SigningKey entry : entries) {
+ if (entry.getStartsAt().isAfter(at)) {
+ continue;
+ }
+ if (best == null || entry.getStartsAt().isAfter(best.getStartsAt())) {
+ best = entry;
+ }
+ }
+ return best;
+ }
+
+ /**
+ * The entries that may have signed something created at the moment,
+ * best first: the entry in force, then the entry in force a tolerance
+ * earlier and the entry in force a tolerance later where those differ.
+ * Deliberately not every earlier entry, because accepting any earlier
+ * entry would mean one leaked period of key material could sign
+ * something dated in any later period, and rotating the key would then
+ * bound nothing.
+ */
+ static List candidatesFor(
+ List entries, Instant at) {
+ List candidates = new ArrayList(3);
+ addIfNew(candidates, inForceAt(entries, at));
+ addIfNew(candidates, inForceAt(entries, at.minus(BOUNDARY_TOLERANCE)));
+ addIfNew(candidates, inForceAt(entries, at.plus(BOUNDARY_TOLERANCE)));
+ return candidates;
+ }
+
+ private static void addIfNew(List candidates, SigningKey entry) {
+ if (entry == null) {
+ return;
+ }
+ for (SigningKey held : candidates) {
+ if (held == entry) {
+ return;
+ }
+ }
+ candidates.add(entry);
+ }
+
+ // ----- Offline signature verification -----
+
+ /**
+ * Whether the identifier's signature verifies offline against the
+ * cloud's published key for its date. See
+ * {@link #verifySignatureDetailed(FodId)} for why not.
+ *
+ * @param fodId the identifier
+ * @return true when the signature verifies
+ * @throws IOException if the required key list cannot be fetched
+ */
+ public boolean verifySignature(FodId fodId) throws IOException {
+ return verifySignatureDetailed(fodId) == SignatureCheck.VERIFIED;
+ }
+
+ /**
+ * Checks the identifier's signature offline, mirroring the cloud's own
+ * verify endpoint: the envelope version must be 3, the payload must be
+ * at least the base length for its type (a longer payload carries a
+ * creator context section and is accepted), and the signature must
+ * verify with the key in force at the identifier's date or, within a
+ * short tolerance either side of a key boundary, the neighbouring key.
+ *
+ * @param fodId the identifier
+ * @return the outcome
+ * @throws IOException if the required key list cannot be fetched
+ */
+ public SignatureCheck verifySignatureDetailed(FodId fodId)
+ throws IOException {
+ Objects.requireNonNull(fodId, "fodId");
+ if (fodId.getVersion() != Version.VERSION3) {
+ return SignatureCheck.UNSUPPORTED_VERSION;
+ }
+ boolean isRandom = fodId.getType() == IdType.RANDOM;
+ int baseLength = FodId.HEADER_LENGTH
+ + (isRandom ? FodId.GUID_LENGTH : FodId.HASH_LENGTH);
+ if (fodId.getPayload().length < baseLength) {
+ return SignatureCheck.MALFORMED_PAYLOAD;
+ }
+ Instant date = fodId.getDate();
+ List candidates = candidatesFor(keysFor(date), date);
+ if (candidates.isEmpty()) {
+ return SignatureCheck.NO_KEY_COVERS_DATE;
+ }
+ for (SigningKey candidate : candidates) {
+ try {
+ if (fodId.verify(candidate.getPublicKeyPem())) {
+ return SignatureCheck.VERIFIED;
+ }
+ } catch (OwidException unusable) {
+ // A key whose PEM cannot be read verifies nothing. Try the
+ // next candidate.
+ }
+ }
+ return SignatureCheck.INVALID;
+ }
+
+ // ----- Cloud signature verification -----
+
+ /**
+ * Whether the identifier's signature verifies according to the cloud's
+ * verify endpoint, the open endpoint that needs no licence key. One use
+ * against the resource key.
+ *
+ * @param fodId the identifier
+ * @return true when the cloud answers valid
+ * @throws IOException if the cloud cannot be reached, or answers with a
+ * status the client does not map
+ */
+ public boolean verify(FodId fodId) throws IOException {
+ Objects.requireNonNull(fodId, "fodId");
+ return verify(base64Url(fodId));
+ }
+
+ /**
+ * Whether the identifier's signature verifies according to the cloud's
+ * verify endpoint. The identifier may be in either base64 alphabet. It
+ * is sent under both parameter names the endpoint accepts, {@code 51did}
+ * and {@code owid}, so a cloud that reads only the older name answers.
+ *
+ * @param fodId the identifier as base64
+ * @return true when the cloud answers valid, false when it answers
+ * invalid
+ * @throws IllegalArgumentException if the value is too long to be an
+ * identifier at all, or if the cloud
+ * says it is not a 51Did, with the
+ * cloud's message
+ * @throws IOException if the cloud cannot be reached, or answers with a
+ * status the client does not map
+ */
+ public boolean verify(String fodId) throws IOException {
+ Objects.requireNonNull(fodId, "fodId");
+ ensureEncodedLength(fodId);
+ // Under both names so the request works with hosts that read either
+ // parameter. Hosts that recognise both prefer 51did and keep owid as
+ // a compatibility alias.
+ String encoded = encode(fodId);
+ String url = endpoint + "id/verify/" + encode(resourceKey)
+ + "?51did=" + encoded + "&owid=" + encoded;
+ HttpTransport.Response response = send("GET", url, null);
+ JSONObject json = asObject(response.getBody());
+ int status = response.getStatusCode();
+ if (status == 200 && json != null && json.has("valid")) {
+ return json.getBoolean("valid");
+ }
+ if (status == 400 && json != null) {
+ if (json.has("valid")) {
+ return json.getBoolean("valid");
+ }
+ if (json.has("errors")) {
+ throw new IllegalArgumentException(errorsText(json));
+ }
+ }
+ throw httpError("Signature verification", response);
+ }
+
+ // ----- Redeem -----
+
+ /**
+ * Redeems a sealed creator context result against the identifier the
+ * caller knows independently, sending the licence key where one was
+ * given. One use against the resource key, the second of the two a
+ * browser-based context check costs.
+ *
+ * @param fodId the identifier the sealed result was made for
+ * @param result the sealed result exactly as the verify endpoint
+ * returned it
+ * @param challenge the single-use challenge given to the verify
+ * endpoint, or null where none was
+ * @return the typed result, for a 200 or a 503 answer
+ * @throws IllegalArgumentException if the cloud says the identifier is
+ * not a 51Did, with the cloud's message
+ * @throws DidNotSupportedException if the host does not offer the
+ * creator context
+ * @throws DidHttpException if the cloud answers with any other status,
+ * or a body that is not its own shape
+ * @throws IOException if the cloud cannot be reached
+ */
+ public RedeemResult redeem(FodId fodId, String result, String challenge)
+ throws IOException {
+ Objects.requireNonNull(fodId, "fodId");
+ return redeem(base64(fodId), result, challenge);
+ }
+
+ /**
+ * Redeems a sealed creator context result. The identifier may be in
+ * either base64 alphabet. See {@link #redeem(FodId, String, String)}.
+ *
+ * @param fodId the identifier as base64
+ * @param result the sealed result
+ * @param challenge the challenge, or null
+ * @return the typed result, for a 200 or a 503 answer
+ * @throws IllegalArgumentException if the value is too long to be an
+ * identifier at all, or as
+ * {@link #redeem(FodId, String, String)}
+ * @throws IOException as {@link #redeem(FodId, String, String)}
+ */
+ public RedeemResult redeem(String fodId, String result, String challenge)
+ throws IOException {
+ Objects.requireNonNull(fodId, "fodId");
+ ensureEncodedLength(fodId);
+ // The POST route has no {resource} segment, so the resource key
+ // goes in the form with everything else.
+ StringBuilder form = new StringBuilder()
+ .append("resource=").append(encode(resourceKey))
+ .append("&51did=").append(encode(fodId))
+ .append("&result=").append(encode(nullToEmpty(result)))
+ .append("&challenge=").append(encode(nullToEmpty(challenge)));
+ if (licenceKey != null) {
+ form.append("&license=").append(encode(licenceKey));
+ }
+ String url = endpoint + "id/redeem";
+ HttpTransport.Response response = send(
+ "POST", url, form.toString().getBytes(StandardCharsets.UTF_8));
+ int status = response.getStatusCode();
+ JSONObject json = asObject(response.getBody());
+ switch (status) {
+ case 200:
+ case 503:
+ if (json == null) {
+ throw httpError("Redemption", response);
+ }
+ return RedeemResult.parse(status, json, response.getBody());
+ case 400:
+ if (json != null && json.has("errors")) {
+ throw new IllegalArgumentException(errorsText(json));
+ }
+ throw httpError("Redemption", response);
+ case 404:
+ throw new DidNotSupportedException(
+ endpoint, response.getBody());
+ default:
+ throw httpError("Redemption", response);
+ }
+ }
+
+ // ----- HTTP -----
+
+ private HttpTransport.Response send(String method, String url, byte[] body)
+ throws IOException {
+ Map headers = new LinkedHashMap();
+ headers.put("User-Agent", USER_AGENT);
+ headers.put("Accept", "application/json");
+ if (body != null) {
+ headers.put("Content-Type",
+ "application/x-www-form-urlencoded; charset=utf-8");
+ }
+ return transport.send(
+ new HttpTransport.Request(method, url, headers, body));
+ }
+
+ /**
+ * Refuses input that cannot be an identifier at all, before any work is
+ * done on it. See {@link #MAXIMUM_ENCODED_LENGTH}.
+ */
+ private static void ensureEncodedLength(String fodId) {
+ if (fodId.length() > MAXIMUM_ENCODED_LENGTH) {
+ throw new IllegalArgumentException(
+ "The value is too long to be a 51Did.");
+ }
+ }
+
+ private static DidHttpException httpError(
+ String what, HttpTransport.Response response) {
+ String body = response.getBody();
+ String summary = body.length() > 200 ? body.substring(0, 200) : body;
+ return new DidHttpException(
+ what + " answered HTTP " + response.getStatusCode() + ": "
+ + summary, response.getStatusCode(), body);
+ }
+
+ /** The body as a JSON object, or null when it is not one. */
+ private static JSONObject asObject(String body) {
+ try {
+ return new JSONObject(body);
+ } catch (JSONException notAnObject) {
+ return null;
+ }
+ }
+
+ /** The cloud's {@code errors} array as one message. */
+ private static String errorsText(JSONObject json) {
+ JSONArray errors = json.optJSONArray("errors");
+ if (errors == null) {
+ return json.toString();
+ }
+ StringBuilder text = new StringBuilder();
+ for (int i = 0; i < errors.length(); i++) {
+ if (i > 0) {
+ text.append(' ');
+ }
+ text.append(errors.optString(i));
+ }
+ return text.toString();
+ }
+
+ private static String base64(FodId fodId) {
+ try {
+ return fodId.asBase64();
+ } catch (OwidException impossible) {
+ // A FodId is only ever built from a serialised envelope, so it
+ // always serialises again.
+ throw new IllegalStateException(impossible);
+ }
+ }
+
+ private static String base64Url(FodId fodId) {
+ try {
+ return fodId.asBase64Url();
+ } catch (OwidException impossible) {
+ throw new IllegalStateException(impossible);
+ }
+ }
+
+ static String encode(String value) {
+ try {
+ return URLEncoder.encode(value, "UTF-8");
+ } catch (UnsupportedEncodingException impossible) {
+ // UTF-8 is part of every Java runtime. The checked exception
+ // is a formality of the Java 8 signature.
+ throw new IllegalStateException(impossible);
+ }
+ }
+
+ /**
+ * The API base to use: the argument, else {@value #ENDPOINT_VARIABLE},
+ * else {@link #DEFAULT_ENDPOINT}, ending in exactly one slash so every
+ * route can be appended directly.
+ */
+ static String resolveEndpoint(String endpoint) {
+ String value = blankToNull(endpoint);
+ if (value == null) {
+ value = blankToNull(System.getenv(ENDPOINT_VARIABLE));
+ }
+ if (value == null) {
+ value = DEFAULT_ENDPOINT;
+ }
+ while (value.endsWith("/")) {
+ value = value.substring(0, value.length() - 1);
+ }
+ return value + "/";
+ }
+
+ private static String blankToNull(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return null;
+ }
+ return value.trim();
+ }
+
+ private static String nullToEmpty(String value) {
+ return value == null ? "" : value;
+ }
+
+ /**
+ * The package version from the jar manifest, which the build writes,
+ * or {@code dev} when running from class files.
+ */
+ private static String version() {
+ Package pkg = DidClient.class.getPackage();
+ String version = pkg == null ? null : pkg.getImplementationVersion();
+ return version == null ? "dev" : version;
+ }
+
+ /**
+ * The default transport, over {@link HttpURLConnection}. Ten seconds to
+ * connect and ten to read, which is generous for the cloud and short
+ * enough that a request thread is not held for long by a host that is
+ * down.
+ */
+ private static final class UrlConnectionTransport implements HttpTransport {
+
+ private static final int TIMEOUT_MILLIS = 10_000;
+
+ @Override
+ public Response send(Request request) throws IOException {
+ HttpURLConnection connection = (HttpURLConnection)
+ URI.create(request.getUrl()).toURL().openConnection();
+ connection.setConnectTimeout(TIMEOUT_MILLIS);
+ connection.setReadTimeout(TIMEOUT_MILLIS);
+ connection.setRequestMethod(request.getMethod());
+ for (Map.Entry header
+ : request.getHeaders().entrySet()) {
+ connection.setRequestProperty(
+ header.getKey(), header.getValue());
+ }
+ byte[] body = request.getBody();
+ if (body != null) {
+ connection.setDoOutput(true);
+ connection.setFixedLengthStreamingMode(body.length);
+ OutputStream out = connection.getOutputStream();
+ try {
+ out.write(body);
+ } finally {
+ out.close();
+ }
+ }
+ int status = connection.getResponseCode();
+ // A refusal is explained in the body, which HttpURLConnection
+ // puts on the error stream, so read whichever stream the
+ // status selected.
+ InputStream stream = status >= 400
+ ? connection.getErrorStream()
+ : connection.getInputStream();
+ return new Response(status, readAll(stream));
+ }
+
+ /** Reads a stream to its end as UTF-8 and closes it. */
+ private static String readAll(InputStream stream) throws IOException {
+ if (stream == null) {
+ return "";
+ }
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = stream.read(buffer)) > 0) {
+ out.write(buffer, 0, read);
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ } finally {
+ stream.close();
+ }
+ }
+ }
+}
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/DidHttpException.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidHttpException.java
new file mode 100644
index 000000000..49d6d41cb
--- /dev/null
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidHttpException.java
@@ -0,0 +1,61 @@
+/* *********************************************************************
+ * 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.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+import java.io.IOException;
+
+/**
+ * The cloud answered a {@link DidClient} request with a status the client
+ * does not map to a result, or with a body it could not read. Carries the
+ * status and the body so the caller can log what the service said. An
+ * {@link IOException} because, like {@link java.net.HttpRetryException}, it
+ * is a failure of the exchange rather than of the caller's input.
+ */
+public class DidHttpException extends IOException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final int statusCode;
+ private final String body;
+
+ /**
+ * @param message what went wrong
+ * @param statusCode the HTTP status the service answered with
+ * @param body the body the service answered with
+ */
+ public DidHttpException(String message, int statusCode, String body) {
+ super(message);
+ this.statusCode = statusCode;
+ this.body = body == null ? "" : body;
+ }
+
+ /** @return the HTTP status the service answered with */
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ /** @return the body the service answered with, never null */
+ public String getBody() {
+ return body;
+ }
+}
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/DidNotSupportedException.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidNotSupportedException.java
new file mode 100644
index 000000000..c757136c7
--- /dev/null
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidNotSupportedException.java
@@ -0,0 +1,43 @@
+/* *********************************************************************
+ * 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.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+/**
+ * The host the client is pointed at does not offer the creator context. It
+ * answered 404 to a redeem request, which the cloud's creator context
+ * endpoints never do, so the caller should point the client at a host that
+ * carries the feature rather than retry.
+ */
+public class DidNotSupportedException extends DidHttpException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * @param endpoint the API base the client was pointed at
+ * @param body the body the host answered with
+ */
+ public DidNotSupportedException(String endpoint, String body) {
+ super("The service at " + endpoint + " does not support the "
+ + "creator context.", 404, body);
+ }
+}
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java
index 6752b639b..5636a5d25 100644
--- a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java
@@ -26,8 +26,10 @@
import com.swancommunity.owid.OwidException;
import com.swancommunity.owid.Version;
+import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
+import java.util.Base64;
import java.util.Collections;
import java.util.Objects;
@@ -52,15 +54,25 @@
*
offset 1, length 4: License Id (uint32, little-endian)
*
offset 5: value - 32-byte SHA-256 (Probabilistic, HashedEmail) or
* 16 GUID bytes (Random)
+ *
after the value, optionally: a creator context section, which binds
+ * the identifier to the browser and connection it was created on.
+ * Only 51Degrees can read it, so this reader exposes it only as the
+ * part of {@link #getPayload()} beyond the value.
*
*
+ * The cloud issues a 51Did in standard base64 with padding, and a page that
+ * puts one in a link converts it to the URL-safe alphabet without padding.
+ * {@link #fromBase64(String)} accepts either form.
+ *
* Java's {@link Owid} is {@code final}, so this type composes an OWID
* rather than inheriting from it: it holds the wrapped envelope and delegates
* OWID-level concerns (domain, date, payload, signature, base64 round-trip,
* verification) to it, adding the strongly typed 51Did accessors on top.
*
* Constructing a {@code FodId} does not verify the OWID signature. Call
- * {@link #verify(String)} explicitly when cryptographic verification is needed.
+ * {@link #verify(String)} explicitly when cryptographic verification is
+ * needed, or use {@link DidClient} to verify against the cloud's published
+ * keys.
*/
public final class FodId {
@@ -101,6 +113,12 @@ public final class FodId {
*/
public static final int PAYLOAD_LENGTH = HASH_OFFSET + HASH_LENGTH;
+ /**
+ * The origin the envelope's date counts from, 2020-01-01T00:00:00Z, as
+ * epoch seconds. See {@link #getDateMinutes()}.
+ */
+ private static final long DATE_ORIGIN_EPOCH_SECONDS = 1_577_836_800L;
+
private final Owid owid;
private final int flags;
private final long licenseId;
@@ -148,7 +166,18 @@ private FodId(Owid owid, String paramName) {
}
/**
- * Parses a 51Did from its base64-encoded OWID string.
+ * Parses a 51Did from its base64-encoded OWID string, in either the
+ * standard alphabet ({@code +} and {@code /}, as the cloud issues it) or
+ * the URL-safe alphabet ({@code -} and {@code _}, as a page puts it in a
+ * link), with or without padding.
+ *
+ * The URL-safe form is restored to the standard one here, before the
+ * envelope library sees it, because that library's decoder ignores
+ * characters outside the standard alphabet rather than refusing them,
+ * which would silently drop bytes from a URL-safe value. Leading and
+ * trailing whitespace is removed at the same point, so a value that
+ * arrives with a newline or a space around it reads as the same
+ * identifier as the clean form.
*
* @param base64 base64 of the full OWID envelope
* @return the parsed 51Did
@@ -160,7 +189,34 @@ private FodId(Owid owid, String paramName) {
*/
public static FodId fromBase64(String base64) throws OwidException {
Objects.requireNonNull(base64, "base64");
- return new FodId(Owid.fromBase64(base64), "base64");
+ return new FodId(Owid.fromBase64(toStandardBase64(base64)), "base64");
+ }
+
+ /**
+ * Restores a base64 string that may use the URL-safe alphabet, with or
+ * without padding, to the standard alphabet with padding. Leading and
+ * trailing whitespace is removed first, because a value read from a
+ * header, a file or a form field often carries a newline or a space
+ * around it and neither belongs to the identifier. Then {@code -}
+ * becomes {@code +}, {@code _} becomes {@code /}, and {@code ==} or
+ * {@code =} is appended when the length modulo 4 is 2 or 3. That
+ * padding is worked out from the trimmed length, so whitespace cannot
+ * push the value into the wrong case. A value already in the standard
+ * padded form with no whitespace around it is returned unchanged.
+ *
+ * @param value the base64 text in either alphabet
+ * @return the same value in the standard alphabet with padding
+ */
+ static String toStandardBase64(String value) {
+ String standard = value.trim().replace('-', '+').replace('_', '/');
+ switch (standard.length() % 4) {
+ case 2:
+ return standard + "==";
+ case 3:
+ return standard + "=";
+ default:
+ return standard;
+ }
}
/**
@@ -213,7 +269,14 @@ public IdType getType() {
}
/**
- * @return the 4-byte little-endian License Id (0 to 4294967295)
+ * The 4-byte little-endian License Id field (0 to 4294967295).
+ *
+ * On an identifier carrying a creator context, the four bytes at offset
+ * 1 hold an encrypted value that only 51Degrees can turn back into a
+ * licence identifier. This property is therefore the field's raw value,
+ * and on such an identifier it identifies nothing outside 51Degrees.
+ *
+ * @return the raw License Id field
*/
public long getLicenseId() {
return licenseId;
@@ -241,11 +304,30 @@ public String getDomain() {
return owid.getDomain();
}
- /** @return the OWID creation date. */
+ /**
+ * The envelope's creation date, to the minute. See
+ * {@link #getDateMinutes()} for the same date as the envelope stores it.
+ *
+ * @return the OWID creation date
+ */
public Instant getDate() {
return owid.getDate();
}
+ /**
+ * The envelope's own date field, the unsigned 32-bit count of minutes
+ * since 2020-01-01T00:00:00Z. It is the value the OWID
+ * {@code public-key?date=} parameter takes, and the integer a caller
+ * comparing creation times wants rather than a converted date.
+ *
+ * @return minutes since 2020-01-01T00:00:00Z, 0 to 4294967295
+ */
+ public long getDateMinutes() {
+ return Duration.between(
+ Instant.ofEpochSecond(DATE_ORIGIN_EPOCH_SECONDS),
+ owid.getDate()).toMinutes();
+ }
+
/** @return a copy of the OWID payload bytes. */
public byte[] getPayload() {
return owid.getPayload();
@@ -257,13 +339,28 @@ public byte[] getSignature() {
}
/**
- * @return the OWID as a base64 string
+ * @return the OWID as a base64 string in the standard alphabet with
+ * padding, the form the cloud issues
* @throws OwidException if the OWID has not been signed or cannot be encoded
*/
public String asBase64() throws OwidException {
return owid.asBase64();
}
+ /**
+ * The OWID as a base64 string in the URL-safe alphabet ({@code -} and
+ * {@code _}) without padding, the inverse of what
+ * {@link #fromBase64(String)} restores, so the identifier can go into a
+ * URL without any conversion by the caller.
+ *
+ * @return the URL-safe base64 form without padding
+ * @throws OwidException if the OWID has not been signed or cannot be encoded
+ */
+ public String asBase64Url() throws OwidException {
+ return Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(asByteArray());
+ }
+
/**
* @return the OWID as a byte array including the signature
* @throws OwidException if the OWID has not been signed or cannot be encoded
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/HttpTransport.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/HttpTransport.java
new file mode 100644
index 000000000..69f2c2e18
--- /dev/null
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/HttpTransport.java
@@ -0,0 +1,123 @@
+/* *********************************************************************
+ * 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.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * The one HTTP operation {@link DidClient} needs, 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. The default implementation uses
+ * {@link java.net.HttpURLConnection}.
+ */
+public interface HttpTransport {
+
+ /**
+ * Sends the request and returns whatever the server answered, whatever
+ * the status. Only a failure to reach the server or read its answer is
+ * an exception.
+ *
+ * @param request the request to send
+ * @return the status and body the server answered with
+ * @throws IOException if the server could not be reached or the answer
+ * could not be read
+ */
+ Response send(Request request) throws IOException;
+
+ /** An HTTP request: method, URL, headers and an optional body. */
+ final class Request {
+
+ private final String method;
+ private final String url;
+ private final Map headers;
+ private final byte[] body;
+
+ /**
+ * @param method the HTTP method, {@code GET} or {@code POST}
+ * @param url the full URL to send to
+ * @param headers the headers to send, copied
+ * @param body the body to send, or null for none
+ */
+ public Request(
+ String method,
+ String url,
+ Map headers,
+ byte[] body) {
+ this.method = Objects.requireNonNull(method, "method");
+ this.url = Objects.requireNonNull(url, "url");
+ this.headers = Collections.unmodifiableMap(
+ new LinkedHashMap(headers));
+ this.body = body == null ? null : body.clone();
+ }
+
+ /** @return the HTTP method */
+ public String getMethod() {
+ return method;
+ }
+
+ /** @return the full URL */
+ public String getUrl() {
+ return url;
+ }
+
+ /** @return the headers, read only */
+ public Map getHeaders() {
+ return headers;
+ }
+
+ /** @return a copy of the body, or null when there is none */
+ public byte[] getBody() {
+ return body == null ? null : body.clone();
+ }
+ }
+
+ /** An HTTP response: the status code and the body as text. */
+ final class Response {
+
+ private final int statusCode;
+ private final String body;
+
+ /**
+ * @param statusCode the HTTP status code
+ * @param body the body as text, empty when there was none
+ */
+ public Response(int statusCode, String body) {
+ this.statusCode = statusCode;
+ this.body = body == null ? "" : body;
+ }
+
+ /** @return the HTTP status code */
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ /** @return the body as text, never null */
+ public String getBody() {
+ return body;
+ }
+ }
+}
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/RedeemResult.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/RedeemResult.java
new file mode 100644
index 000000000..03af4285c
--- /dev/null
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/RedeemResult.java
@@ -0,0 +1,332 @@
+/* *********************************************************************
+ * 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.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+import org.json.JSONObject;
+
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * The typed answer to {@link DidClient#redeem(FodId, String, String)}: the
+ * creator context verdict, the signature outcome, the per-factor detail
+ * where the cloud gave it, and when the verification behind the sealed
+ * result happened.
+ *
+ * Every cryptographic failure comes back from the cloud as the one word
+ * {@code unreadable}, by design, so that a forger probing the endpoint
+ * learns nothing about which part of a guess was wrong. This type does not
+ * try to distinguish them either, and it maps any context value it does
+ * not know to {@link Context#UNREADABLE} for the same reason, keeping the
+ * raw value in {@link #getContextValue()}.
+ */
+public final class RedeemResult {
+
+ /** The creator context verdict. */
+ public enum Context {
+ /** The identifier is being presented from where it was created. */
+ VERIFIED("verified"),
+ /**
+ * At least one factor differs from creation. {@link #getFactors()}
+ * says which.
+ */
+ MISMATCH("mismatch"),
+ /** The identifier carries no creator context to check. */
+ NO_CONTEXT("nocontext"),
+ /**
+ * The identifier carries a context the cloud could not check, for
+ * example one made under a secret the answering node does not hold.
+ */
+ NOT_CHECKABLE("notcheckable"),
+ /** The sealed result was redeemed outside the freshness window. */
+ EXPIRED("expired"),
+ /** The sealed result had already been redeemed. */
+ REPLAYED("replayed"),
+ /**
+ * The sealed result could not be read: tampered, made for another
+ * identifier, sealed under another challenge or licence key, or an
+ * answer this client did not recognise.
+ */
+ UNREADABLE("unreadable"),
+ /**
+ * First use could not be confirmed (answered with 503). Not a
+ * verdict, and the caller may retry.
+ */
+ UNCONFIRMED("unconfirmed");
+
+ private final String value;
+
+ Context(String value) {
+ this.value = value;
+ }
+
+ /** @return the word the cloud uses for this verdict */
+ public String getValue() {
+ return value;
+ }
+
+ /**
+ * Maps the cloud's word to a verdict. Anything unrecognised,
+ * including a missing value, is {@link #UNREADABLE}, so an answer
+ * this client does not know fails closed.
+ *
+ * @param value the {@code context} string from the cloud, or null
+ * @return the matching verdict, or {@link #UNREADABLE}
+ */
+ public static Context fromValue(String value) {
+ if (value != null) {
+ for (Context context : values()) {
+ if (context.value.equals(value)) {
+ return context;
+ }
+ }
+ }
+ return UNREADABLE;
+ }
+ }
+
+ /** The signature outcome, reported by the cloud out of the seal. */
+ public enum Signature {
+ /** The identifier is a genuine 51Degrees identifier. */
+ VERIFIED,
+ /** The signature did not verify. */
+ INVALID,
+ /**
+ * The cloud did not report the signature, which it only does on the
+ * redeemed outcomes.
+ */
+ UNKNOWN;
+
+ /**
+ * Maps the cloud's word to an outcome. Absent is {@link #UNKNOWN},
+ * {@code verified} is {@link #VERIFIED}, and any other word is
+ * {@link #INVALID}, so a word this client does not know fails
+ * closed.
+ *
+ * @param value the {@code signature} string from the cloud, or null
+ * @return the matching outcome
+ */
+ public static Signature fromValue(String value) {
+ if (value == null) {
+ return UNKNOWN;
+ }
+ return "verified".equals(value) ? VERIFIED : INVALID;
+ }
+ }
+
+ /** The outcome for one factor of the creator context. */
+ public enum Factor {
+ /** The factor matches creation. */
+ VERIFIED,
+ /** The factor differs from creation. */
+ MISMATCH;
+
+ /**
+ * @param value the factor's string from the cloud
+ * @return {@link #VERIFIED} for {@code verified}, otherwise
+ * {@link #MISMATCH}
+ */
+ public static Factor fromValue(String value) {
+ return "verified".equals(value) ? VERIFIED : MISMATCH;
+ }
+ }
+
+ /**
+ * The factor names in the order the cloud reports them. Kept so that
+ * {@link #getFactors()} iterates in that order whatever order the JSON
+ * parser hands the keys back in.
+ */
+ private static final String[] FACTOR_ORDER = {
+ "transport", "device", "browserip", "connectionip", "asn", "browser",
+ };
+
+ private final Context context;
+ private final String contextValue;
+ private final Signature signature;
+ private final Map factors;
+ private final boolean hasFactors;
+ private final Instant verifiedAt;
+ private final Integer secondsSinceVerified;
+ private final int statusCode;
+ private final String raw;
+
+ private RedeemResult(
+ Context context,
+ String contextValue,
+ Signature signature,
+ Map factors,
+ boolean hasFactors,
+ Instant verifiedAt,
+ Integer secondsSinceVerified,
+ int statusCode,
+ String raw) {
+ this.context = context;
+ this.contextValue = contextValue;
+ this.signature = signature;
+ this.factors = factors;
+ this.hasFactors = hasFactors;
+ this.verifiedAt = verifiedAt;
+ this.secondsSinceVerified = secondsSinceVerified;
+ this.statusCode = statusCode;
+ this.raw = raw;
+ }
+
+ /**
+ * Builds a result from the cloud's answer.
+ *
+ * @param statusCode the HTTP status, 200 or 503
+ * @param json the body parsed as a JSON object
+ * @param raw the body as received
+ * @return the typed result
+ */
+ static RedeemResult parse(int statusCode, JSONObject json, String raw) {
+ String contextValue = json.optString("context", null);
+ Context context = Context.fromValue(contextValue);
+ Signature signature = Signature.fromValue(
+ json.optString("signature", null));
+
+ Map factors = new LinkedHashMap();
+ JSONObject factorsJson = json.optJSONObject("factors");
+ boolean hasFactors = factorsJson != null;
+ if (hasFactors) {
+ for (String name : FACTOR_ORDER) {
+ if (factorsJson.has(name)) {
+ factors.put(name, Factor.fromValue(
+ factorsJson.optString(name, null)));
+ }
+ }
+ for (String name : factorsJson.keySet()) {
+ if (factors.containsKey(name) == false) {
+ factors.put(name, Factor.fromValue(
+ factorsJson.optString(name, null)));
+ }
+ }
+ }
+
+ Instant verifiedAt = null;
+ String verifiedAtValue = json.optString("verifiedAt", null);
+ if (verifiedAtValue != null) {
+ try {
+ verifiedAt = Instant.parse(verifiedAtValue);
+ } catch (DateTimeParseException unreadable) {
+ verifiedAt = null;
+ }
+ }
+ Integer secondsSinceVerified = null;
+ if (json.has("secondsSinceVerified")
+ && json.isNull("secondsSinceVerified") == false) {
+ secondsSinceVerified = json.optInt("secondsSinceVerified");
+ }
+
+ return new RedeemResult(
+ context,
+ contextValue == null ? context.getValue() : contextValue,
+ signature,
+ Collections.unmodifiableMap(factors),
+ hasFactors,
+ verifiedAt,
+ secondsSinceVerified,
+ statusCode,
+ raw);
+ }
+
+ /** @return the creator context verdict */
+ public Context getContext() {
+ return context;
+ }
+
+ /**
+ * @return the {@code context} word exactly as the cloud sent it, so an
+ * unrecognised word (mapped to {@link Context#UNREADABLE}) is
+ * still visible; the verdict's own word when the cloud sent none
+ */
+ public String getContextValue() {
+ return contextValue;
+ }
+
+ /** @return the signature outcome */
+ public Signature getSignature() {
+ return signature;
+ }
+
+ /**
+ * The per-factor outcomes, present only when the cloud sent
+ * {@code factors}, which it does on the mismatch verdict, the one with
+ * something to diagnose. Names are {@code transport}, {@code device},
+ * {@code browserip}, {@code connectionip}, {@code asn} and
+ * {@code browser}.
+ *
+ * @return factor name to outcome, read only, empty when the cloud sent
+ * none (see {@link #hasFactors()})
+ */
+ public Map getFactors() {
+ return factors;
+ }
+
+ /** @return whether the cloud sent {@code factors} */
+ public boolean hasFactors() {
+ return hasFactors;
+ }
+
+ /**
+ * When the verify endpoint checked the context and sealed the result,
+ * UTC to the second. Present on the redeemed and expired outcomes.
+ *
+ * @return the verification time, or null when the cloud did not send one
+ */
+ public Instant getVerifiedAt() {
+ return verifiedAt;
+ }
+
+ /**
+ * How long before this redemption the verification happened, in whole
+ * seconds by the cloud's clock. Present on the redeemed and expired
+ * outcomes. A caller wanting a stricter freshness rule than the cloud's
+ * own window applies it to this value.
+ *
+ * @return the age in seconds, or null when the cloud did not send one
+ */
+ public Integer getSecondsSinceVerified() {
+ return secondsSinceVerified;
+ }
+
+ /** @return the HTTP status the cloud answered with, 200 or 503 */
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ /** @return the response body as received */
+ public String getRaw() {
+ return raw;
+ }
+
+ @Override
+ public String toString() {
+ return "RedeemResult " + statusCode + " context=" + contextValue
+ + " signature=" + signature
+ + (hasFactors ? " factors=" + factors : "");
+ }
+}
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/SigningKey.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/SigningKey.java
new file mode 100644
index 000000000..c846888be
--- /dev/null
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/SigningKey.java
@@ -0,0 +1,63 @@
+/* *********************************************************************
+ * 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.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * One entry of the cloud's published signing key schedule: the public key
+ * and the moment it came, or comes, into force. A key stays in force until
+ * the next one starts, so the schedule never has a gap, and keys are
+ * published ahead of their start.
+ */
+public final class SigningKey {
+
+ private final Instant startsAt;
+ private final String publicKeyPem;
+
+ /**
+ * @param startsAt when the key comes into force, UTC
+ * @param publicKeyPem the public key in SPKI PEM form
+ */
+ public SigningKey(Instant startsAt, String publicKeyPem) {
+ this.startsAt = Objects.requireNonNull(startsAt, "startsAt");
+ this.publicKeyPem = Objects.requireNonNull(
+ publicKeyPem, "publicKeyPem");
+ }
+
+ /** @return when the key comes into force, UTC */
+ public Instant getStartsAt() {
+ return startsAt;
+ }
+
+ /** @return the public key in SPKI PEM form */
+ public String getPublicKeyPem() {
+ return publicKeyPem;
+ }
+
+ @Override
+ public String toString() {
+ return "SigningKey from " + startsAt;
+ }
+}
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java
index 9d13d6cdf..ce2e1fd65 100644
--- a/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java
@@ -21,12 +21,20 @@
* ********************************************************************* */
/**
- * Strongly typed reader for the 51Did (51Degrees Identifier) value.
+ * Strongly typed reader and cloud client for the 51Did (51Degrees
+ * Identifier) value.
*
* {@link fiftyone.pipeline.did.FodId} parses a 51Did from its base64 OWID
- * form, exposes the three payload fields (Flags, License Id and the value
- * Hash) and the identifier {@link fiftyone.pipeline.did.IdType}, and delegates
- * OWID-level concerns to the wrapped envelope. Compare 51Dids by their value
- * ({@code getHash()}), never by their envelopes.
+ * form, in either base64 alphabet, exposes the three payload fields (Flags,
+ * License Id and the value Hash) and the identifier
+ * {@link fiftyone.pipeline.did.IdType}, and delegates OWID-level concerns to
+ * the wrapped envelope. Compare 51Dids by their value ({@code getHash()}),
+ * never by their envelopes.
+ *
+ * {@link fiftyone.pipeline.did.DidClient} is what a server uses against the
+ * 51Degrees cloud: it fetches and holds the published signing keys, verifies
+ * a 51Did's signature offline or through the cloud, and redeems a sealed
+ * creator context result into a typed
+ * {@link fiftyone.pipeline.did.RedeemResult}.
*/
package fiftyone.pipeline.did;
diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientLiveTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientLiveTests.java
new file mode 100644
index 000000000..44c6b5110
--- /dev/null
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientLiveTests.java
@@ -0,0 +1,143 @@
+/* *********************************************************************
+ * 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.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+import org.json.JSONObject;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests against the live cloud, skipped unless {@code _51DEGREES_RESOURCE_KEY}
+ * (or the older {@code RESOURCE_KEY}) is set. {@code FOD_CLOUD_API_URL}
+ * points them at another host, and {@code _51DEGREES_LICENSE_KEY} (or
+ * {@code LICENSE_KEY}) supplies the licence key where the account holds
+ * one. Each test creates a 51Did through the cloud {@code json} endpoint,
+ * which is one use against the resource key, plus one for each cloud call
+ * it then makes.
+ */
+public class DidClientLiveTests {
+
+ private String resourceKey;
+ private DidClient client;
+
+ @Before
+ public void init() {
+ resourceKey = env("_51DEGREES_RESOURCE_KEY", "RESOURCE_KEY");
+ Assume.assumeTrue(
+ "Set _51DEGREES_RESOURCE_KEY to run the live 51Did cloud tests.",
+ resourceKey != null);
+ client = new DidClient(
+ resourceKey, env("_51DEGREES_LICENSE_KEY", "LICENSE_KEY"));
+ }
+
+ @Test
+ public void create_Parse_VerifyOffline_VerifyThroughTheCloud()
+ throws Exception {
+ FodId fodId = create();
+
+ assertEquals(DidClient.SignatureCheck.VERIFIED,
+ client.verifySignatureDetailed(fodId));
+ assertTrue(client.verifySignature(fodId));
+ assertTrue(client.verify(fodId));
+ }
+
+ @Test
+ public void redeem_GarbageResult_IsUnreadable() throws Exception {
+ FodId fodId = create();
+
+ RedeemResult result;
+ try {
+ result = client.redeem(fodId, "not-base64url!!", "live-test");
+ } catch (DidNotSupportedException unsupported) {
+ Assume.assumeNoException(
+ "The host does not offer the creator context.", unsupported);
+ return;
+ }
+
+ assertEquals(200, result.getStatusCode());
+ assertEquals(RedeemResult.Context.UNREADABLE, result.getContext());
+ }
+
+ /**
+ * Creates a 51Did for this connection through the cloud {@code json}
+ * endpoint, the same call a page or the cloud request engine makes.
+ */
+ private FodId create() throws Exception {
+ String url = client.getEndpoint() + "json?resource="
+ + DidClient.encode(resourceKey) + "&values=FODiD.IdProbGlobal";
+ HttpURLConnection connection = (HttpURLConnection)
+ URI.create(url).toURL().openConnection();
+ connection.setRequestProperty("User-Agent", "pipeline.did live test");
+ int status = connection.getResponseCode();
+ String body = readAll(status >= 400
+ ? connection.getErrorStream()
+ : connection.getInputStream());
+ assertEquals("Creating a 51Did: " + body, 200, status);
+ JSONObject fodid = new JSONObject(body).optJSONObject("fodid");
+ String value = fodid == null
+ ? null
+ : fodid.optString("idprobglobal", null);
+ Assume.assumeTrue(
+ "The resource key does not return FODiD.IdProbGlobal.",
+ value != null);
+ return FodId.fromBase64(value);
+ }
+
+ private static String readAll(InputStream stream) throws IOException {
+ if (stream == null) {
+ return "";
+ }
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = stream.read(buffer)) > 0) {
+ out.write(buffer, 0, read);
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ } finally {
+ stream.close();
+ }
+ }
+
+ private static String env(String... names) {
+ for (String name : names) {
+ String value = System.getenv(name);
+ if (value != null && value.trim().isEmpty() == false) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+}
diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java
new file mode 100644
index 000000000..f378765aa
--- /dev/null
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java
@@ -0,0 +1,931 @@
+/* *********************************************************************
+ * 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.
+ * ********************************************************************* */
+
+package fiftyone.pipeline.did;
+
+import com.swancommunity.owid.OwidException;
+import com.swancommunity.owid.Version;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.List;
+
+import static fiftyone.pipeline.did.FodIdTestFactory.canonicalPayload;
+import static fiftyone.pipeline.did.FodIdTestFactory.canonicalPayloadWithSection;
+import static fiftyone.pipeline.did.FodIdTestFactory.canonicalRandomPayload;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for {@link DidClient} with the network stood in for by a recording
+ * transport. Three signing keys, one per week, stand in for the cloud's
+ * published schedule.
+ */
+public class DidClientTests {
+
+ private static final String ENDPOINT = "https://example.test/api/v4/";
+ private static final Instant WEEK1 = Instant.parse("2026-08-03T00:00:00Z");
+ private static final Instant WEEK2 = WEEK1.plus(Duration.ofDays(7));
+ private static final Instant WEEK3 = WEEK2.plus(Duration.ofDays(7));
+
+ // Offsets either side of a key boundary, chosen far apart so that each
+ // one is plainly on its own side of the tolerance whatever the
+ // tolerance is set to.
+ private static final Duration JUST_INSIDE = Duration.ofMinutes(1);
+ private static final Duration WELL_OUTSIDE = Duration.ofHours(1);
+
+ private FodIdTestFactory key1;
+ private FodIdTestFactory key2;
+ private FodIdTestFactory key3;
+ private FakeTransport transport;
+ private MutableClock clock;
+ private DidClient client;
+
+ @Before
+ public void init() throws OwidException {
+ key1 = new FodIdTestFactory();
+ key2 = new FodIdTestFactory();
+ key3 = new FodIdTestFactory();
+ transport = new FakeTransport();
+ clock = new MutableClock(WEEK2.plus(Duration.ofDays(1)));
+ client = DidClient.builder("resource")
+ .licenceKey("licence")
+ .endpoint(ENDPOINT)
+ .transport(transport)
+ .clock(clock)
+ .build();
+ }
+
+ // ----- Construction -----
+
+ @Test
+ public void builder_RejectsBlankResourceKey() {
+ assertThrows(IllegalArgumentException.class,
+ () -> DidClient.builder(" "));
+ assertThrows(IllegalArgumentException.class,
+ () -> new DidClient(null));
+ }
+
+ @Test
+ public void endpoint_IsNormalisedToOneTrailingSlash() {
+ assertEquals("https://host/api/v4/",
+ DidClient.resolveEndpoint("https://host/api/v4"));
+ assertEquals("https://host/api/v4/",
+ DidClient.resolveEndpoint("https://host/api/v4///"));
+ assertEquals("https://host/api/v4/",
+ DidClient.resolveEndpoint(" https://host/api/v4/ "));
+ assertEquals(ENDPOINT,
+ new DidClient("resource", null, ENDPOINT).getEndpoint());
+ }
+
+ @Test
+ public void endpoint_DefaultsToTheCloudOrTheEnvironment() {
+ String fromEnvironment = System.getenv(DidClient.ENDPOINT_VARIABLE);
+ String expected = fromEnvironment == null
+ || fromEnvironment.trim().isEmpty()
+ ? DidClient.DEFAULT_ENDPOINT
+ : DidClient.resolveEndpoint(fromEnvironment);
+ assertEquals(expected, DidClient.resolveEndpoint(null));
+ assertEquals(expected, DidClient.resolveEndpoint(""));
+ }
+
+ @Test
+ public void licenceKey_BlankCountsAsNone() {
+ assertFalse(new DidClient("resource", " ", ENDPOINT).hasLicenceKey());
+ assertTrue(client.hasLicenceKey());
+ }
+
+ // ----- Public keys -----
+
+ @Test
+ public void publicKeys_ReadsStartsAtAndIgnoresWeekStart() throws Exception {
+ transport.queue(200, keyList("startsAt", true));
+
+ List keys = client.publicKeys();
+
+ assertEquals(3, keys.size());
+ assertEquals(WEEK1, keys.get(0).getStartsAt());
+ assertEquals(WEEK2, keys.get(1).getStartsAt());
+ assertEquals(WEEK3, keys.get(2).getStartsAt());
+ assertEquals(key1.publicPem, keys.get(0).getPublicKeyPem());
+ assertEquals(key3.publicPem, keys.get(2).getPublicKeyPem());
+
+ HttpTransport.Request request = transport.last();
+ assertEquals("GET", request.getMethod());
+ assertEquals(ENDPOINT + "id/key/resource", request.getUrl());
+ assertTrue(request.getHeaders().get("User-Agent")
+ .startsWith("pipeline.did/"));
+ }
+
+ @Test
+ public void publicKeys_FallsBackToCreated() throws Exception {
+ transport.queue(200, keyList("created", false));
+
+ List keys = client.publicKeys();
+
+ assertEquals(WEEK1, keys.get(0).getStartsAt());
+ assertEquals(WEEK3, keys.get(2).getStartsAt());
+ }
+
+ @Test
+ public void publicKeys_SortsByStart() throws Exception {
+ JSONArray reversed = new JSONArray();
+ reversed.put(keyEntry("startsAt", WEEK3, key3));
+ reversed.put(keyEntry("startsAt", WEEK1, key1));
+ reversed.put(keyEntry("startsAt", WEEK2, key2));
+ transport.queue(200, reversed.toString());
+
+ List keys = client.publicKeys();
+
+ assertEquals(WEEK1, keys.get(0).getStartsAt());
+ assertEquals(WEEK2, keys.get(1).getStartsAt());
+ assertEquals(WEEK3, keys.get(2).getStartsAt());
+ }
+
+ @Test
+ public void publicKeys_SecondCallUsesTheCache() throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+
+ List first = client.publicKeys();
+ List second = client.publicKeys();
+
+ assertSame(first, second);
+ assertEquals(1, transport.requests.size());
+ }
+
+ @Test
+ public void publicKeys_FirstFetchFailureRaises() {
+ transport.queue(500, "down");
+
+ DidHttpException error = assertThrows(DidHttpException.class,
+ () -> client.publicKeys());
+
+ assertEquals(500, error.getStatusCode());
+ assertEquals("down", error.getBody());
+ }
+
+ @Test
+ public void publicKeys_UnreadableListRaises() {
+ transport.queue(200, "[{\"publicKey\":\"x\"}]");
+
+ assertThrows(DidHttpException.class, () -> client.publicKeys());
+ }
+
+ @Test
+ public void publicKeyFor_ReturnsTheKeyInForce() throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key2.fodIdAt(
+ canonicalPayload(), WEEK2.plus(Duration.ofDays(3)));
+
+ SigningKey key = client.publicKeyFor(fodId);
+
+ assertEquals(WEEK2, key.getStartsAt());
+ assertEquals(1, transport.requests.size());
+ }
+
+ @Test
+ public void publicKeyFor_RefetchesWhenDateIsBeyondTheNewestStart()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ transport.queue(200, keyList("startsAt", false));
+ client.publicKeys();
+ FodId fodId = key3.fodIdAt(
+ canonicalPayload(), WEEK3.plus(Duration.ofDays(8)));
+
+ SigningKey key = client.publicKeyFor(fodId);
+
+ assertEquals(WEEK3, key.getStartsAt());
+ assertEquals(2, transport.requests.size());
+ }
+
+ @Test
+ public void publicKeyFor_DoesNotRefetchStraightAfterTheFirstFetch()
+ throws Exception {
+ // A list fetched for this very call cannot get better by fetching
+ // again, so a date the fresh list does not reach costs one use,
+ // not two.
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key3.fodIdAt(
+ canonicalPayload(), WEEK3.plus(Duration.ofDays(8)));
+
+ SigningKey key = client.publicKeyFor(fodId);
+
+ assertEquals(WEEK3, key.getStartsAt());
+ assertEquals(1, transport.requests.size());
+ }
+
+ @Test
+ public void publicKeyFor_RefetchesWhenNoKeyCoversTheDate()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ transport.queue(200, keyList("startsAt", false));
+ client.publicKeys();
+ FodId fodId = key1.fodIdAt(
+ canonicalPayload(), WEEK1.minus(Duration.ofDays(1)));
+
+ SigningKey key = client.publicKeyFor(fodId);
+
+ assertNull(key);
+ assertEquals(2, transport.requests.size());
+ }
+
+ @Test
+ public void publicKeyFor_RefetchesWhenTheListIsADayOld()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key2.fodIdAt(
+ canonicalPayload(), WEEK2.plus(Duration.ofDays(1)));
+
+ client.publicKeyFor(fodId);
+ clock.advance(Duration.ofHours(25));
+ client.publicKeyFor(fodId);
+
+ assertEquals(2, transport.requests.size());
+ }
+
+ @Test
+ public void publicKeyFor_DoesNotRefetchWithinADay() throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key2.fodIdAt(
+ canonicalPayload(), WEEK2.plus(Duration.ofDays(1)));
+
+ client.publicKeyFor(fodId);
+ clock.advance(Duration.ofHours(23));
+ client.publicKeyFor(fodId);
+
+ assertEquals(1, transport.requests.size());
+ }
+
+ @Test
+ public void publicKeyFor_RefetchFailureRaises()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key2.fodIdAt(
+ canonicalPayload(), WEEK2.plus(Duration.ofDays(1)));
+ client.publicKeyFor(fodId);
+ clock.advance(Duration.ofHours(25));
+
+ // Nothing queued, so the refetch fails with an I/O error.
+ IOException error = assertThrows(IOException.class,
+ () -> client.publicKeyFor(fodId));
+
+ assertTrue(error.getMessage().contains("Nothing queued"));
+ assertEquals(2, transport.requests.size());
+ }
+
+ // ----- Selection -----
+
+ @Test
+ public void candidates_KeyInForceOnly_AwayFromBoundaries() throws Exception {
+ List keys = DidClient.parseKeys(keyList("startsAt", false));
+
+ List candidates = DidClient.candidatesFor(
+ keys, WEEK2.plus(Duration.ofDays(3)));
+
+ assertEquals(1, candidates.size());
+ assertEquals(WEEK2, candidates.get(0).getStartsAt());
+ }
+
+ @Test
+ public void candidates_EarlierNeighbourJustAfterABoundary() throws Exception {
+ List keys = DidClient.parseKeys(keyList("startsAt", false));
+
+ List candidates = DidClient.candidatesFor(
+ keys, WEEK2.plus(JUST_INSIDE));
+
+ assertEquals(2, candidates.size());
+ assertEquals(WEEK2, candidates.get(0).getStartsAt());
+ assertEquals(WEEK1, candidates.get(1).getStartsAt());
+ }
+
+ @Test
+ public void candidates_LaterNeighbourJustBeforeABoundary() throws Exception {
+ List keys = DidClient.parseKeys(keyList("startsAt", false));
+
+ List candidates = DidClient.candidatesFor(
+ keys, WEEK2.minus(JUST_INSIDE));
+
+ assertEquals(2, candidates.size());
+ assertEquals(WEEK1, candidates.get(0).getStartsAt());
+ assertEquals(WEEK2, candidates.get(1).getStartsAt());
+ }
+
+ @Test
+ public void candidates_NoneBeforeTheSchedule() throws Exception {
+ List keys = DidClient.parseKeys(keyList("startsAt", false));
+
+ assertTrue(DidClient.candidatesFor(
+ keys, WEEK1.minus(Duration.ofDays(1))).isEmpty());
+ // Within the tolerance of the first start, the first key applies.
+ assertEquals(1, DidClient.candidatesFor(
+ keys, WEEK1.minus(JUST_INSIDE)).size());
+ }
+
+ // ----- Offline signature verification -----
+
+ @Test
+ public void verifySignature_TrueWithTheKeyInForce() throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key2.fodIdAt(
+ canonicalPayload(), WEEK2.plus(Duration.ofDays(1)));
+
+ assertTrue(client.verifySignature(fodId));
+ assertEquals(DidClient.SignatureCheck.VERIFIED,
+ client.verifySignatureDetailed(fodId));
+ }
+
+ @Test
+ public void verifySignature_FalseWithTheWrongKey() throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodIdTestFactory unpublished = new FodIdTestFactory();
+ FodId fodId = unpublished.fodIdAt(
+ canonicalPayload(), WEEK2.plus(Duration.ofDays(1)));
+
+ assertFalse(client.verifySignature(fodId));
+ assertEquals(DidClient.SignatureCheck.INVALID,
+ client.verifySignatureDetailed(fodId));
+ }
+
+ @Test
+ public void verifySignature_FalseWithAPublishedKeyFromAnotherPeriod()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ // Signed with week 1's key but dated well inside week 2, which is
+ // what a leaked key from an earlier period would produce.
+ FodId fodId = key1.fodIdAt(
+ canonicalPayload(), WEEK2.plus(Duration.ofDays(1)));
+
+ assertFalse(client.verifySignature(fodId));
+ }
+
+ @Test
+ public void verifySignature_RefetchFailureRaisesRatherThanFalse()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ client.publicKeys();
+ FodIdTestFactory missingKey = new FodIdTestFactory();
+ FodId fodId = missingKey.fodIdAt(
+ canonicalPayload(), WEEK3.plus(Duration.ofDays(8)));
+
+ // The held schedule cannot contain the correct key, and the
+ // required refetch has no queued answer.
+ assertThrows(IOException.class, () -> client.verifySignature(fodId));
+ assertEquals(2, transport.requests.size());
+ }
+
+ @Test
+ public void verifySignature_EarlierNeighbourWithinToleranceAfterBoundary()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodId inside = key1.fodIdAt(
+ canonicalPayload(), WEEK2.plus(JUST_INSIDE));
+ FodId outside = key1.fodIdAt(
+ canonicalPayload(), WEEK2.plus(WELL_OUTSIDE));
+
+ assertTrue(client.verifySignature(inside));
+ assertFalse(client.verifySignature(outside));
+ }
+
+ @Test
+ public void verifySignature_LaterNeighbourWithinToleranceBeforeBoundary()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodId inside = key2.fodIdAt(
+ canonicalPayload(), WEEK2.minus(JUST_INSIDE));
+ FodId outside = key2.fodIdAt(
+ canonicalPayload(), WEEK2.minus(WELL_OUTSIDE));
+
+ assertTrue(client.verifySignature(inside));
+ assertFalse(client.verifySignature(outside));
+ }
+
+ @Test
+ public void verifySignature_NoKeyCoversADateBeforeTheSchedule()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key1.fodIdAt(
+ canonicalPayload(), WEEK1.minus(Duration.ofDays(1)));
+
+ assertEquals(DidClient.SignatureCheck.NO_KEY_COVERS_DATE,
+ client.verifySignatureDetailed(fodId));
+ assertFalse(client.verifySignature(fodId));
+ }
+
+ @Test
+ public void verifySignature_FalseForVersion2() throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = FodId.fromOwid(key2.signedOwidAt(
+ canonicalPayload(), WEEK2.plus(Duration.ofDays(1)),
+ Version.VERSION2));
+
+ assertEquals(Version.VERSION2, fodId.getVersion());
+ assertEquals(DidClient.SignatureCheck.UNSUPPORTED_VERSION,
+ client.verifySignatureDetailed(fodId));
+ assertFalse(client.verifySignature(fodId));
+ }
+
+ @Test
+ public void verifySignature_FalseForPayloadShorterThanBase()
+ throws Exception {
+ // Only the Reserved type parses with a payload below the 37-byte
+ // base, which is exactly the shape the cloud refuses on length.
+ byte[] payload = new byte[FodId.HEADER_LENGTH + 4];
+ payload[FodId.FLAGS_OFFSET] = (byte) 0b1100_0000;
+ FodId fodId = key2.fodIdAt(payload, WEEK2.plus(Duration.ofDays(1)));
+
+ assertEquals(DidClient.SignatureCheck.MALFORMED_PAYLOAD,
+ client.verifySignatureDetailed(fodId));
+ assertFalse(client.verifySignature(fodId));
+ // Refused on shape before any key is needed.
+ assertEquals(0, transport.requests.size());
+ }
+
+ @Test
+ public void verifySignature_TrueForPayloadLongerThanBase() throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key2.fodIdAt(
+ canonicalPayloadWithSection(25), WEEK2.plus(Duration.ofDays(1)));
+
+ assertTrue(client.verifySignature(fodId));
+ }
+
+ @Test
+ public void verifySignature_TrueForALongContextSectionAndLongDomain()
+ throws Exception {
+ // A self-hosted container signs with its own creator domain, which
+ // may be longer than the cloud's, and a context section of a
+ // version this package does not read may be any length. Neither is
+ // this client's business, so both must verify.
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key2.fodIdAt(
+ canonicalPayloadWithSection(512),
+ WEEK2.plus(Duration.ofDays(1)),
+ "a-rather-long-self-hosted-creator.example.internal.51degrees.com");
+
+ assertTrue(client.verifySignature(fodId));
+ }
+
+ @Test
+ public void verifySignature_TrueForRandomBaseLength() throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ FodId fodId = key2.fodIdAt(
+ canonicalRandomPayload(), WEEK2.plus(Duration.ofDays(1)));
+
+ assertEquals(IdType.RANDOM, fodId.getType());
+ assertTrue(client.verifySignature(fodId));
+ }
+
+ // ----- Cloud signature verification -----
+
+ @Test
+ public void verify_ValidAnswers200True() throws Exception {
+ transport.queue(200, "{\"valid\":true}");
+ FodId fodId = key2.fodIdAt(canonicalPayload(), WEEK2);
+
+ assertTrue(client.verify(fodId));
+
+ HttpTransport.Request request = transport.last();
+ assertEquals("GET", request.getMethod());
+ assertEquals(ENDPOINT + "id/verify/resource?51did="
+ + fodId.asBase64Url() + "&owid=" + fodId.asBase64Url(),
+ request.getUrl());
+ assertFalse(request.getUrl().contains("licence"));
+ assertNull(request.getBody());
+ }
+
+ @Test
+ public void verify_OverLongStringIsRefusedBeforeTransport() {
+ // Nothing this long can be an identifier, so it is turned away
+ // before the client decodes it, fetches a key or calls the cloud.
+ assertThrows(IllegalArgumentException.class,
+ () -> client.verify(repeat('A', 8192)));
+
+ assertEquals(0, transport.requests.size());
+ }
+
+ @Test
+ public void verify_OverLongObjectIsRefusedBeforeTransport()
+ throws Exception {
+ FodId fodId = overLongFodId(key2, WEEK2);
+
+ assertThrows(IllegalArgumentException.class,
+ () -> client.verify(fodId));
+
+ assertEquals(0, transport.requests.size());
+ }
+
+ @Test
+ public void verify_InvalidAnswers400False() throws Exception {
+ transport.queue(400, "{\"valid\":false}");
+
+ assertFalse(client.verify("AwAA"));
+ }
+
+ @Test
+ public void verify_ErrorsAnswer400Raises() {
+ transport.queue(400, "{\"errors\":[\"Value for 51did is not a valid "
+ + "Base64-encoded 51Did: 'x'.\"]}");
+
+ IllegalArgumentException error = assertThrows(
+ IllegalArgumentException.class, () -> client.verify("x"));
+
+ assertTrue(error.getMessage().contains("not a valid"));
+ }
+
+ @Test
+ public void verify_OtherStatusRaisesWithStatusAndBody() {
+ transport.queue(401, "{\"errors\":[\"bad key\"]}");
+
+ DidHttpException error = assertThrows(DidHttpException.class,
+ () -> client.verify("AwAA"));
+
+ assertEquals(401, error.getStatusCode());
+ assertTrue(error.getBody().contains("bad key"));
+ }
+
+ @Test
+ public void verify_TransportFailureRaisesIoException() {
+ assertThrows(IOException.class, () -> client.verify("AwAA"));
+ }
+
+ // ----- Redeem -----
+
+ @Test
+ public void redeem_RedeemedWithFactors() throws Exception {
+ String body = "{\"signature\":\"verified\",\"context\":\"mismatch\","
+ + "\"factors\":{\"transport\":\"verified\",\"device\":\"mismatch\","
+ + "\"browserip\":\"verified\",\"connectionip\":\"verified\","
+ + "\"asn\":\"verified\",\"browser\":\"mismatch\"},"
+ + "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ + "\"secondsSinceVerified\":2}";
+ transport.queue(200, body);
+ FodId fodId = key2.fodIdAt(canonicalPayload(), WEEK2);
+
+ RedeemResult result = client.redeem(fodId, "sealed", "abc");
+
+ assertEquals(RedeemResult.Context.MISMATCH, result.getContext());
+ assertEquals("mismatch", result.getContextValue());
+ assertEquals(RedeemResult.Signature.VERIFIED, result.getSignature());
+ assertTrue(result.hasFactors());
+ assertEquals(Arrays.asList("transport", "device", "browserip",
+ "connectionip", "asn", "browser"),
+ new ArrayList(result.getFactors().keySet()));
+ assertEquals(RedeemResult.Factor.VERIFIED,
+ result.getFactors().get("transport"));
+ assertEquals(RedeemResult.Factor.MISMATCH,
+ result.getFactors().get("device"));
+ assertEquals(Instant.parse("2026-08-07T09:15:32Z"),
+ result.getVerifiedAt());
+ assertEquals(Integer.valueOf(2), result.getSecondsSinceVerified());
+ assertEquals(200, result.getStatusCode());
+ assertEquals(body, result.getRaw());
+
+ HttpTransport.Request request = transport.last();
+ assertEquals("POST", request.getMethod());
+ assertEquals(ENDPOINT + "id/redeem", request.getUrl());
+ assertFalse(request.getUrl().contains("licence"));
+ assertFalse(request.getUrl().contains("resource"));
+ assertTrue(request.getHeaders().get("Content-Type")
+ .startsWith("application/x-www-form-urlencoded"));
+ String form = new String(request.getBody(), StandardCharsets.UTF_8);
+ assertTrue(form.startsWith("resource=resource&51did="
+ + DidClient.encode(fodId.asBase64()) + "&"));
+ assertTrue(form.contains("&result=sealed"));
+ assertTrue(form.contains("&challenge=abc"));
+ assertTrue(form.contains("&license=licence"));
+ }
+
+ @Test
+ public void redeem_RedeemedWithoutFactors() throws Exception {
+ transport.queue(200, "{\"signature\":\"verified\","
+ + "\"context\":\"verified\","
+ + "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ + "\"secondsSinceVerified\":0}");
+
+ RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+
+ assertEquals(RedeemResult.Context.VERIFIED, result.getContext());
+ assertEquals(RedeemResult.Signature.VERIFIED, result.getSignature());
+ assertFalse(result.hasFactors());
+ assertTrue(result.getFactors().isEmpty());
+ assertEquals(Integer.valueOf(0), result.getSecondsSinceVerified());
+ assertNotNull(result.getVerifiedAt());
+ }
+
+ @Test
+ public void redeem_InvalidSignatureIsReported() throws Exception {
+ transport.queue(200, "{\"signature\":\"invalid\","
+ + "\"context\":\"verified\","
+ + "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ + "\"secondsSinceVerified\":1}");
+
+ RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+
+ assertEquals(RedeemResult.Signature.INVALID, result.getSignature());
+ }
+
+ @Test
+ public void redeem_Expired() throws Exception {
+ transport.queue(200, "{\"context\":\"expired\","
+ + "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ + "\"secondsSinceVerified\":14}");
+
+ RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+
+ assertEquals(RedeemResult.Context.EXPIRED, result.getContext());
+ assertEquals(RedeemResult.Signature.UNKNOWN, result.getSignature());
+ assertEquals(Integer.valueOf(14), result.getSecondsSinceVerified());
+ assertEquals(Instant.parse("2026-08-07T09:15:32Z"),
+ result.getVerifiedAt());
+ assertFalse(result.hasFactors());
+ }
+
+ @Test
+ public void redeem_Replayed() throws Exception {
+ transport.queue(200, "{\"context\":\"replayed\"}");
+
+ RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+
+ assertEquals(RedeemResult.Context.REPLAYED, result.getContext());
+ assertNull(result.getVerifiedAt());
+ assertNull(result.getSecondsSinceVerified());
+ }
+
+ @Test
+ public void redeem_Unreadable() throws Exception {
+ transport.queue(200, "{\"context\":\"unreadable\"}");
+
+ RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+
+ assertEquals(RedeemResult.Context.UNREADABLE, result.getContext());
+ assertEquals(RedeemResult.Signature.UNKNOWN, result.getSignature());
+ }
+
+ @Test
+ public void redeem_503Unconfirmed() throws Exception {
+ transport.queue(503, "{\"context\":\"unconfirmed\"}");
+
+ RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+
+ assertEquals(RedeemResult.Context.UNCONFIRMED, result.getContext());
+ assertEquals(503, result.getStatusCode());
+ }
+
+ @Test
+ public void redeem_UnknownContextFailsClosedAndKeepsTheRawValue()
+ throws Exception {
+ transport.queue(200, "{\"context\":\"something-new\"}");
+
+ RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+
+ assertEquals(RedeemResult.Context.UNREADABLE, result.getContext());
+ assertEquals("something-new", result.getContextValue());
+ }
+
+ @Test
+ public void redeem_MissingContextFailsClosed() throws Exception {
+ transport.queue(200, "{}");
+
+ RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+
+ assertEquals(RedeemResult.Context.UNREADABLE, result.getContext());
+ assertEquals("unreadable", result.getContextValue());
+ }
+
+ @Test
+ public void redeem_400ErrorsRaisesArgumentError() {
+ transport.queue(400, "{\"errors\":[\"'x' is not a valid "
+ + "Base64-encoded 51Did.\"]}");
+
+ IllegalArgumentException error = assertThrows(
+ IllegalArgumentException.class,
+ () -> client.redeem("x", "sealed", "abc"));
+
+ assertTrue(error.getMessage().contains("not a valid"));
+ }
+
+ @Test
+ public void redeem_OverLongInputsAreRefusedBeforeTransport()
+ throws Exception {
+ FodId fodId = overLongFodId(key2, WEEK2);
+
+ assertThrows(IllegalArgumentException.class,
+ () -> client.redeem(repeat('A', 8192), "sealed", "abc"));
+ assertThrows(IllegalArgumentException.class,
+ () -> client.redeem(fodId, "sealed", "abc"));
+
+ assertEquals(0, transport.requests.size());
+ }
+
+ @Test
+ public void redeem_404RaisesNotSupported() {
+ transport.queue(404, "Not Found");
+
+ DidNotSupportedException error = assertThrows(
+ DidNotSupportedException.class,
+ () -> client.redeem("AwAA", "sealed", "abc"));
+
+ assertEquals(404, error.getStatusCode());
+ assertEquals("Not Found", error.getBody());
+ assertTrue(error.getMessage().contains(ENDPOINT));
+ }
+
+ @Test
+ public void redeem_OtherStatusRaisesWithStatusAndBody() {
+ transport.queue(500, "boom");
+
+ DidHttpException error = assertThrows(DidHttpException.class,
+ () -> client.redeem("AwAA", "sealed", "abc"));
+
+ assertEquals(500, error.getStatusCode());
+ assertEquals("boom", error.getBody());
+ }
+
+ @Test
+ public void redeem_NonJson200Raises() {
+ transport.queue(200, "proxy");
+
+ DidHttpException error = assertThrows(DidHttpException.class,
+ () -> client.redeem("AwAA", "sealed", "abc"));
+
+ assertEquals(200, error.getStatusCode());
+ }
+
+ @Test
+ public void redeem_TransportFailureRaisesIoException() {
+ assertThrows(IOException.class,
+ () -> client.redeem("AwAA", "sealed", "abc"));
+ }
+
+ @Test
+ public void redeem_WithoutLicenceKeyOmitsTheField() throws Exception {
+ DidClient noLicence = DidClient.builder("resource")
+ .endpoint(ENDPOINT).transport(transport).build();
+ transport.queue(200, "{\"context\":\"unreadable\"}");
+
+ noLicence.redeem("AwAA", "sealed", null);
+
+ String form = new String(
+ transport.last().getBody(), StandardCharsets.UTF_8);
+ assertFalse(form.contains("license"));
+ assertTrue(form.endsWith("&challenge="));
+ }
+
+ @Test
+ public void redeem_FormEncodesTheValues() throws Exception {
+ transport.queue(200, "{\"context\":\"unreadable\"}");
+
+ client.redeem("AwAA+/==", "a b&c", "x=y");
+
+ String form = new String(
+ transport.last().getBody(), StandardCharsets.UTF_8);
+ assertTrue(form.startsWith("resource=resource&51did=AwAA%2B%2F%3D%3D&"));
+ assertTrue(form.contains("&result=a+b%26c&"));
+ assertTrue(form.contains("&challenge=x%3Dy&"));
+ }
+
+ // ----- Helpers -----
+
+ /**
+ * An identifier whose encoded form is longer than the client will take
+ * from a caller. The identifier itself is perfectly good, so this only
+ * shows that the object overloads pass through the same guard as the
+ * string ones.
+ */
+ private static FodId overLongFodId(FodIdTestFactory factory, Instant date)
+ throws OwidException {
+ FodId fodId = factory.fodIdAt(canonicalPayloadWithSection(3200), date);
+ assertTrue(fodId.asBase64Url().length() > 4096);
+ return fodId;
+ }
+
+ private static String repeat(char value, int count) {
+ char[] characters = new char[count];
+ Arrays.fill(characters, value);
+ return new String(characters);
+ }
+
+ private String keyList(String dateField, boolean withWeekStart) {
+ JSONArray array = new JSONArray();
+ array.put(keyEntry(dateField, WEEK1, key1, withWeekStart));
+ array.put(keyEntry(dateField, WEEK2, key2, withWeekStart));
+ array.put(keyEntry(dateField, WEEK3, key3, withWeekStart));
+ return array.toString();
+ }
+
+ private static JSONObject keyEntry(
+ String dateField, Instant startsAt, FodIdTestFactory key) {
+ return keyEntry(dateField, startsAt, key, false);
+ }
+
+ private static JSONObject keyEntry(
+ String dateField,
+ Instant startsAt,
+ FodIdTestFactory key,
+ boolean withWeekStart) {
+ JSONObject entry = new JSONObject();
+ // The cloud writes the C# round-trip form, with seven fractional
+ // digits, so that is what the parser is given.
+ entry.put(dateField, startsAt.toString()
+ .replace("Z", ".0000000Z"));
+ if (withWeekStart) {
+ // A wrong value on purpose, so a parser that read it would
+ // select the wrong key and fail the selection tests.
+ entry.put("weekStart", "2001-01-01T00:00:00.0000000Z");
+ }
+ entry.put("publicKey", key.publicPem);
+ return entry;
+ }
+
+ /** Records every request and answers from a queue. */
+ static final class FakeTransport implements HttpTransport {
+
+ final List requests = new ArrayList();
+ private final Deque responses = new ArrayDeque();
+
+ void queue(int status, String body) {
+ responses.add(new Response(status, body));
+ }
+
+ Request last() {
+ return requests.get(requests.size() - 1);
+ }
+
+ @Override
+ public Response send(Request request) throws IOException {
+ requests.add(request);
+ if (responses.isEmpty()) {
+ throw new IOException("Nothing queued for " + request.getUrl());
+ }
+ return responses.removeFirst();
+ }
+ }
+
+ /** A clock the test moves by hand. */
+ static final class MutableClock extends Clock {
+
+ private Instant now;
+
+ MutableClock(Instant now) {
+ this.now = now;
+ }
+
+ void advance(Duration by) {
+ now = now.plus(by);
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ return now;
+ }
+ }
+}
diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java
index 1bfc1c104..9b13a4ee6 100644
--- a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java
@@ -26,7 +26,11 @@
import com.swancommunity.owid.Crypto;
import com.swancommunity.owid.Owid;
import com.swancommunity.owid.OwidException;
+import com.swancommunity.owid.Version;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.time.Instant;
/**
@@ -51,13 +55,23 @@ final class FodIdTestFactory {
/** The canonical 32-byte hash value, bytes 0x20..0x3F. */
static final byte[] CANONICAL_HASH = canonicalHash();
+ /** The origin the envelope date counts minutes from. */
+ static final Instant DATE_ORIGIN = Instant.parse("2020-01-01T00:00:00Z");
+
private final Creator creator;
+ /** The key pair behind {@link #publicPem}. */
+ final Crypto crypto;
+
/** The PEM-encoded public key matching the signing key. */
final String publicPem;
FodIdTestFactory() throws OwidException {
- Crypto crypto = Crypto.generate();
+ this(Crypto.generate());
+ }
+
+ FodIdTestFactory(Crypto crypto) throws OwidException {
+ this.crypto = crypto;
this.publicPem = crypto.publicKeyPem();
this.creator = Creator.create(TEST_DOMAIN, crypto);
}
@@ -98,6 +112,20 @@ static byte[] canonicalRandomPayload() {
return payload;
}
+ /**
+ * The canonical payload followed by a context section of the given
+ * length, as an identifier carrying a creator context is laid out.
+ */
+ static byte[] canonicalPayloadWithSection(int sectionLength) {
+ byte[] payload = new byte[FodId.PAYLOAD_LENGTH + sectionLength];
+ System.arraycopy(
+ canonicalPayload(), 0, payload, 0, FodId.PAYLOAD_LENGTH);
+ for (int i = FodId.PAYLOAD_LENGTH; i < payload.length; i++) {
+ payload[i] = (byte) 0xCC;
+ }
+ return payload;
+ }
+
private static void writeCanonicalLicenseId(byte[] payload) {
// Little-endian: low byte first.
payload[FodId.LICENSE_ID_OFFSET] = 0x78;
@@ -121,4 +149,68 @@ Owid signedOwid(byte[] payload) throws OwidException {
String signedOwidBase64(byte[] payload) throws OwidException {
return signedOwid(payload).asBase64();
}
+
+ /**
+ * Signs the given payload with the envelope dated at the given moment,
+ * which {@code Creator.sign} cannot do because it stamps the current
+ * time. The envelope is built by hand in the OWID wire layout (version
+ * byte, null-terminated domain, four little-endian bytes of minutes
+ * since 2020, four little-endian bytes of payload length, the payload)
+ * and signed over exactly those bytes, so the result verifies with
+ * {@link #publicPem} and reads back with the chosen date.
+ */
+ Owid signedOwidAt(byte[] payload, Instant date) throws OwidException {
+ return signedOwidAt(payload, date, Version.VERSION3);
+ }
+
+ /**
+ * As {@link #signedOwidAt(byte[], Instant)} with the version byte given,
+ * so a test can produce a version 2 envelope. Versions 2 and 3 share the
+ * wire layout.
+ */
+ Owid signedOwidAt(byte[] payload, Instant date, Version version)
+ throws OwidException {
+ return signedOwidAt(payload, date, version, TEST_DOMAIN);
+ }
+
+ /**
+ * As {@link #signedOwidAt(byte[], Instant, Version)} with the creator
+ * domain given, because the domain is a deployment parameter and a
+ * self-hosted container may sign with a longer one than the cloud does.
+ */
+ Owid signedOwidAt(
+ byte[] payload, Instant date, Version version, String domainName)
+ throws OwidException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.write(version.asByte());
+ byte[] domain = domainName.getBytes(StandardCharsets.UTF_8);
+ out.write(domain, 0, domain.length);
+ out.write(0);
+ writeUInt32(out, Duration.between(DATE_ORIGIN, date).toMinutes());
+ writeUInt32(out, payload.length);
+ out.write(payload, 0, payload.length);
+ byte[] unsigned = out.toByteArray();
+ byte[] signature = crypto.signByteArray(unsigned);
+ out.write(signature, 0, signature.length);
+ return Owid.fromByteArray(out.toByteArray());
+ }
+
+ /** Signs the payload dated at the moment and parses it as a 51Did. */
+ FodId fodIdAt(byte[] payload, Instant date) throws OwidException {
+ return FodId.fromOwid(signedOwidAt(payload, date));
+ }
+
+ /** As {@link #fodIdAt(byte[], Instant)} with the creator domain given. */
+ FodId fodIdAt(byte[] payload, Instant date, String domain)
+ throws OwidException {
+ return FodId.fromOwid(
+ signedOwidAt(payload, date, Version.VERSION3, domain));
+ }
+
+ private static void writeUInt32(ByteArrayOutputStream out, long value) {
+ out.write((int) (value & 0xFF));
+ out.write((int) ((value >> 8) & 0xFF));
+ out.write((int) ((value >> 16) & 0xFF));
+ out.write((int) ((value >> 24) & 0xFF));
+ }
}
diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java
index 2de51f316..ca5397c80 100644
--- a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java
@@ -25,9 +25,11 @@
import com.swancommunity.owid.Crypto;
import com.swancommunity.owid.Owid;
import com.swancommunity.owid.OwidException;
+import com.swancommunity.owid.Version;
import org.junit.Before;
import org.junit.Test;
+import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
@@ -37,6 +39,7 @@
import static fiftyone.pipeline.did.FodIdTestFactory.CANONICAL_LICENSE_ID;
import static fiftyone.pipeline.did.FodIdTestFactory.TEST_DOMAIN;
import static fiftyone.pipeline.did.FodIdTestFactory.canonicalPayload;
+import static fiftyone.pipeline.did.FodIdTestFactory.canonicalPayloadWithSection;
import static fiftyone.pipeline.did.FodIdTestFactory.canonicalRandomPayload;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
@@ -239,6 +242,32 @@ public void constructor_PayloadLargerThanSpec_UsesFirst37Bytes() throws Exceptio
assertEquals(FodId.HASH_LENGTH, fodId.getHash().length);
}
+ @Test
+ public void constructor_LongContextSectionAndLongDomain_Parses()
+ throws Exception {
+ // The creator context section has no length this package knows, and
+ // the creator domain is a deployment parameter, so a self-hosted
+ // container may sign with a longer one. Both must read back, and
+ // the cloud stays the judge of what the section means.
+ String longDomain = "a-rather-long-self-hosted-creator."
+ + "identifier.example.internal.51degrees.com";
+ byte[] payload = canonicalPayloadWithSection(512);
+
+ Owid owid = factory.signedOwidAt(
+ payload, Instant.now(), Version.VERSION3, longDomain);
+ FodId fromBytes = FodId.fromByteArray(owid.asByteArray());
+ FodId fromBase64 = FodId.fromBase64(owid.asBase64());
+ FodId fromOwid = FodId.fromOwid(owid);
+
+ for (FodId fodId : new FodId[] { fromBytes, fromBase64, fromOwid }) {
+ assertEquals(longDomain, fodId.getDomain());
+ assertEquals(CANONICAL_FLAGS, fodId.getFlags());
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ assertArrayEquals(payload, fodId.getPayload());
+ assertTrue(fodId.verify(factory.publicPem));
+ }
+ }
+
@Test
public void fodId_IsCryptographicallyVerifiable() throws Exception {
FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload()));
@@ -409,4 +438,114 @@ public void roundtrip_ThroughBytesConstructor_PreservesAllFields()
assertArrayEquals(fodId1.getHash(), fodId2.getHash());
assertEquals(fodId1.getDomain(), fodId2.getDomain());
}
+
+ // ----- Base64 alphabets and the envelope date -----
+
+ @Test
+ public void fromBase64_AcceptsStandardUrlSafeAndUnpadded() throws Exception {
+ String standard = factory.signedOwidBase64(canonicalPayload());
+ String urlSafe = standard.replace('+', '-').replace('/', '_');
+ String unpadded = urlSafe.replace("=", "");
+ // The envelope is 124 bytes, so the standard form always ends in
+ // padding and the unpadded form always differs from it.
+ assertTrue(standard.endsWith("="));
+ assertFalse(unpadded.endsWith("="));
+
+ FodId fromStandard = FodId.fromBase64(standard);
+ FodId fromUrlSafe = FodId.fromBase64(urlSafe);
+ FodId fromUnpadded = FodId.fromBase64(unpadded);
+
+ assertArrayEquals(fromStandard.asByteArray(), fromUrlSafe.asByteArray());
+ assertArrayEquals(fromStandard.asByteArray(), fromUnpadded.asByteArray());
+ assertArrayEquals(CANONICAL_HASH, fromUnpadded.getHash());
+ }
+
+ @Test
+ public void fromBase64_IgnoresSurroundingWhitespace() throws Exception {
+ String clean = factory.signedOwidBase64(canonicalPayload());
+ byte[] expected = FodId.fromBase64(clean).asByteArray();
+ String urlSafe = clean.replace('+', '-').replace('/', '_')
+ .replace("=", "");
+
+ // A value read from a header, a file or a form field often arrives
+ // with a newline or a space around it, and every one of these is
+ // the same identifier.
+ assertArrayEquals(
+ expected, FodId.fromBase64(clean + "\n").asByteArray());
+ assertArrayEquals(
+ expected, FodId.fromBase64(clean + "\r\n").asByteArray());
+ assertArrayEquals(
+ expected, FodId.fromBase64(" " + clean).asByteArray());
+ assertArrayEquals(
+ expected, FodId.fromBase64(clean + " ").asByteArray());
+ assertArrayEquals(
+ expected, FodId.fromBase64(" " + clean + " ").asByteArray());
+ assertArrayEquals(
+ expected, FodId.fromBase64(urlSafe + "\n").asByteArray());
+ assertArrayEquals(
+ expected, FodId.fromBase64(" " + urlSafe + " ").asByteArray());
+ }
+
+ @Test
+ public void toStandardBase64_RestoresAlphabetAndPadding() {
+ assertEquals("+/8=", FodId.toStandardBase64("-_8"));
+ assertEquals("+/==", FodId.toStandardBase64("-_"));
+ assertEquals("+/8=", FodId.toStandardBase64("+/8="));
+ assertEquals("abcd", FodId.toStandardBase64("abcd"));
+ // The padding comes from the trimmed length, so whitespace cannot
+ // push the value into the wrong case.
+ assertEquals("+/8=", FodId.toStandardBase64("-_8\n"));
+ assertEquals("+/8=", FodId.toStandardBase64(" -_8 "));
+ assertEquals("+/==", FodId.toStandardBase64("-_\r\n"));
+ assertEquals("abcd", FodId.toStandardBase64(" abcd "));
+ }
+
+ @Test
+ public void asBase64Url_RoundTrips() throws Exception {
+ FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload()));
+
+ String url = fodId.asBase64Url();
+
+ assertFalse(url.contains("+"));
+ assertFalse(url.contains("/"));
+ assertFalse(url.contains("="));
+ assertEquals(fodId.asBase64(), FodId.toStandardBase64(url));
+ assertArrayEquals(fodId.asByteArray(), FodId.fromBase64(url).asByteArray());
+ }
+
+ @Test
+ public void dateMinutes_IsTheEnvelopeDateField() throws Exception {
+ Instant date = Instant.parse("2026-01-01T00:00:00Z");
+
+ FodId fodId = factory.fodIdAt(canonicalPayload(), date);
+
+ // 2020 through 2025 is 2192 days, 2020 and 2024 being leap years.
+ assertEquals(2192L * 24 * 60, fodId.getDateMinutes());
+ assertEquals(3_156_480L, fodId.getDateMinutes());
+ assertEquals(date, fodId.getDate());
+ }
+
+ @Test
+ public void dateMinutes_HighBitStaysUnsigned() throws Exception {
+ // 0x80000000 minutes after 2020 is the year 6103, inside the uint32
+ // range the envelope stores, and must not read back negative.
+ Instant date = FodIdTestFactory.DATE_ORIGIN.plus(
+ Duration.ofMinutes(0x80000000L));
+
+ FodId fodId = factory.fodIdAt(canonicalPayload(), date);
+
+ assertEquals(0x80000000L, fodId.getDateMinutes());
+ }
+
+ @Test
+ public void factory_SignedAtChosenDate_VerifiesWithItsKey() throws Exception {
+ // The hand-built envelope the client tests rely on is signed over
+ // exactly the bytes the library verifies, or every selection test
+ // would pass for the wrong reason.
+ FodId fodId = factory.fodIdAt(
+ canonicalPayload(), Instant.parse("2026-08-05T12:00:00Z"));
+
+ assertTrue(fodId.verify(factory.publicPem));
+ assertFalse(fodId.verify(Crypto.generate().publicKeyPem()));
+ }
}