diff --git a/owid-java b/owid-java index 7a7f30311..694f63332 160000 --- a/owid-java +++ b/owid-java @@ -1 +1 @@ -Subproject commit 7a7f30311079d13a356e4339917e872b31dfd134 +Subproject commit 694f63332734132f9e432b9bf1e9ecb734af1b1c diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/Main.java b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/Main.java index fade8d438..1e98446fa 100644 --- a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/Main.java +++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/Main.java @@ -24,10 +24,8 @@ import com.swancommunity.owid.Creator; import com.swancommunity.owid.Crypto; -import com.swancommunity.owid.Owid; import fiftyone.pipeline.did.FodId; -import java.time.Instant; import java.util.Arrays; /** @@ -89,12 +87,14 @@ public void run() throws Exception { } } - /** Issues (signs) a 51Did over the payload and returns it as base64. */ + /** + * Issues (signs) a 51Did over the payload and returns it as base64. + * The creator stamps the date and signs in one step, which is the + * only way an OWID comes into being. + */ private String issue(Creator creator, byte[] payload) throws Exception { - Owid owid = new Owid(DOMAIN, Instant.now(), payload); - creator.sign(owid); - return owid.asBase64(); + return creator.createBytes(payload).asBase64(); } /** 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 0ee2b7433..9451e81fc 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 @@ -24,7 +24,6 @@ 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; @@ -59,10 +58,9 @@ public class ExampleTests { 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(); + did = FodId.fromOwid(creator.createBytes(samplePayload())) + .asBase64Url(); transport = new FakeTransport(); client = DidClient.builder("resource") .licenceKey("licence") diff --git a/pipeline.did/README.md b/pipeline.did/README.md index d3f95e7e3..be047f66a 100644 --- a/pipeline.did/README.md +++ b/pipeline.did/README.md @@ -42,10 +42,14 @@ 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. +The minimums in that table are the only lengths this package enforces. There +is no upper bound. An identifier carrying a creator context is longer than +the minimum, its extra bytes have a shape only the cloud knows, and a reader +built before that shape existed still reads the identifier, so the package +never refuses a payload for being long. 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 @@ -55,11 +59,18 @@ package `com.swancommunity.owid`). Because that library's `Owid` type is `final`, `FodId` **composes** an OWID (holds one and delegates OWID-level concerns to it) rather than inheriting from it. -The OWID source is consumed from a git submodule of the 51Degrees fork at the -repository root (`owid-java/`, mirroring how `pipeline-dotnet` carries the -`owid-dotnet` submodule) and compiled into this module at its Java 8 level, so -there is no separate runtime dependency. The vendored OWID sources keep their -Apache-2.0 headers; the 51Did sources are EUPL-1.2. +The OWID source is consumed from a git submodule at the repository root +(`owid-java/`, mirroring how `pipeline-dotnet` carries the `owid-dotnet` +submodule) and compiled into this module at its Java 8 level, so there is no +separate runtime dependency. The vendored OWID sources keep their Apache-2.0 +headers; the 51Did sources are EUPL-1.2. + +The OWID library only hands out an `Owid` that came from a complete, +structurally valid read or from a `Creator` that signed it. There is no +public constructor, no setter and no throwing parse. `FodId` keeps the same +rule, so a `FodId` you hold is always a whole envelope over a payload that +met the 51Did minimums, and the only thing left to ask about it is whether +the signature is genuine. The cloud client reads JSON with `org.json:json`, at the version the pipeline's cloud request engine already uses. @@ -74,6 +85,82 @@ and an attribution: see `META-INF/LICENSE-owid.txt` and `META-INF/NOTICE.txt` [SWAN-community/owid-java](https://github.com/SWAN-community/owid-java), Apache-2.0). +## Reading a 51Did + +A 51Did arrives from outside, so failing to be one is an ordinary outcome +rather than an error. The readers that answer without throwing are the ones +to use on external input. + +```java +import fiftyone.pipeline.did.FodId; +import fiftyone.pipeline.did.FodIdParseResult; +import fiftyone.pipeline.did.FodIdParseStatus; + +FodIdParseResult read = FodId.tryFromBase64(valueFromThePage); +if (read.isSuccess()) { + FodId fodId = read.getValue(); // structurally valid, not yet verified +} else { + FodIdParseStatus why = read.getStatus(); // for example INVALID_BASE64 +} +``` + +`tryFromByteArray(byte[])` does the same for the raw envelope bytes. Every +result reports the same three facts, and they always agree: + +1. `isSuccess()` says whether the input was a 51Did. +2. `getValue()` is the `FodId` on success and `null` otherwise. There is never + a half-read `FodId`. +3. `getStatus()` is `PARSED` on success and names the reason otherwise. + +The throwing readers `fromBase64(String)`, `fromByteArray(byte[])` and +`fromOwid(Owid)` make exactly the same read and throw instead, for code that +already handles exceptions. An envelope fault throws `OwidException` with the +status in the message, a payload shorter than its type's minimum throws +`IllegalArgumentException`, and a `null` argument throws +`NullPointerException`. + +Both string readers accept the standard alphabet the cloud issues (`+`, `/`, +padded) and the URL-safe alphabet a page puts in a link (`-`, `_`, padding +optional), with or without whitespace around the value. `asBase64Url()` gives +the URL-safe form back, so an identifier can go into a URL without any +conversion by the caller. + +### What the status means + +`FodIdParseStatus` carries the OWID library's own statuses across under the +same names, so an envelope fault is reported exactly as the envelope reader +found it, and adds two of its own for the payload rules. + +| Status | Meaning | Layer | +|---|---|---| +| `PARSED` | A structurally valid 51Did. Says nothing about the signature. | | +| `MISSING_INPUT` | Null or empty input. | OWID | +| `INVALID_BASE64` | The string is not base64. | OWID | +| `UNSUPPORTED_VERSION` | The first byte names an envelope version the reader does not know. | OWID | +| `UNEXPECTED_END` | The data stopped in the middle of an envelope field. | OWID | +| `INVALID_DOMAIN_ENCODING` | The creator domain is unterminated or longer than a domain name can be. | OWID | +| `BYTE_COUNT_MISMATCH` | The declared payload length disagrees with the bytes present. | OWID | +| `IMPLEMENTATION_CAPACITY_EXCEEDED` | Consistent, but larger than this runtime can hold. Not reachable in Java. | OWID | +| `MALFORMED_ENVELOPE` | A fault none of the others describes. Nothing produces one today. | OWID | +| `ABSENT_NODE` | The single zero byte that marks an absent optional OWID. Not a fault, not a 51Did. | OWID | +| `INVALID_INPUT_TYPE` | Kept for the cross language vocabulary. Not reachable in Java. | OWID | +| `PAYLOAD_TOO_SHORT` | The envelope read, but the payload cannot hold the five byte header, so the type cannot be read. | 51Did | +| `INVALID_TYPE_PAYLOAD_LENGTH` | The header names a type and the payload is shorter than that type's minimum. | 51Did | + +Every one of those is an expected data result and comes back as a status. +What remains exceptional is a `null` passed to a throwing reader, and on the +client, a cloud that cannot be reached or a key list that cannot be fetched. + +### Reading is not verifying + +A successful read means the bytes are the right shape. It does not mean the +cloud issued them. Verify the signature as a separate step, with +`verify(publicKeyPem)` for a yes or no, `verifyDetailed(publicKeyPem)` to +learn why not, or `DidClient` to check against the cloud's published keys. +`verifyDetailed` keeps "the signature does not match" (`SIGNATURE_INVALID`) +apart from "the signature could not be checked" (`KEY_UNAVAILABLE`, +`INVALID_KEY`), because an outage is not a forgery. + ## Usage ```java @@ -95,11 +182,6 @@ 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 @@ -134,10 +216,14 @@ 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. +1. **Read.** The identifier arrives from a page in the URL-safe alphabet. ```java - FodId fodId = FodId.fromBase64(fromThePage); + FodIdParseResult read = FodId.tryFromBase64(fromThePage); + if (read.isSuccess() == false) { + // answer 400, naming read.getStatus() if you wish + } + FodId fodId = read.getValue(); ``` 2. **Verify offline.** The client fetches the cloud's signing keys once, @@ -153,7 +239,9 @@ In the order a server uses them: `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. + or when the list is more than a day old. A key list that cannot be + fetched raises `IOException`, never a false, because not being able to + check is not the same as the signature being wrong. 3. **Verify through the cloud.** The open verify endpoint, one use against the resource key, needing no licence key. @@ -184,13 +272,23 @@ In the order a server uses them: 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. + A malformed identifier raises `IllegalArgumentException`, 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. + +The client methods that take the identifier as a string, `verify(String)` +and `redeem(String, String, String)`, read the value before doing anything +else, and a value that does not read as a 51Did is refused with +`IllegalArgumentException` naming the status before any key is fetched or +the cloud is called. Two things are worth knowing about that boundary. The +client also turns away any string longer than a generous fixed limit before +reading it, which is client policy against obviously wrong input and says +nothing about how long a 51Did can be. And the client checks the shape, +not the signature, because the signature is the question the cloud is about +to be asked. `verify-context` and `verify-full` are browser calls rather than client methods, because the creator context describes the browser's own @@ -201,10 +299,47 @@ 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. +## Migrating from the OWID library's removed API + +Earlier OWID library versions let code build an `Owid` directly, parse one +with a throwing factory, and change its fields afterwards. The hardened +library removed all three, so code that reached those through this package +changes as follows. + +Before: + +```java +Owid owid = Owid.fromBase64(text); // threw on bad input +FodId fodId = FodId.fromOwid(owid); + +Owid built = new Owid(domain, Instant.now(), payload); +creator.sign(built); +``` + +After: + +```java +FodIdParseResult read = FodId.tryFromBase64(text); // never throws +if (read.isSuccess()) { + FodId fodId = read.getValue(); +} +// or keep the exception style, which makes the same read: +FodId fodId = FodId.fromBase64(text); + +Owid signed = creator.createBytes(payload); // stamped and signed +FodId fromSigned = FodId.fromOwid(signed); +``` + +`FodId.fromOwid(Owid)` still exists and still declares `OwidException`, so +callers compile unchanged, and since the library only hands out complete +signed envelopes there is no longer a copy made inside it. + ## Non-goals -- **No signature verification on construction.** Constructing a `FodId` does - 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 +- **No signature verification on reading.** Reading a `FodId` does not check + the signature. Call `verify(publicKeyPem)`, `verifyDetailed(publicKeyPem)` + or `DidClient.verifySignature(fodId)` when needed. +- **No creation of new 51Dids.** This is a reader and a verifier; new 51Dids are issued by the 51Degrees cloud / on-premise hashing engines. +- **No upper bound on a payload.** The cloud owns the shape of anything past + the value, and this package does not second-guess it. diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java index 1372a136d..03dfc3095 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/DidClient.java @@ -23,6 +23,8 @@ package fiftyone.pipeline.did; import com.swancommunity.owid.OwidException; +import com.swancommunity.owid.OwidSignatureStatus; +import com.swancommunity.owid.OwidVerificationResult; import com.swancommunity.owid.Version; import org.json.JSONArray; import org.json.JSONException; @@ -495,14 +497,15 @@ public SignatureCheck verifySignatureDetailed(FodId fodId) 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. + OwidVerificationResult check = + fodId.verifyDetailed(candidate.getPublicKeyPem()); + if (check.getStatus() == OwidSignatureStatus.SIGNATURE_VALID) { + return SignatureCheck.VERIFIED; } + // Any other answer, whether the signature does not match this + // key or the key itself cannot be read, leaves the next + // candidate to try. Only when none verifies is the signature + // reported invalid. } return SignatureCheck.INVALID; } @@ -534,15 +537,18 @@ public boolean verify(FodId fodId) throws IOException { * @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 + * identifier at all, if it does not + * read as a 51Did, with the + * {@link FodIdParseStatus} in the + * message, 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); + ensureReadsAs51Did(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. @@ -603,7 +609,10 @@ public RedeemResult redeem(FodId fodId, String result, String challenge) * @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 + * identifier at all, if it does not + * read as a 51Did, with the + * {@link FodIdParseStatus} in the + * message, or as * {@link #redeem(FodId, String, String)} * @throws IOException as {@link #redeem(FodId, String, String)} */ @@ -611,6 +620,7 @@ public RedeemResult redeem(String fodId, String result, String challenge) throws IOException { Objects.requireNonNull(fodId, "fodId"); ensureEncodedLength(fodId); + ensureReadsAs51Did(fodId); // The POST route has no {resource} segment, so the resource key // goes in the form with everything else. StringBuilder form = new StringBuilder() @@ -672,6 +682,22 @@ private static void ensureEncodedLength(String fodId) { } } + /** + * Refuses a value that does not read as a 51Did, before any key is + * fetched or the cloud is called, so malformed input costs no network + * round trip and no use. The value is still sent to the cloud exactly + * as the caller gave it. The signature is not checked here, because the + * signature is the question the cloud is about to be asked. + */ + private static void ensureReadsAs51Did(String fodId) { + FodIdParseResult read = FodId.tryFromBase64(fodId); + if (read.isSuccess() == false) { + throw new IllegalArgumentException( + "The value does not read as a 51Did: " + read.getStatus() + + "."); + } + } + private static DidHttpException httpError( String what, HttpTransport.Response response) { String body = response.getBody(); 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 5636a5d25..8fd6e23ca 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java @@ -24,6 +24,8 @@ import com.swancommunity.owid.Owid; import com.swancommunity.owid.OwidException; +import com.swancommunity.owid.OwidParseResult; +import com.swancommunity.owid.OwidVerificationResult; import com.swancommunity.owid.Version; import java.time.Duration; @@ -57,22 +59,30 @@ *
+ * Reading and verifying are two separate steps. {@link #tryFromBase64(String)} + * and {@link #tryFromByteArray(byte[])} read a 51Did from external input + * without throwing, answering with a {@link FodIdParseResult} that says + * whether the input was a 51Did and, when it was not, a + * {@link FodIdParseStatus} naming the reason. {@link #fromBase64(String)}, + * {@link #fromByteArray(byte[])} and {@link #fromOwid(Owid)} do the same read + * and throw instead for a caller who prefers an exception. A 51Did that + * reads successfully is structurally valid and nothing more. Its signature + * has not been checked, so call {@link #verify(String)} or + * {@link #verifyDetailed(String)} explicitly, or use {@link DidClient} to + * verify against the cloud's published keys. + *
* 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. + * Both readers of a string accept 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 + * rather than inheriting from it: it holds the 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, or use {@link DidClient} to verify against the cloud's published - * keys. */ public final class FodId { @@ -124,29 +134,93 @@ public final class FodId { private final long licenseId; private final byte[] hash; - private FodId(Owid owid, String paramName) { + /** + * Built only by {@link #read(Owid)} once the payload has passed the + * 51Did rules, so an instance never exists for a payload that failed + * them. + */ + private FodId(Owid owid, int flags, long licenseId, byte[] hash) { this.owid = owid; + this.flags = flags; + this.licenseId = licenseId; + this.hash = hash; + } + + // ----- Reading without throwing ----- + + /** + * Reads a 51Did from its base64 form without throwing, 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, and with or without whitespace around + * it. + *
+ * The value may be anything at all, because it is external data and + * failing to be a 51Did is an ordinary outcome rather than an error. The + * result reports whether the read worked, the 51Did only when it did, + * and a named reason either way. An envelope failure carries the OWID + * library's own status unchanged, and a payload failure is one of the + * two 51Did statuses. See {@link FodIdParseStatus}. + *
+ * Success means the input is structurally a 51Did. The signature has + * not been checked. + * + * @param base64 the encoded 51Did, which may be null + * @return the 51Did and {@link FodIdParseStatus#PARSED}, or no value and + * the reason the string is not a 51Did + */ + public static FodIdParseResult tryFromBase64(String base64) { + if (base64 == null) { + return FodIdParseResult.failed(FodIdParseStatus.MISSING_INPUT); + } + return read(Owid.parse(toStandardBase64(base64))); + } + + /** + * Reads a 51Did from the raw bytes of an OWID envelope without throwing. + * The buffer must hold exactly one envelope. See + * {@link #tryFromBase64(String)} for what the result reports. + * + * @param buffer the envelope bytes, which may be null + * @return the 51Did and {@link FodIdParseStatus#PARSED}, or no value and + * the reason the bytes are not a 51Did + */ + public static FodIdParseResult tryFromByteArray(byte[] buffer) { + return read(Owid.parse(buffer)); + } + + private static FodIdParseResult read(OwidParseResult envelope) { + if (envelope.isSuccess() == false) { + return FodIdParseResult.failed( + FodIdParseStatus.fromOwid(envelope.getStatus())); + } + return read(envelope.getValue()); + } + + /** + * Applies the 51Did payload rules to an envelope the OWID library has + * already read or signed. This is the one walk of the payload that every + * reader, throwing or not, goes through. + *
+ * The rules are lower bounds only. The header must be present before the + * type can be read, and the type then sets the least the payload can + * hold. Anything longer is accepted as it stands, because the bytes past + * the value are a creator context section whose shape the cloud judges. + */ + private static FodIdParseResult read(Owid owid) { byte[] payload = owid.getPayload(); - if (payload == null || payload.length < HEADER_LENGTH) { - throw new IllegalArgumentException( - "51Did payload must be at least " + HEADER_LENGTH - + " bytes; got " + (payload == null ? 0 : payload.length) - + " (" + paramName + ")."); + if (payload.length < HEADER_LENGTH) { + return FodIdParseResult.failed(FodIdParseStatus.PAYLOAD_TOO_SHORT); } - this.flags = payload[FLAGS_OFFSET] & 0xFF; - // Little-endian uint32, kept unsigned in a long so the high bit does - // not sign-extend into a negative value. - this.licenseId = - (payload[LICENSE_ID_OFFSET] & 0xFFL) - | ((payload[LICENSE_ID_OFFSET + 1] & 0xFFL) << 8) - | ((payload[LICENSE_ID_OFFSET + 2] & 0xFFL) << 16) - | ((payload[LICENSE_ID_OFFSET + 3] & 0xFFL) << 24); + int flags = payload[FLAGS_OFFSET] & 0xFF; int valueLength; switch (IdType.fromFlags(flags)) { case RANDOM: valueLength = GUID_LENGTH; break; case RESERVED: + // Not yet assigned, so read best-effort. The header fields + // are unpacked and whatever follows is exposed as the value. valueLength = payload.length - HEADER_LENGTH; break; default: @@ -154,55 +228,59 @@ private FodId(Owid owid, String paramName) { break; } if (payload.length < HEADER_LENGTH + valueLength) { - throw new IllegalArgumentException( - "51Did payload for the " + IdType.fromFlags(flags) - + " type must be at least " + (HEADER_LENGTH + valueLength) - + " bytes; got " + payload.length + " (" + paramName + ")."); + return FodIdParseResult.failed( + FodIdParseStatus.INVALID_TYPE_PAYLOAD_LENGTH); } - // Defensive copy: mutating the returned hash must not change the - // underlying OWID payload bytes. - this.hash = Arrays.copyOfRange( + // Little-endian uint32, kept unsigned in a long so the high bit does + // not sign-extend into a negative value. + long licenseId = + (payload[LICENSE_ID_OFFSET] & 0xFFL) + | ((payload[LICENSE_ID_OFFSET + 1] & 0xFFL) << 8) + | ((payload[LICENSE_ID_OFFSET + 2] & 0xFFL) << 16) + | ((payload[LICENSE_ID_OFFSET + 3] & 0xFFL) << 24); + // The value is copied out so that mutating the array a caller gets + // back from getHash() can never reach the envelope's own bytes. + byte[] hash = Arrays.copyOfRange( payload, HASH_OFFSET, HASH_OFFSET + valueLength); + return FodIdParseResult.parsed( + new FodId(owid, flags, licenseId, hash)); } + // ----- Reading with exceptions ----- + /** - * 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.
+ * Parses a 51Did from its base64 form, accepting the same inputs as
+ * {@link #tryFromBase64(String)}, and throws when the input is not a
+ * 51Did. The read is the same one, so the two never disagree about an
+ * input. Parsing does not check the signature.
*
* @param base64 base64 of the full OWID envelope
* @return the parsed 51Did
* @throws NullPointerException if {@code base64} is null
* @throws OwidException if the string is not valid base64 or not a
- * valid OWID
+ * valid OWID envelope, with the
+ * {@link FodIdParseStatus} in the message
* @throws IllegalArgumentException if the payload is shorter than the
* minimum for its identifier type
*/
public static FodId fromBase64(String base64) throws OwidException {
Objects.requireNonNull(base64, "base64");
- return new FodId(Owid.fromBase64(toStandardBase64(base64)), "base64");
+ return valueOrThrow(tryFromBase64(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.
+ * without padding, to the standard alphabet with padding, which is the
+ * only alphabet the envelope library reads. 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. Nothing here decides whether the
+ * result is base64 at all, which is the envelope library's answer.
*
* @param value the base64 text in either alphabet
* @return the same value in the standard alphabet with padding
@@ -220,40 +298,75 @@ static String toStandardBase64(String value) {
}
/**
- * Parses a 51Did from the raw bytes of an OWID envelope.
+ * Parses a 51Did from the raw bytes of an OWID envelope, accepting the
+ * same inputs as {@link #tryFromByteArray(byte[])}, and throws when the
+ * bytes are not a 51Did. Parsing does not check the signature.
*
* @param buffer the OWID envelope bytes
* @return the parsed 51Did
* @throws NullPointerException if {@code buffer} is null
* @throws OwidException if the bytes are not a valid OWID
+ * envelope, with the
+ * {@link FodIdParseStatus} in the message
* @throws IllegalArgumentException if the payload is shorter than the
* minimum for its identifier type
*/
public static FodId fromByteArray(byte[] buffer) throws OwidException {
Objects.requireNonNull(buffer, "buffer");
- return new FodId(Owid.fromByteArray(buffer), "buffer");
+ return valueOrThrow(tryFromByteArray(buffer), "buffer");
}
/**
- * Promotes an already-parsed OWID into a 51Did by unpacking its payload.
- * The OWID is copied (round-tripped through its byte form), not
- * aliased, so that a {@code FodId} can never desync from its envelope if
- * the caller later mutates the OWID it passed in. The supplied OWID must
- * therefore be signed (serializable).
+ * Promotes an OWID the envelope library has already read or signed into
+ * a 51Did by applying the payload rules to it. The OWID is held as it
+ * is, because the library hands out only immutable envelopes that came
+ * from a complete read or from a signer, so there is nothing a caller
+ * can later change underneath the 51Did.
*
- * @param owid the already-parsed OWID envelope
- * @return a 51Did wrapping an independent copy of {@code owid}
+ * @param owid the envelope
+ * @return a 51Did over {@code owid}
* @throws NullPointerException if {@code owid} is null
- * @throws OwidException if {@code owid} cannot be serialized (e.g.
- * it has not been signed)
+ * @throws OwidException never thrown by the current envelope
+ * library, which cannot hand out an
+ * envelope this method fails to read.
+ * Declared so that callers written against
+ * the earlier library keep compiling.
* @throws IllegalArgumentException if the payload is shorter than the
* minimum for its identifier type
*/
public static FodId fromOwid(Owid owid) throws OwidException {
Objects.requireNonNull(owid, "owid");
- return new FodId(Owid.fromByteArray(owid.asByteArray()), "owid");
+ return valueOrThrow(read(owid), "owid");
}
+ /**
+ * Turns a failed read into the exception the throwing readers document
+ * for it. A payload rule failure is an argument failure and an envelope
+ * failure is an OWID one, which is the split the readers have always
+ * made. The message names the status and the parameter, never the input.
+ */
+ private static FodId valueOrThrow(FodIdParseResult result, String paramName)
+ throws OwidException {
+ switch (result.getStatus()) {
+ case PARSED:
+ return result.getValue();
+ case PAYLOAD_TOO_SHORT:
+ throw new IllegalArgumentException(
+ "51Did payload must be at least " + HEADER_LENGTH
+ + " bytes to carry the header (" + paramName + ").");
+ case INVALID_TYPE_PAYLOAD_LENGTH:
+ throw new IllegalArgumentException(
+ "51Did payload is shorter than the minimum for its "
+ + "identifier type (" + paramName + ").");
+ default:
+ throw new OwidException(
+ "The value is not an OWID envelope: "
+ + result.getStatus() + " (" + paramName + ").");
+ }
+ }
+
+ // ----- Fields -----
+
/**
* @return the 1-byte usage flags bit-mask from the payload (0-255)
*/
@@ -338,10 +451,12 @@ public byte[] getSignature() {
return owid.getSignature();
}
+ // ----- Encoding -----
+
/**
* @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
+ * @throws OwidException if a field cannot be encoded
*/
public String asBase64() throws OwidException {
return owid.asBase64();
@@ -354,7 +469,7 @@ public String asBase64() throws OwidException {
* 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
+ * @throws OwidException if a field cannot be encoded
*/
public String asBase64Url() throws OwidException {
return Base64.getUrlEncoder().withoutPadding()
@@ -363,15 +478,17 @@ public String asBase64Url() throws OwidException {
/**
* @return the OWID as a byte array including the signature
- * @throws OwidException if the OWID has not been signed or cannot be encoded
+ * @throws OwidException if a field cannot be encoded
*/
public byte[] asByteArray() throws OwidException {
return owid.asByteArray();
}
+ // ----- Signature verification -----
+
/**
* Verifies the OWID signature against the supplied public key. This is an
- * explicit, separate step - construction never verifies.
+ * explicit, separate step, because reading a 51Did never verifies it.
*
* @param publicPem the creator's public key in SPKI PEM form
* @return true if the signature verifies, false otherwise
@@ -381,4 +498,20 @@ public byte[] asByteArray() throws OwidException {
public boolean verify(String publicPem) throws OwidException {
return owid.verifyWithPublicKey(publicPem, Collections.
+ * Every read reports the same three facts, so a caller never has to infer
+ * one of them from another. Whether the read worked is {@link #isSuccess()},
+ * the 51Did is {@link #getValue()} and is present only on success, and the
+ * reason is {@link #getStatus()} either way. When {@link #isSuccess()} is
+ * true the value is not null and the status is
+ * {@link FodIdParseStatus#PARSED}, and when it is false the value is null
+ * and the status names the problem.
+ *
+ * A successful read says the bytes are a structurally valid 51Did. It says
+ * nothing about whether the signature is genuine, which is a separate
+ * question answered by {@link FodId#verify(String)},
+ * {@link FodId#verifyDetailed(String)} or
+ * {@link DidClient#verifySignature(FodId)}.
+ *
+ * A result carries no text taken from the input, because the input came
+ * from outside and putting it in a message would mean logging whatever an
+ * untrusted sender chose to send.
+ */
+public final class FodIdParseResult {
+
+ private final FodId value;
+
+ private final FodIdParseStatus status;
+
+ private FodIdParseResult(FodId value, FodIdParseStatus status) {
+ this.value = value;
+ this.status = status;
+ }
+
+ static FodIdParseResult parsed(FodId value) {
+ return new FodIdParseResult(value, FodIdParseStatus.PARSED);
+ }
+
+ static FodIdParseResult failed(FodIdParseStatus status) {
+ return new FodIdParseResult(null, status);
+ }
+
+ /**
+ * @return true when the input was a structurally valid 51Did and
+ * {@link #getValue()} holds it
+ */
+ public boolean isSuccess() {
+ return value != null;
+ }
+
+ /**
+ * @return the 51Did when {@link #isSuccess()} is true, otherwise null.
+ * A parsed 51Did has not had its signature checked.
+ */
+ public FodId getValue() {
+ return value;
+ }
+
+ /**
+ * @return {@link FodIdParseStatus#PARSED} on success, otherwise the
+ * reason the input is not a 51Did
+ */
+ public FodIdParseStatus getStatus() {
+ return status;
+ }
+
+ @Override
+ public String toString() {
+ return "FodIdParseResult{" + status + "}";
+ }
+}
diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseStatus.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseStatus.java
new file mode 100644
index 000000000..a9f998561
--- /dev/null
+++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseStatus.java
@@ -0,0 +1,133 @@
+/* *********************************************************************
+ * 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.OwidParseStatus;
+
+/**
+ * Why a read of a 51Did produced what it did.
+ *
+ * A 51Did is an OWID envelope around a typed payload, so a read can fail at
+ * either layer. The members up to {@link #ABSENT_NODE} are the OWID
+ * library's own {@link OwidParseStatus} vocabulary carried across one for
+ * one, under the same names and with the same meanings, so a caller reading
+ * a 51Did learns exactly what the envelope reader found. The two members
+ * after them are the 51Did payload rules that apply once the envelope has
+ * been read.
+ *
+ * Java cannot extend an enum, so the OWID members are mirrored rather than
+ * inherited. {@link #fromOwid(OwidParseStatus)} maps by name and refuses to
+ * map anything the mirror does not know, so an OWID status is never quietly
+ * folded into a more general one.
+ */
+public enum FodIdParseStatus {
+
+ /**
+ * The bytes are a structurally valid 51Did. Says nothing about the
+ * signature, which {@link FodId#verify(String)} and
+ * {@link DidClient#verifySignature(FodId)} answer separately.
+ */
+ PARSED,
+
+ /** Nothing was supplied to read, being a null or empty value. */
+ MISSING_INPUT,
+
+ /**
+ * The input was supplied in a form the reader cannot take. Kept for the
+ * cross language vocabulary. Not reachable in Java, where the compiler
+ * already refuses anything that is not a string or a byte array.
+ */
+ INVALID_INPUT_TYPE,
+
+ /** The string is not valid base64, so there are no bytes to read. */
+ INVALID_BASE64,
+
+ /** The first byte names an envelope version this reader does not know. */
+ UNSUPPORTED_VERSION,
+
+ /** The data stopped in the middle of an envelope field. */
+ UNEXPECTED_END,
+
+ /**
+ * The creator domain is not terminated, or is longer than the maximum
+ * published for a domain name.
+ */
+ INVALID_DOMAIN_ENCODING,
+
+ /**
+ * The declared payload byte count disagrees with the bytes actually
+ * present. See {@link OwidParseStatus#BYTE_COUNT_MISMATCH}.
+ */
+ BYTE_COUNT_MISMATCH,
+
+ /**
+ * The envelope is consistent but larger than this runtime can hold. See
+ * {@link OwidParseStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}.
+ */
+ IMPLEMENTATION_CAPACITY_EXCEEDED,
+
+ /**
+ * The envelope is malformed in a way none of the others describes. See
+ * {@link OwidParseStatus#MALFORMED_ENVELOPE}.
+ */
+ MALFORMED_ENVELOPE,
+
+ /**
+ * The bytes are the marker for an absent optional OWID, the single byte
+ * zero, so there is deliberately no identifier here. Not a fault, and
+ * not a 51Did either. See {@link OwidParseStatus#ABSENT_NODE}.
+ */
+ ABSENT_NODE,
+
+ /**
+ * The envelope read, but its payload is shorter than the five byte 51Did
+ * header (one byte of flags and a four byte licence id), so the
+ * identifier type cannot even be read.
+ */
+ PAYLOAD_TOO_SHORT,
+
+ /**
+ * The envelope read and the header names a type, but the payload is
+ * shorter than the minimum for that type. Random needs the header plus
+ * 16 GUID bytes, and Probabilistic and HashedEmail need the header plus
+ * a 32 byte hash. A longer payload is never refused here, because
+ * anything past the value is a creator context section whose lengths
+ * belong to the cloud.
+ */
+ INVALID_TYPE_PAYLOAD_LENGTH;
+
+ /**
+ * Carries an OWID status across unchanged.
+ *
+ * @param status what the OWID reader reported
+ * @return the member of the same name
+ * @throws IllegalArgumentException if the OWID library reports a status
+ * this enum has no member for, which
+ * means the mirror needs the new member
+ * added rather than the status being
+ * mapped to a more general one
+ */
+ public static FodIdParseStatus fromOwid(OwidParseStatus status) {
+ return valueOf(status.name());
+ }
+}
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 ce2e1fd65..74d564420 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
@@ -24,11 +24,16 @@
* 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, 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()}),
+ * {@link fiftyone.pipeline.did.FodId} reads a 51Did from its base64 OWID
+ * form, in either base64 alphabet, or from the envelope bytes. The
+ * {@code tryFrom} readers answer with a
+ * {@link fiftyone.pipeline.did.FodIdParseResult} instead of throwing, whose
+ * {@link fiftyone.pipeline.did.FodIdParseStatus} names why an input is not
+ * a 51Did, and the {@code from} readers make the same read and throw. A
+ * 51Did 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 envelope it holds. Reading never
+ * checks the signature. Compare 51Dids by their value ({@code getHash()}),
* never by their envelopes.
*
* {@link fiftyone.pipeline.did.DidClient} is what a server uses against the
diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java
index f378765aa..2e5ea0a30 100644
--- a/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/DidClientTests.java
@@ -77,6 +77,8 @@ public class DidClientTests {
private FakeTransport transport;
private MutableClock clock;
private DidClient client;
+ /** A genuine identifier as a page sends it, signed with key2. */
+ private String validDid;
@Before
public void init() throws OwidException {
@@ -84,6 +86,7 @@ public void init() throws OwidException {
key2 = new FodIdTestFactory();
key3 = new FodIdTestFactory();
transport = new FakeTransport();
+ validDid = key2.fodIdAt(canonicalPayload(), WEEK2).asBase64Url();
clock = new MutableClock(WEEK2.plus(Duration.ofDays(1)));
client = DidClient.builder("resource")
.licenceKey("licence")
@@ -557,18 +560,21 @@ public void verify_OverLongObjectIsRefusedBeforeTransport()
public void verify_InvalidAnswers400False() throws Exception {
transport.queue(400, "{\"valid\":false}");
- assertFalse(client.verify("AwAA"));
+ assertFalse(client.verify(validDid));
}
@Test
public void verify_ErrorsAnswer400Raises() {
+ // The cloud's own rejection of an identifier that read locally still
+ // maps to the argument failure, with the cloud's message.
transport.queue(400, "{\"errors\":[\"Value for 51did is not a valid "
- + "Base64-encoded 51Did: 'x'.\"]}");
+ + "Base64-encoded 51Did.\"]}");
IllegalArgumentException error = assertThrows(
- IllegalArgumentException.class, () -> client.verify("x"));
+ IllegalArgumentException.class, () -> client.verify(validDid));
assertTrue(error.getMessage().contains("not a valid"));
+ assertEquals(1, transport.requests.size());
}
@Test
@@ -576,7 +582,7 @@ public void verify_OtherStatusRaisesWithStatusAndBody() {
transport.queue(401, "{\"errors\":[\"bad key\"]}");
DidHttpException error = assertThrows(DidHttpException.class,
- () -> client.verify("AwAA"));
+ () -> client.verify(validDid));
assertEquals(401, error.getStatusCode());
assertTrue(error.getBody().contains("bad key"));
@@ -584,7 +590,7 @@ public void verify_OtherStatusRaisesWithStatusAndBody() {
@Test
public void verify_TransportFailureRaisesIoException() {
- assertThrows(IOException.class, () -> client.verify("AwAA"));
+ assertThrows(IOException.class, () -> client.verify(validDid));
}
// ----- Redeem -----
@@ -641,7 +647,7 @@ public void redeem_RedeemedWithoutFactors() throws Exception {
+ "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ "\"secondsSinceVerified\":0}");
- RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+ RedeemResult result = client.redeem(validDid, "sealed", "abc");
assertEquals(RedeemResult.Context.VERIFIED, result.getContext());
assertEquals(RedeemResult.Signature.VERIFIED, result.getSignature());
@@ -658,7 +664,7 @@ public void redeem_InvalidSignatureIsReported() throws Exception {
+ "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ "\"secondsSinceVerified\":1}");
- RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+ RedeemResult result = client.redeem(validDid, "sealed", "abc");
assertEquals(RedeemResult.Signature.INVALID, result.getSignature());
}
@@ -669,7 +675,7 @@ public void redeem_Expired() throws Exception {
+ "\"verifiedAt\":\"2026-08-07T09:15:32Z\","
+ "\"secondsSinceVerified\":14}");
- RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+ RedeemResult result = client.redeem(validDid, "sealed", "abc");
assertEquals(RedeemResult.Context.EXPIRED, result.getContext());
assertEquals(RedeemResult.Signature.UNKNOWN, result.getSignature());
@@ -683,7 +689,7 @@ public void redeem_Expired() throws Exception {
public void redeem_Replayed() throws Exception {
transport.queue(200, "{\"context\":\"replayed\"}");
- RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+ RedeemResult result = client.redeem(validDid, "sealed", "abc");
assertEquals(RedeemResult.Context.REPLAYED, result.getContext());
assertNull(result.getVerifiedAt());
@@ -694,7 +700,7 @@ public void redeem_Replayed() throws Exception {
public void redeem_Unreadable() throws Exception {
transport.queue(200, "{\"context\":\"unreadable\"}");
- RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+ RedeemResult result = client.redeem(validDid, "sealed", "abc");
assertEquals(RedeemResult.Context.UNREADABLE, result.getContext());
assertEquals(RedeemResult.Signature.UNKNOWN, result.getSignature());
@@ -704,7 +710,7 @@ public void redeem_Unreadable() throws Exception {
public void redeem_503Unconfirmed() throws Exception {
transport.queue(503, "{\"context\":\"unconfirmed\"}");
- RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+ RedeemResult result = client.redeem(validDid, "sealed", "abc");
assertEquals(RedeemResult.Context.UNCONFIRMED, result.getContext());
assertEquals(503, result.getStatusCode());
@@ -715,7 +721,7 @@ public void redeem_UnknownContextFailsClosedAndKeepsTheRawValue()
throws Exception {
transport.queue(200, "{\"context\":\"something-new\"}");
- RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+ RedeemResult result = client.redeem(validDid, "sealed", "abc");
assertEquals(RedeemResult.Context.UNREADABLE, result.getContext());
assertEquals("something-new", result.getContextValue());
@@ -725,7 +731,7 @@ public void redeem_UnknownContextFailsClosedAndKeepsTheRawValue()
public void redeem_MissingContextFailsClosed() throws Exception {
transport.queue(200, "{}");
- RedeemResult result = client.redeem("AwAA", "sealed", "abc");
+ RedeemResult result = client.redeem(validDid, "sealed", "abc");
assertEquals(RedeemResult.Context.UNREADABLE, result.getContext());
assertEquals("unreadable", result.getContextValue());
@@ -738,9 +744,10 @@ public void redeem_400ErrorsRaisesArgumentError() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
- () -> client.redeem("x", "sealed", "abc"));
+ () -> client.redeem(validDid, "sealed", "abc"));
assertTrue(error.getMessage().contains("not a valid"));
+ assertEquals(1, transport.requests.size());
}
@Test
@@ -762,7 +769,7 @@ public void redeem_404RaisesNotSupported() {
DidNotSupportedException error = assertThrows(
DidNotSupportedException.class,
- () -> client.redeem("AwAA", "sealed", "abc"));
+ () -> client.redeem(validDid, "sealed", "abc"));
assertEquals(404, error.getStatusCode());
assertEquals("Not Found", error.getBody());
@@ -774,7 +781,7 @@ public void redeem_OtherStatusRaisesWithStatusAndBody() {
transport.queue(500, "boom");
DidHttpException error = assertThrows(DidHttpException.class,
- () -> client.redeem("AwAA", "sealed", "abc"));
+ () -> client.redeem(validDid, "sealed", "abc"));
assertEquals(500, error.getStatusCode());
assertEquals("boom", error.getBody());
@@ -785,7 +792,7 @@ public void redeem_NonJson200Raises() {
transport.queue(200, "proxy");
DidHttpException error = assertThrows(DidHttpException.class,
- () -> client.redeem("AwAA", "sealed", "abc"));
+ () -> client.redeem(validDid, "sealed", "abc"));
assertEquals(200, error.getStatusCode());
}
@@ -793,7 +800,7 @@ public void redeem_NonJson200Raises() {
@Test
public void redeem_TransportFailureRaisesIoException() {
assertThrows(IOException.class,
- () -> client.redeem("AwAA", "sealed", "abc"));
+ () -> client.redeem(validDid, "sealed", "abc"));
}
@Test
@@ -802,7 +809,7 @@ public void redeem_WithoutLicenceKeyOmitsTheField() throws Exception {
.endpoint(ENDPOINT).transport(transport).build();
transport.queue(200, "{\"context\":\"unreadable\"}");
- noLicence.redeem("AwAA", "sealed", null);
+ noLicence.redeem(validDid, "sealed", null);
String form = new String(
transport.last().getBody(), StandardCharsets.UTF_8);
@@ -813,16 +820,113 @@ public void redeem_WithoutLicenceKeyOmitsTheField() throws Exception {
@Test
public void redeem_FormEncodesTheValues() throws Exception {
transport.queue(200, "{\"context\":\"unreadable\"}");
+ // The standard alphabet with padding, as the cloud issues it, so
+ // the form has characters that need encoding.
+ String standard = key2.fodIdAt(canonicalPayload(), WEEK2).asBase64();
+ assertTrue(standard.endsWith("="));
- client.redeem("AwAA+/==", "a b&c", "x=y");
+ client.redeem(standard, "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.startsWith(
+ "resource=resource&51did=" + DidClient.encode(standard) + "&"));
+ assertTrue(form.contains("%3D&result="));
assertTrue(form.contains("&result=a+b%26c&"));
assertTrue(form.contains("&challenge=x%3Dy&"));
}
+ // ----- Malformed input never reaches the network -----
+
+ @Test
+ public void verify_MalformedStringIsRefusedBeforeTransport() {
+ // Not base64 at all, so the OWID reader's own status is the reason,
+ // and neither a key fetch nor the verify call happens.
+ IllegalArgumentException error = assertThrows(
+ IllegalArgumentException.class,
+ () -> client.verify("This is not a 51Did!"));
+
+ assertTrue(error.getMessage(),
+ error.getMessage().contains("INVALID_BASE64"));
+ assertEquals(0, transport.requests.size());
+ }
+
+ @Test
+ public void verify_ShortPayloadStringIsRefusedBeforeTransport()
+ throws Exception {
+ // A genuine envelope whose payload cannot carry the 51Did header.
+ String tooShort = key2.signedOwidAt(new byte[3], WEEK2).asBase64();
+
+ IllegalArgumentException error = assertThrows(
+ IllegalArgumentException.class, () -> client.verify(tooShort));
+
+ assertTrue(error.getMessage(),
+ error.getMessage().contains("PAYLOAD_TOO_SHORT"));
+ assertEquals(0, transport.requests.size());
+ }
+
+ @Test
+ public void redeem_MalformedStringIsRefusedBeforeTransport()
+ throws Exception {
+ String tooShort = key2.signedOwidAt(
+ Arrays.copyOf(canonicalRandomPayload(),
+ FodId.RANDOM_PAYLOAD_LENGTH - 1), WEEK2).asBase64();
+
+ assertThrows(IllegalArgumentException.class,
+ () -> client.redeem("This is not a 51Did!", "sealed", "abc"));
+ IllegalArgumentException error = assertThrows(
+ IllegalArgumentException.class,
+ () -> client.redeem(tooShort, "sealed", "abc"));
+
+ assertTrue(error.getMessage(),
+ error.getMessage().contains("INVALID_TYPE_PAYLOAD_LENGTH"));
+ assertEquals(0, transport.requests.size());
+ }
+
+ @Test
+ public void verify_LongerContextSectionStringReachesTheCloud()
+ throws Exception {
+ // Longer than any shape this package knows is still a 51Did, so it
+ // is not turned away here and the cloud is asked as usual.
+ transport.queue(200, "{\"valid\":true}");
+ String longer = key2.fodIdAt(
+ canonicalPayloadWithSection(600), WEEK2).asBase64Url();
+
+ assertTrue(client.verify(longer));
+
+ assertEquals(1, transport.requests.size());
+ }
+
+ // ----- Invalid is not the same as could not check -----
+
+ @Test
+ public void verifySignature_TamperedSignatureIsInvalidNotAnError()
+ throws Exception {
+ transport.queue(200, keyList("startsAt", false));
+ byte[] bytes = key2.fodIdAt(canonicalPayload(), WEEK2).asByteArray();
+ bytes[bytes.length - 1] ^= (byte) 0xFF;
+ FodIdParseResult read = FodId.tryFromByteArray(bytes);
+ // Reading succeeds, because reading never checks the signature.
+ assertTrue(read.isSuccess());
+ assertEquals(FodIdParseStatus.PARSED, read.getStatus());
+
+ assertEquals(DidClient.SignatureCheck.INVALID,
+ client.verifySignatureDetailed(read.getValue()));
+ assertFalse(client.verifySignature(read.getValue()));
+ }
+
+ @Test
+ public void verifySignature_FirstKeyFetchFailureRaisesRatherThanFalse()
+ throws Exception {
+ // Nothing queued, so the key list cannot be fetched. That is an
+ // error, never a verdict on the signature.
+ FodId fodId = key2.fodIdAt(canonicalPayload(), WEEK2);
+
+ assertThrows(IOException.class, () -> client.verifySignature(fodId));
+ assertThrows(IOException.class,
+ () -> client.verifySignatureDetailed(fodId));
+ }
+
// ----- Helpers -----
/**
diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdParseTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdParseTests.java
new file mode 100644
index 000000000..61b3f758f
--- /dev/null
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdParseTests.java
@@ -0,0 +1,415 @@
+/* *********************************************************************
+ * 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.OwidParseStatus;
+import com.swancommunity.owid.OwidSignatureStatus;
+import com.swancommunity.owid.OwidVerificationResult;
+import com.swancommunity.owid.Version;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+
+import static fiftyone.pipeline.did.FodIdTestFactory.CANONICAL_FLAGS;
+import static fiftyone.pipeline.did.FodIdTestFactory.CANONICAL_HASH;
+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;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The non-throwing read contract. Every result is checked for all three
+ * facts at once, being whether the read succeeded, whether a value is
+ * present, and the status, because a caller must be able to rely on any one
+ * of them without consulting the others.
+ */
+public class FodIdParseTests {
+
+ private static final Instant DATE = Instant.parse("2026-08-05T12:00:00Z");
+
+ private FodIdTestFactory factory;
+
+ @Before
+ public void init() throws OwidException {
+ factory = new FodIdTestFactory();
+ }
+
+ // ----- Success -----
+
+ @Test
+ public void tryFromBase64_ValidIdentifier_ParsedWithValue()
+ throws Exception {
+ FodIdParseResult result = FodId.tryFromBase64(
+ factory.signedOwidBase64(canonicalPayload()));
+
+ FodId fodId = assertParsed(result);
+ assertEquals(CANONICAL_FLAGS, fodId.getFlags());
+ assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId());
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ assertEquals(TEST_DOMAIN, fodId.getDomain());
+ }
+
+ @Test
+ public void tryFromBase64_UrlSafeUnpaddedWithWhitespace_Parsed()
+ throws Exception {
+ String standard = factory.signedOwidBase64(canonicalPayload());
+ String urlSafe = standard.replace('+', '-').replace('/', '_')
+ .replace("=", "");
+
+ FodId fromUrlSafe = assertParsed(FodId.tryFromBase64(urlSafe));
+ FodId fromSpaced = assertParsed(
+ FodId.tryFromBase64(" " + urlSafe + "\r\n"));
+
+ assertArrayEquals(fromUrlSafe.asByteArray(), fromSpaced.asByteArray());
+ assertArrayEquals(CANONICAL_HASH, fromUrlSafe.getHash());
+ }
+
+ @Test
+ public void tryFromByteArray_ValidIdentifier_ParsedWithValue()
+ throws Exception {
+ byte[] bytes = factory.signedOwid(canonicalPayload()).asByteArray();
+
+ FodId fodId = assertParsed(FodId.tryFromByteArray(bytes));
+
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ assertArrayEquals(bytes, fodId.asByteArray());
+ }
+
+ @Test
+ public void tryFromBase64_LongerSelfHostedDomain_Parsed()
+ throws Exception {
+ // The creator domain is a deployment parameter, and a self-hosted
+ // container may sign with a much longer one than the cloud does.
+ String longDomain = "identifier.creator.self-hosted-deployment."
+ + "region-two.customer-platform.example.internal."
+ + "a-rather-long-name.51degrees.com";
+ String base64 = factory.signedOwidAt(
+ canonicalPayload(), DATE, Version.VERSION3, longDomain).asBase64();
+
+ FodId fodId = assertParsed(FodId.tryFromBase64(base64));
+
+ assertEquals(longDomain, fodId.getDomain());
+ assertTrue(fodId.verify(factory.publicPem));
+ }
+
+ @Test
+ public void tryFromBase64_LongerContextSection_Parsed() throws Exception {
+ // A payload longer than the value carries a creator context section
+ // whose shape belongs to the cloud, so an older reader accepts it
+ // and exposes it as the payload beyond the value.
+ byte[] payload = canonicalPayloadWithSection(512);
+ String base64 = factory.signedOwidAt(payload, DATE).asBase64();
+
+ FodId fodId = assertParsed(FodId.tryFromBase64(base64));
+
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ assertArrayEquals(payload, fodId.getPayload());
+ assertTrue(fodId.verify(factory.publicPem));
+ }
+
+ @Test
+ public void tryFromByteArray_MuchLongerPayload_NotRejectedForLength()
+ throws Exception {
+ // Nothing in this package puts an upper bound on a payload.
+ for (int section : new int[] { 1, 100, 3000, 20000 }) {
+ byte[] payload = canonicalPayloadWithSection(section);
+ byte[] bytes = factory.signedOwidAt(payload, DATE).asByteArray();
+
+ FodId fodId = assertParsed(FodId.tryFromByteArray(bytes));
+
+ assertEquals(payload.length, fodId.getPayload().length);
+ assertArrayEquals(CANONICAL_HASH, fodId.getHash());
+ }
+ byte[] random = Arrays.copyOf(canonicalRandomPayload(), 700);
+ assertParsed(FodId.tryFromByteArray(
+ factory.signedOwidAt(random, DATE).asByteArray()));
+ }
+
+ @Test
+ public void tryFromBase64_EachTypeAtItsMinimum_Parsed() throws Exception {
+ byte[] probabilistic = canonicalPayload();
+ probabilistic[FodId.FLAGS_OFFSET] = 0b0000_0101;
+ byte[] hashedEmail = canonicalPayload();
+ hashedEmail[FodId.FLAGS_OFFSET] = (byte) 0b1000_0101;
+
+ assertEquals(IdType.PROBABILISTIC, assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(probabilistic, DATE).asBase64())).getType());
+ assertEquals(IdType.HASHED_EMAIL, assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(hashedEmail, DATE).asBase64())).getType());
+ assertEquals(IdType.RANDOM, assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(canonicalRandomPayload(), DATE).asBase64()))
+ .getType());
+ }
+
+ @Test
+ public void tryFromBase64_ReservedHeaderOnly_ParsedBestEffort()
+ throws Exception {
+ byte[] payload = new byte[FodId.HEADER_LENGTH];
+ payload[FodId.FLAGS_OFFSET] = (byte) 0b1100_0000;
+
+ FodId fodId = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(payload, DATE).asBase64()));
+
+ assertEquals(IdType.RESERVED, fodId.getType());
+ assertEquals(0, fodId.getHash().length);
+ }
+
+ // ----- 51Did payload rules -----
+
+ @Test
+ public void tryFromBase64_RandomOneByteShort_InvalidTypePayloadLength()
+ throws Exception {
+ byte[] payload = Arrays.copyOf(
+ canonicalRandomPayload(), FodId.RANDOM_PAYLOAD_LENGTH - 1);
+
+ assertFailed(
+ FodId.tryFromBase64(factory.signedOwidAt(payload, DATE).asBase64()),
+ FodIdParseStatus.INVALID_TYPE_PAYLOAD_LENGTH);
+ }
+
+ @Test
+ public void tryFromBase64_ProbabilisticOneByteShort_InvalidTypePayloadLength()
+ throws Exception {
+ byte[] payload = Arrays.copyOf(
+ canonicalPayload(), FodId.PAYLOAD_LENGTH - 1);
+ payload[FodId.FLAGS_OFFSET] = 0;
+
+ assertFailed(
+ FodId.tryFromBase64(factory.signedOwidAt(payload, DATE).asBase64()),
+ FodIdParseStatus.INVALID_TYPE_PAYLOAD_LENGTH);
+ }
+
+ @Test
+ public void tryFromByteArray_HashedEmailOneByteShort_InvalidTypePayloadLength()
+ throws Exception {
+ // CANONICAL_FLAGS carries the HashedEmail tag.
+ byte[] payload = Arrays.copyOf(
+ canonicalPayload(), FodId.PAYLOAD_LENGTH - 1);
+
+ assertFailed(
+ FodId.tryFromByteArray(
+ factory.signedOwidAt(payload, DATE).asByteArray()),
+ FodIdParseStatus.INVALID_TYPE_PAYLOAD_LENGTH);
+ }
+
+ @Test
+ public void tryFromBase64_ShorterThanHeader_PayloadTooShort()
+ throws Exception {
+ for (int length = 0; length < FodId.HEADER_LENGTH; length++) {
+ byte[] payload = new byte[length];
+ if (length > 0) {
+ // Whatever the type bits say, the header is not all there.
+ payload[FodId.FLAGS_OFFSET] = (byte) 0b1100_0000;
+ }
+ assertFailed(
+ FodId.tryFromBase64(
+ factory.signedOwidAt(payload, DATE).asBase64()),
+ FodIdParseStatus.PAYLOAD_TOO_SHORT);
+ assertFailed(
+ FodId.tryFromByteArray(
+ factory.signedOwidAt(payload, DATE).asByteArray()),
+ FodIdParseStatus.PAYLOAD_TOO_SHORT);
+ }
+ }
+
+ // ----- OWID statuses carried across unchanged -----
+
+ @Test
+ public void tryFromBase64_InvalidBase64_ReportsTheOwidStatus() {
+ assertFailed(FodId.tryFromBase64("This is not valid Base64!@#$"),
+ FodIdParseStatus.INVALID_BASE64);
+ assertFailed(FodId.tryFromBase64("AwAA*"),
+ FodIdParseStatus.INVALID_BASE64);
+ }
+
+ @Test
+ public void tryFrom_NullOrEmpty_MissingInput() {
+ assertFailed(FodId.tryFromBase64(null), FodIdParseStatus.MISSING_INPUT);
+ assertFailed(FodId.tryFromBase64(""), FodIdParseStatus.MISSING_INPUT);
+ assertFailed(FodId.tryFromBase64(" "),
+ FodIdParseStatus.MISSING_INPUT);
+ assertFailed(FodId.tryFromByteArray(null),
+ FodIdParseStatus.MISSING_INPUT);
+ assertFailed(FodId.tryFromByteArray(new byte[0]),
+ FodIdParseStatus.MISSING_INPUT);
+ }
+
+ @Test
+ public void tryFromByteArray_DeclarationMismatch_PropagatedUnchanged()
+ throws Exception {
+ // The declared payload count disagrees with the bytes present. The
+ // OWID reader settles this before it sizes anything by the
+ // declaration, and this package hands the reason on as it is. No
+ // key is involved anywhere on this path, so no cryptography can
+ // have been reached.
+ byte[] payload = canonicalPayload();
+ byte[] overDeclared = factory.envelopeBytes(
+ payload, DATE, Version.VERSION3, TEST_DOMAIN, payload.length + 1);
+ byte[] underDeclared = factory.envelopeBytes(
+ payload, DATE, Version.VERSION3, TEST_DOMAIN, payload.length - 1);
+ byte[] truncated = Arrays.copyOf(
+ factory.signedOwidAt(payload, DATE).asByteArray(), 100);
+
+ assertFailed(FodId.tryFromByteArray(overDeclared),
+ FodIdParseStatus.BYTE_COUNT_MISMATCH);
+ assertFailed(FodId.tryFromByteArray(underDeclared),
+ FodIdParseStatus.BYTE_COUNT_MISMATCH);
+ assertFailed(FodId.tryFromByteArray(truncated),
+ FodIdParseStatus.BYTE_COUNT_MISMATCH);
+ assertFailed(FodId.tryFromBase64(
+ java.util.Base64.getEncoder().encodeToString(overDeclared)),
+ FodIdParseStatus.BYTE_COUNT_MISMATCH);
+ }
+
+ @Test
+ public void tryFromByteArray_OtherEnvelopeFaults_PropagatedUnchanged()
+ throws Exception {
+ byte[] good = factory.signedOwidAt(canonicalPayload(), DATE)
+ .asByteArray();
+
+ // Cut inside the domain, before its terminator.
+ assertFailed(FodId.tryFromByteArray(Arrays.copyOf(good, 4)),
+ FodIdParseStatus.UNEXPECTED_END);
+ // A version byte nothing knows.
+ byte[] unknownVersion = good.clone();
+ unknownVersion[0] = (byte) 0x7F;
+ assertFailed(FodId.tryFromByteArray(unknownVersion),
+ FodIdParseStatus.UNSUPPORTED_VERSION);
+ // The marker for an absent optional node is not a 51Did either.
+ assertFailed(FodId.tryFromByteArray(new byte[] { 0 }),
+ FodIdParseStatus.ABSENT_NODE);
+ }
+
+ @Test
+ public void status_MirrorsEveryOwidStatusByName() {
+ for (OwidParseStatus status : OwidParseStatus.values()) {
+ assertEquals(status.name(),
+ FodIdParseStatus.fromOwid(status).name());
+ }
+ assertEquals(FodIdParseStatus.PARSED,
+ FodIdParseStatus.fromOwid(OwidParseStatus.PARSED));
+ }
+
+ // ----- Reading is not verifying -----
+
+ @Test
+ public void tamperedSignature_ParsesThenVerifiesAsSignatureInvalid()
+ throws Exception {
+ byte[] bytes = factory.signedOwidAt(canonicalPayload(), DATE)
+ .asByteArray();
+ bytes[bytes.length - 1] ^= (byte) 0xFF;
+
+ FodId fodId = assertParsed(FodId.tryFromByteArray(bytes));
+ OwidVerificationResult check = fodId.verifyDetailed(factory.publicPem);
+
+ assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, check.getStatus());
+ assertFalse(check.isValid());
+ assertFalse(fodId.verify(factory.publicPem));
+ // The untouched envelope verifies with the same key.
+ assertEquals(OwidSignatureStatus.SIGNATURE_VALID,
+ factory.fodIdAt(canonicalPayload(), DATE)
+ .verifyDetailed(factory.publicPem).getStatus());
+ }
+
+ @Test
+ public void verifyDetailed_KeyUnavailable_IsNotSignatureInvalid()
+ throws Exception {
+ FodId fodId = factory.fodIdAt(canonicalPayload(), DATE);
+
+ assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE,
+ fodId.verifyDetailed(null).getStatus());
+ assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE,
+ fodId.verifyDetailed(" ").getStatus());
+ assertEquals(OwidSignatureStatus.INVALID_KEY,
+ fodId.verifyDetailed("not a public key").getStatus());
+ for (OwidVerificationResult result : new OwidVerificationResult[] {
+ fodId.verifyDetailed(null),
+ fodId.verifyDetailed("not a public key") }) {
+ assertNotEquals(OwidSignatureStatus.SIGNATURE_INVALID,
+ result.getStatus());
+ assertFalse(result.isValid());
+ }
+ // The boolean form keeps its documented behaviour for a bad key.
+ assertThrows(OwidException.class,
+ () -> fodId.verify("not a public key"));
+ }
+
+ // ----- The throwing readers make the same read -----
+
+ @Test
+ public void throwingReaders_ThrowTheDocumentedTypesForTheSameInputs()
+ throws Exception {
+ String tooShort = factory.signedOwidAt(new byte[2], DATE).asBase64();
+ String randomShort = factory.signedOwidAt(Arrays.copyOf(
+ canonicalRandomPayload(), FodId.RANDOM_PAYLOAD_LENGTH - 1), DATE)
+ .asBase64();
+ byte[] truncated = Arrays.copyOf(
+ factory.signedOwidAt(canonicalPayload(), DATE).asByteArray(), 100);
+
+ // Payload rule failures are argument failures.
+ assertThrows(IllegalArgumentException.class,
+ () -> FodId.fromBase64(tooShort));
+ assertThrows(IllegalArgumentException.class,
+ () -> FodId.fromBase64(randomShort));
+ // Envelope failures are OWID failures, naming the status.
+ OwidException notBase64 = assertThrows(OwidException.class,
+ () -> FodId.fromBase64("This is not valid Base64!@#$"));
+ assertTrue(notBase64.getMessage().contains("INVALID_BASE64"));
+ OwidException mismatch = assertThrows(OwidException.class,
+ () -> FodId.fromByteArray(truncated));
+ assertTrue(mismatch.getMessage().contains("BYTE_COUNT_MISMATCH"));
+ assertThrows(OwidException.class, () -> FodId.fromBase64(""));
+ // Null keeps its own contract on the throwing surface.
+ assertThrows(NullPointerException.class, () -> FodId.fromBase64(null));
+ assertThrows(NullPointerException.class,
+ () -> FodId.fromByteArray(null));
+ }
+
+ // ----- Helpers -----
+
+ private static FodId assertParsed(FodIdParseResult result) {
+ assertTrue(result.toString(), result.isSuccess());
+ assertNotNull(result.getValue());
+ assertEquals(FodIdParseStatus.PARSED, result.getStatus());
+ return result.getValue();
+ }
+
+ private static void assertFailed(
+ FodIdParseResult result, FodIdParseStatus expected) {
+ assertFalse(result.toString(), result.isSuccess());
+ assertNull(result.getValue());
+ assertEquals(expected, result.getStatus());
+ }
+}
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 9b13a4ee6..f02d6c5d3 100644
--- a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java
@@ -26,6 +26,7 @@
import com.swancommunity.owid.Crypto;
import com.swancommunity.owid.Owid;
import com.swancommunity.owid.OwidException;
+import com.swancommunity.owid.OwidParseResult;
import com.swancommunity.owid.Version;
import java.io.ByteArrayOutputStream;
@@ -34,36 +35,32 @@
import java.time.Instant;
/**
- * Shared test helper for the 51Did tests. Generates a fresh ECDSA P-256 key
- * pair per instance and signs real OWID envelopes with it, and builds the
- * canonical payloads the tests assert against.
+ * Builds signed test envelopes over chosen payloads, with one key pair per
+ * factory so that the client tests can stand up a schedule of keys.
+ *
+ * Two routes produce an envelope. {@link #signedOwid(byte[])} asks the OWID
+ * library's own {@link Creator} to stamp the date and sign, which is the
+ * route production code takes. {@link #signedOwidAt} writes the envelope
+ * bytes by hand, signs them with the same key, and reads them back through
+ * the library, which is the only way to choose the date, the version or the
+ * domain, and the only way to produce bytes the library would refuse.
*/
final class FodIdTestFactory {
- /** The domain stamped into every signed test OWID. */
static final String TEST_DOMAIN = "51degrees.com";
- /**
- * The canonical flags byte (0xA5): usage bits plus the HashedEmail type
- * tag in bits 6-7, so the 37-byte payload minimum applies.
- */
static final int CANONICAL_FLAGS = 0xA5;
- /** The canonical little-endian License Id, 0x12345678. */
static final long CANONICAL_LICENSE_ID = 0x12345678L;
- /** 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 {
@@ -84,10 +81,6 @@ private static byte[] canonicalHash() {
return hash;
}
- /**
- * A canonical 37-byte 51Did payload: {@link #CANONICAL_FLAGS},
- * {@link #CANONICAL_LICENSE_ID} (little-endian) and {@link #CANONICAL_HASH}.
- */
static byte[] canonicalPayload() {
byte[] payload = new byte[FodId.PAYLOAD_LENGTH];
payload[FodId.FLAGS_OFFSET] = (byte) CANONICAL_FLAGS;
@@ -97,11 +90,6 @@ static byte[] canonicalPayload() {
return payload;
}
- /**
- * A canonical 21-byte Random payload: the Random type tag in bits 6-7 plus
- * usage bits 0b001, {@link #CANONICAL_LICENSE_ID}, and a stable 16-byte
- * GUID block (0x40..0x4F).
- */
static byte[] canonicalRandomPayload() {
byte[] payload = new byte[FodId.RANDOM_PAYLOAD_LENGTH];
payload[FodId.FLAGS_OFFSET] = (byte) ((1 << 6) | 0b001);
@@ -112,10 +100,6 @@ 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(
@@ -134,73 +118,73 @@ private static void writeCanonicalLicenseId(byte[] payload) {
payload[FodId.LICENSE_ID_OFFSET + 3] = 0x12;
}
- /**
- * Creates and signs a real OWID with the given payload. Note that
- * {@code Creator.sign} stamps the date itself (current time, to the
- * minute), so callers that need distinct dates set them after signing.
- */
+ /** Signs the payload through the library's creator, dated now. */
Owid signedOwid(byte[] payload) throws OwidException {
- Owid owid = new Owid(TEST_DOMAIN, Instant.now(), payload);
- creator.sign(owid);
- return owid;
+ return creator.createBytes(payload);
}
- /** Signs the given payload and returns the OWID as base64. */
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.
+ * Writes an envelope by hand at the chosen date, version and domain,
+ * signs it with this factory's key, and reads it back through the
+ * library so the test holds exactly what production would.
*/
Owid signedOwidAt(
byte[] payload, Instant date, Version version, String domainName)
throws OwidException {
+ byte[] bytes = envelopeBytes(
+ payload, date, version, domainName, payload.length);
+ OwidParseResult read = Owid.parse(bytes);
+ if (read.isSuccess() == false) {
+ throw new OwidException(
+ "The test envelope did not read back: " + read.getStatus());
+ }
+ return read.getValue();
+ }
+
+ /**
+ * The raw bytes of a signed envelope, with the declared payload length
+ * chosen separately from the payload so a test can produce a
+ * declaration that disagrees with the bytes present. The signature is
+ * over the bytes as written, so a matching declaration gives an envelope
+ * that verifies with this factory's key.
+ */
+ byte[] envelopeBytes(
+ byte[] payload,
+ Instant date,
+ Version version,
+ String domainName,
+ long declaredPayloadLength) 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);
+ writeUInt32(out, declaredPayloadLength);
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());
+ return 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(
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 ca5397c80..b78a276ee 100644
--- a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java
@@ -369,12 +369,12 @@ public void constructor_ReservedHeaderOnly_Parses() throws Exception {
public void compareTwo51Dids_SamePayload_SameValueDifferentEnvelopes()
throws Exception {
byte[] payload = canonicalPayload();
- Owid a = factory.signedOwid(payload);
- Owid b = factory.signedOwid(payload);
- // sign() stamps "now" to the minute, so set distinct dates to
- // represent two reissues at different times.
- a.setDate(Instant.parse("2026-01-01T00:00:00Z"));
- b.setDate(Instant.parse("2026-01-01T00:05:00Z"));
+ // The creator stamps "now" to the minute, so two reissues at
+ // different times are written by hand at distinct dates.
+ Owid a = factory.signedOwidAt(
+ payload, Instant.parse("2026-01-01T00:00:00Z"));
+ Owid b = factory.signedOwidAt(
+ payload, Instant.parse("2026-01-01T00:05:00Z"));
FodId fodA = FodId.fromBase64(a.asBase64());
FodId fodB = FodId.fromBase64(b.asBase64());
@@ -395,7 +395,8 @@ public void construction_DoesNotVerify() throws Exception {
byte[] bytes = Base64.getDecoder().decode(
factory.signedOwidBase64(canonicalPayload()));
bytes[bytes.length - 1] ^= (byte) 0xFF; // corrupt the signature
- Owid tampered = Owid.fromByteArray(bytes);
+ Owid tampered = Owid.parse(bytes).getValue();
+ assertNotNull(tampered);
FodId fodId = FodId.fromOwid(tampered);
@@ -405,17 +406,15 @@ public void construction_DoesNotVerify() throws Exception {
}
@Test
- public void fromOwid_IsDecoupledFromSourceOwid() throws Exception {
- // Mutating the source OWID after construction must not affect the
- // FodId (it holds an independent copy).
+ public void fromOwid_HoldsTheEnvelopeItWasGiven() throws Exception {
+ // The library hands out only immutable envelopes, so there is no
+ // copy to make and the 51Did reads exactly what the envelope holds.
Owid owid = factory.signedOwid(canonicalPayload());
FodId fodId = FodId.fromOwid(owid);
- owid.setPayload(new byte[FodId.PAYLOAD_LENGTH]);
-
assertEquals(CANONICAL_FLAGS, fodId.getFlags());
assertArrayEquals(CANONICAL_HASH, fodId.getHash());
- assertEquals(CANONICAL_HASH[0], fodId.getPayload()[FodId.HASH_OFFSET]);
+ assertArrayEquals(owid.asByteArray(), fodId.asByteArray());
}
@Test