diff --git a/.gitignore b/.gitignore index 9d8d1b78d..ada768be0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ pom.xml.versionsBackup .project .settings/ *.classpath +bin/ diff --git a/.gitmodules b/.gitmodules index dc531300e..431dac830 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "pipeline.javascriptbuilder/src/main/resources/fiftyone/pipeline/javascriptbuilder/templates"] path = pipeline.javascriptbuilder/src/main/resources/fiftyone/pipeline/javascriptbuilder/templates url = ../javascript-templates.git +[submodule "owid-java"] + path = owid-java + url = ../owid-java.git + branch = main diff --git a/owid-java b/owid-java new file mode 160000 index 000000000..cb03973c7 --- /dev/null +++ b/owid-java @@ -0,0 +1 @@ +Subproject commit cb03973c7005cce1a028299e0ee7d630c0d2e783 diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml b/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml new file mode 100644 index 000000000..466b3d5ec --- /dev/null +++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/pom.xml @@ -0,0 +1,49 @@ + + + + + + pipeline.developer-examples + com.51degrees + 4.5.7-SNAPSHOT + + 4.0.0 + + pipeline.developerexamples.fodid + 51Degrees :: Pipeline :: Developer Examples :: 51Did + https://51degrees.com?utm_source=maven&utm_medium=package&utm_campaign=pipeline-java&utm_content=pipeline.developer-examples-pipeline.developer-examples.fodid-pom.xml&utm_term=url + + + + ${project.groupId} + ${project.version} + pipeline.did + + + org.junit.jupiter + junit-jupiter + + + 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 new file mode 100644 index 000000000..fade8d438 --- /dev/null +++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/Main.java @@ -0,0 +1,129 @@ +/* ********************************************************************* + * 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.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; + +/** + * Offline example for the 51Did ({@link FodId}) reader. + *

+ * The 51Degrees Cloud service issues real 51Dids. To keep this example + * self-contained and offline, it builds a sample 51Did in process - generate + * an ECDSA P-256 key pair, sign a canonical 37-byte payload - then parses it + * back with {@link FodId} and prints the three payload fields. + *

+ * It also demonstrates the headline use case: a 51Did is re-issued fresh on + * every call (the envelope, hence the base64, changes), but the value (the + * Hash) is stable. Compare values, never envelopes. + */ +public class Main { + + private static final String DOMAIN = "51degrees.com"; + + public static class Example { + + public void run() throws Exception { + // Generate a key pair and a signer entirely in process. + Crypto crypto = Crypto.generate(); + Creator creator = Creator.create(DOMAIN, crypto); + + byte[] payload = samplePayload(); + + // Issue a 51Did over the payload and parse it back. + FodId fodId = FodId.fromBase64(issue(creator, payload)); + + System.out.println("51Did parsed from base64:"); + System.out.println(" Domain : " + fodId.getDomain()); + System.out.println(" Type : " + fodId.getType()); + System.out.println(" Flags : 0x" + + Integer.toHexString(fodId.getFlags())); + System.out.println(" LicenseId : " + fodId.getLicenseId()); + System.out.println(" Hash : " + toHex(fodId.getHash())); + System.out.println(" Verifies : " + + fodId.verify(crypto.publicKeyPem())); + + // Issue the SAME payload again: a separate envelope, same value. + FodId reissued = FodId.fromBase64(issue(creator, payload)); + boolean sameEnvelope = + fodId.asBase64().equals(reissued.asBase64()); + boolean sameValue = + Arrays.equals(fodId.getHash(), reissued.getHash()); + + System.out.println(); + System.out.println("Same payload, re-issued:"); + System.out.println(" Same envelope (base64) : " + sameEnvelope); + System.out.println(" Same value (Hash) : " + sameValue); + + // The reader's whole purpose: the value is the stable, comparable + // part while the envelope is not. + if (sameEnvelope || !sameValue) { + throw new IllegalStateException( + "Expected a different envelope but the same value " + + "across reissues."); + } + } + + /** Issues (signs) a 51Did over the payload and returns it as base64. */ + private String issue(Creator creator, byte[] payload) + throws Exception { + Owid owid = new Owid(DOMAIN, Instant.now(), payload); + creator.sign(owid); + return owid.asBase64(); + } + + /** + * A canonical 37-byte Probabilistic payload: flags 0x00, License Id + * 0x12345678 (little-endian) and a 32-byte value 0x20..0x3F. + */ + private byte[] samplePayload() { + byte[] payload = new byte[FodId.PAYLOAD_LENGTH]; + payload[FodId.FLAGS_OFFSET] = 0x00; + 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; + } + + private static String toHex(byte[] bytes) { + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + builder.append(String.format("%02x", b & 0xFF)); + } + return builder.toString(); + } + } + + public static void main(String[] args) throws Exception { + new Example().run(); + } +} 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 new file mode 100644 index 000000000..8a7d2c226 --- /dev/null +++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java @@ -0,0 +1,39 @@ +/* ********************************************************************* + * 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 org.junit.jupiter.api.Test; + +public class ExampleTests { + + /** + * The 51Did example is fully offline, so unlike the cloud examples it must + * complete without throwing. {@code run()} also self-checks the + * value-stable / envelope-changes invariant and throws if it does not + * hold. + */ + @Test + public void FodId_Example_Test() throws Exception { + new Main.Example().run(); + } +} diff --git a/pipeline.developer-examples/pom.xml b/pipeline.developer-examples/pom.xml index 7a85db684..a1c51b55d 100644 --- a/pipeline.developer-examples/pom.xml +++ b/pipeline.developer-examples/pom.xml @@ -43,6 +43,7 @@ pipeline.developer-examples.clientside-element pipeline.developer-examples.clientside-element-mvc pipeline.developer-examples.usage-sharing + pipeline.developer-examples.fodid true diff --git a/pipeline.did/README.md b/pipeline.did/README.md new file mode 100644 index 000000000..2b580bfa3 --- /dev/null +++ b/pipeline.did/README.md @@ -0,0 +1,104 @@ +# pipeline.did + +Strongly typed Java reader for the 51Did (51Degrees Identifier) returned by +the 51Degrees Cloud service. Mirrors the .NET `FiftyOne.Did` package. + +## Terminology + +A 51Did is described at three levels, and the wording is deliberate. + +- The **51Did** (51Degrees Identifier) is the identifier as a whole. +- The **envelope** is the data model that carries it: a signed OWID holding + the version, domain, date, payload and signature. It changes byte-for-byte + every time the cloud issues one, even for the same inputs, because the date + and signature change on each call. +- The **value** is the stable, comparable part of the payload after the Flags + and License Id: a 32-byte SHA-256 for Probabilistic and HashedEmail + identifiers, or 16 GUID bytes for Random. Two 51Dids for the same inputs + share the same value even though their envelopes differ. + +**Comparing two 51Dids means comparing their values, never their envelopes.** + +## Payload layout + +The header is shared by every identifier type; bits 6-7 of Flags select the +type and the length of the value that follows. + +| Offset | Length | Field | Type | +|-------:|-------:|------------|-------------------------------------------------| +| 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) | + +| Bits 7-6 | `IdType` | Value length | Minimum payload | +|---------:|-----------------|-------------:|----------------:| +| `00` | `PROBABILISTIC` | 32 | 37 | +| `01` | `RANDOM` | 16 | 21 | +| `10` | `HASHED_EMAIL` | 32 | 37 | +| `11` | `RESERVED` | remainder | 5 | + +Identifiers issued before the type tag existed have bits 6-7 zeroed and decode +as `PROBABILISTIC`. + +## OWID dependency + +`FodId` builds on the OWID envelope library +([SWAN-community/owid-java](https://github.com/SWAN-community/owid-java), +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. + +### Bundled third-party licence + +Because the OWID (`com.swancommunity.owid.*`) code is compiled into +`pipeline.did.jar`, the jar ships Apache-2.0 code alongside the EUPL-1.2 51Did +code. As required by Apache-2.0, the jar carries the full Apache licence text +and an attribution: see `META-INF/LICENSE-owid.txt` and `META-INF/NOTICE.txt` +(OWID is © 51 Degrees Mobile Experts Limited, from +[SWAN-community/owid-java](https://github.com/SWAN-community/owid-java), +Apache-2.0). + +## Usage + +```java +import fiftyone.pipeline.did.FodId; +import fiftyone.pipeline.did.IdType; + +FodId fodId = FodId.fromBase64(base64FromCloudService); + +int flags = fodId.getFlags(); +IdType type = fodId.getType(); // PROBABILISTIC / RANDOM / HASHED_EMAIL +long licenseId = fodId.getLicenseId(); +byte[] hash = fodId.getHash(); // SHA-256 or GUID bytes, see type + +// Delegated OWID-level fields and operations. +String domain = fodId.getDomain(); +boolean verified = fodId.verify(publicKeyPem); +String base64 = fodId.asBase64(); +``` + +## Comparing two 51Dids + +```java +FodId a = FodId.fromBase64(idprobglobalA); +FodId b = FodId.fromBase64(idprobglobalB); + +// The envelope (date, signature, base64) differs across reissues. +// The value inside the payload is stable - this is what you compare: +boolean sameValue = java.util.Arrays.equals(a.getHash(), b.getHash()); +``` + +Use `getHash()` as the cache / dedup key. + +## 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. diff --git a/pipeline.did/pom.xml b/pipeline.did/pom.xml new file mode 100644 index 000000000..53bc84181 --- /dev/null +++ b/pipeline.did/pom.xml @@ -0,0 +1,136 @@ + + + + + 4.0.0 + + + com.51degrees + pipeline + 4.5.7-SNAPSHOT + + + pipeline.did + + 51Degrees :: Pipeline :: 51Did + Strongly typed reader for the 51Did (51Degrees Identifier) + value returned by the 51Degrees Cloud service. Parses the OWID + envelope and exposes the Flags, License Id and value (Hash) plus the + identifier type. Compare values, never envelopes. + https://51degrees.com?utm_source=maven&utm_medium=package&utm_campaign=pipeline-java&utm_content=pipeline.did-pom.xml&utm_term=url + + + + ${project.basedir}/../owid-java/src/main/java + + + + + junit + junit + test + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.4.0 + + + add-owid-source + generate-sources + + add-source + + + + ${owid.source.dir} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + **/com/swancommunity/owid/Endpoints.java + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + + + + org.codehaus.mojo + animal-sniffer-maven-plugin + 1.24 + + + org.codehaus.mojo.signature + java18 + 1.0 + + + + + check-java8-api + process-classes + + check + + + + + + + + diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java new file mode 100644 index 000000000..6752b639b --- /dev/null +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java @@ -0,0 +1,287 @@ +/* ********************************************************************* + * 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.Owid; +import com.swancommunity.owid.OwidException; +import com.swancommunity.owid.Version; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.Objects; + +/** + * A strongly typed reader for the 51Did (51Degrees Identifier) value returned + * by the 51Degrees Cloud service. + *

+ * A 51Did is described at three levels, and the wording here is deliberate. + * The 51Did is the identifier as a whole. The envelope is the + * signed {@link Owid} that carries it (version, domain, date, payload, + * signature), re-issued fresh on every call. The value is the stable, + * comparable part of the payload after the Flags and License Id, exposed as + * {@link #getHash()}. Two 51Dids for the same inputs share the same value even + * though their envelopes differ on every issue. Compare values, never + * envelopes. + *

+ * Payload layout. The header (offsets 0-4) is shared by every identifier type; + * bits 6-7 of Flags select the {@link IdType} and the length of the value that + * follows: + *

+ *

+ * 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. + */ +public final class FodId { + + /** Byte offset of the Flags field within the payload. */ + public static final int FLAGS_OFFSET = 0; + + /** Byte offset of the License Id field within the payload. */ + public static final int LICENSE_ID_OFFSET = 1; + + /** Byte length of the License Id field. */ + public static final int LICENSE_ID_LENGTH = 4; + + /** Byte offset of the value (Hash) field within the payload. */ + public static final int HASH_OFFSET = 5; + + /** Byte length of the SHA-256 value. */ + public static final int HASH_LENGTH = 32; + + /** + * Byte length of the payload header (Flags + License Id) common to every + * identifier type. + */ + public static final int HEADER_LENGTH = HASH_OFFSET; + + /** Byte length of the GUID value carried by Random identifiers. */ + public static final int GUID_LENGTH = 16; + + /** + * Minimum byte length of a Random 51Did payload + * (Flags + License Id + GUID). + */ + public static final int RANDOM_PAYLOAD_LENGTH = HEADER_LENGTH + GUID_LENGTH; + + /** + * Minimum byte length of a Probabilistic or HashedEmail 51Did payload + * (Flags + License Id + Hash). Random payloads are shorter - see + * {@link #RANDOM_PAYLOAD_LENGTH}. + */ + public static final int PAYLOAD_LENGTH = HASH_OFFSET + HASH_LENGTH; + + private final Owid owid; + private final int flags; + private final long licenseId; + private final byte[] hash; + + private FodId(Owid owid, String paramName) { + this.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 + ")."); + } + 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 valueLength; + switch (IdType.fromFlags(flags)) { + case RANDOM: + valueLength = GUID_LENGTH; + break; + case RESERVED: + valueLength = payload.length - HEADER_LENGTH; + break; + default: + valueLength = HASH_LENGTH; + 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 + ")."); + } + // Defensive copy: mutating the returned hash must not change the + // underlying OWID payload bytes. + this.hash = Arrays.copyOfRange( + payload, HASH_OFFSET, HASH_OFFSET + valueLength); + } + + /** + * Parses a 51Did from its base64-encoded OWID string. + * + * @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 + * @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(base64), "base64"); + } + + /** + * Parses a 51Did from the raw bytes of an OWID envelope. + * + * @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 + * @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"); + } + + /** + * 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). + * + * @param owid the already-parsed OWID envelope + * @return a 51Did wrapping an independent copy of {@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 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 the 1-byte usage flags bit-mask from the payload (0-255) + */ + public int getFlags() { + return flags; + } + + /** + * @return the identifier type carried in bits 6-7 of {@link #getFlags()} + */ + public IdType getType() { + return IdType.fromFlags(flags); + } + + /** + * @return the 4-byte little-endian License Id (0 to 4294967295) + */ + public long getLicenseId() { + return licenseId; + } + + /** + * Returns the value bytes from the payload (a 32-byte SHA-256 for + * Probabilistic and HashedEmail identifiers, or 16 GUID bytes for Random). + * This is the stable, comparable part of the envelope - use it as the + * cache / dedup key. + * + * @return a defensive copy of the value bytes + */ + public byte[] getHash() { + return hash.clone(); + } + + /** @return the OWID version. */ + public Version getVersion() { + return owid.getVersion(); + } + + /** @return the domain of the OWID creator. */ + public String getDomain() { + return owid.getDomain(); + } + + /** @return the OWID creation date. */ + public Instant getDate() { + return owid.getDate(); + } + + /** @return a copy of the OWID payload bytes. */ + public byte[] getPayload() { + return owid.getPayload(); + } + + /** @return a copy of the 64-byte OWID signature. */ + public byte[] getSignature() { + return owid.getSignature(); + } + + /** + * @return the OWID as a base64 string + * @throws OwidException if the OWID has not been signed or cannot be encoded + */ + public String asBase64() throws OwidException { + return owid.asBase64(); + } + + /** + * @return the OWID as a byte array including the signature + * @throws OwidException if the OWID has not been signed or cannot be encoded + */ + public byte[] asByteArray() throws OwidException { + return owid.asByteArray(); + } + + /** + * Verifies the OWID signature against the supplied public key. This is an + * explicit, separate step - construction never verifies. + * + * @param publicPem the creator's public key in SPKI PEM form + * @return true if the signature verifies, false otherwise + * @throws OwidException if the PEM is not a valid public key or a field + * cannot be encoded + */ + public boolean verify(String publicPem) throws OwidException { + return owid.verifyWithPublicKey(publicPem, Collections.emptyList()); + } +} diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/IdType.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/IdType.java new file mode 100644 index 000000000..28e5fdd1f --- /dev/null +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/IdType.java @@ -0,0 +1,66 @@ +/* ********************************************************************* + * 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 identifier type carried in bits 6-7 of the 51Did flags byte. + *

+ * Existing identifiers were issued with these bits zeroed, so they decode as + * {@link #PROBABILISTIC}. The type selects the length of the value that + * follows the header in the payload. + */ +public enum IdType { + + /** + * Derived from the device fingerprint and IP address. The payload carries + * a 32-byte SHA-256 value. + */ + PROBABILISTIC, + + /** + * A server-generated random GUID. The payload carries 16 GUID bytes. + */ + RANDOM, + + /** + * Derived from the caller-supplied email and salt. The payload carries a + * 32-byte SHA-256 value. + */ + HASHED_EMAIL, + + /** + * Not yet assigned. Parsed best-effort: the header fields are unpacked and + * the remaining payload bytes are exposed as-is. + */ + RESERVED; + + /** + * Decodes the identifier type from the top two bits (6-7) of a flags byte. + * + * @param flags the 1-byte flags value (0-255) + * @return the identifier type + */ + public static IdType fromFlags(int flags) { + return values()[(flags >> 6) & 0b11]; + } +} 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 new file mode 100644 index 000000000..9d13d6cdf --- /dev/null +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/package-info.java @@ -0,0 +1,32 @@ +/* ********************************************************************* + * 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. + * ********************************************************************* */ + +/** + * Strongly typed reader 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. + */ +package fiftyone.pipeline.did; diff --git a/pipeline.did/src/main/resources/META-INF/LICENSE-owid.txt b/pipeline.did/src/main/resources/META-INF/LICENSE-owid.txt new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/pipeline.did/src/main/resources/META-INF/LICENSE-owid.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pipeline.did/src/main/resources/META-INF/NOTICE.txt b/pipeline.did/src/main/resources/META-INF/NOTICE.txt new file mode 100644 index 000000000..3dd0f8e4a --- /dev/null +++ b/pipeline.did/src/main/resources/META-INF/NOTICE.txt @@ -0,0 +1,19 @@ +FiftyOne.Did (fiftyone.pipeline.did) +Copyright 2026 51 Degrees Mobile Experts Limited +Licensed under the European Union Public Licence (EUPL) v.1.2. + +------------------------------------------------------------------------------ +This artifact bundles third-party code: + +OWID (Open Web Id) - com.swancommunity.owid.* + Copyright 2026 51 Degrees Mobile Experts Limited (51degrees.com) + Source: https://github.com/SWAN-community/owid-java + Licensed under the Apache License, Version 2.0. + The full licence text is provided alongside this notice in + META-INF/LICENSE-owid.txt. + +The OWID source files retain their original Apache-2.0 licence headers. This +package compiles that source into its jar (rather than depending on a separate +artifact); the Apache-2.0 licence text and this attribution travel with it as +required by the licence. +------------------------------------------------------------------------------ diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java new file mode 100644 index 000000000..1bfc1c104 --- /dev/null +++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java @@ -0,0 +1,124 @@ +/* ********************************************************************* + * 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.Creator; +import com.swancommunity.owid.Crypto; +import com.swancommunity.owid.Owid; +import com.swancommunity.owid.OwidException; + +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. + */ +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(); + + private final Creator creator; + + /** The PEM-encoded public key matching the signing key. */ + final String publicPem; + + FodIdTestFactory() throws OwidException { + Crypto crypto = Crypto.generate(); + this.publicPem = crypto.publicKeyPem(); + this.creator = Creator.create(TEST_DOMAIN, crypto); + } + + private static byte[] canonicalHash() { + byte[] hash = new byte[FodId.HASH_LENGTH]; + for (int i = 0; i < hash.length; i++) { + hash[i] = (byte) (0x20 + i); + } + 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; + writeCanonicalLicenseId(payload); + System.arraycopy( + CANONICAL_HASH, 0, payload, FodId.HASH_OFFSET, FodId.HASH_LENGTH); + 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); + writeCanonicalLicenseId(payload); + for (int i = 0; i < FodId.GUID_LENGTH; i++) { + payload[FodId.HASH_OFFSET + i] = (byte) (0x40 + i); + } + return payload; + } + + private static void writeCanonicalLicenseId(byte[] payload) { + // Little-endian: low byte first. + 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; + } + + /** + * 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. + */ + Owid signedOwid(byte[] payload) throws OwidException { + Owid owid = new Owid(TEST_DOMAIN, Instant.now(), payload); + creator.sign(owid); + return owid; + } + + /** Signs the given payload and returns the OWID as base64. */ + String signedOwidBase64(byte[] payload) throws OwidException { + return signedOwid(payload).asBase64(); + } +} diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java new file mode 100644 index 000000000..54a92b2cf --- /dev/null +++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java @@ -0,0 +1,412 @@ +/* ********************************************************************* + * 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.Crypto; +import com.swancommunity.owid.Owid; +import com.swancommunity.owid.OwidException; +import org.junit.Before; +import org.junit.Test; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; + +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.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.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * Tests for {@link FodId}. Ports the live .NET suite (including the Type-model + * cases) and adds the runbook's gap tests. + */ +public class FodIdTests { + + private FodIdTestFactory factory; + + @Before + public void init() throws OwidException { + factory = new FodIdTestFactory(); + } + + // ----- Current .NET coverage ----- + + @Test + public void constants_AreInternallyConsistent() { + assertEquals(FodId.PAYLOAD_LENGTH, FodId.HASH_OFFSET + FodId.HASH_LENGTH); + assertEquals(FodId.HASH_OFFSET, FodId.LICENSE_ID_OFFSET + FodId.LICENSE_ID_LENGTH); + assertEquals(FodId.RANDOM_PAYLOAD_LENGTH, FodId.HASH_OFFSET + FodId.GUID_LENGTH); + } + + @Test + public void exposesOwidLevelFields() throws Exception { + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload())); + // OWID-level concerns are delegated to the wrapped envelope. + assertEquals(TEST_DOMAIN, fodId.getDomain()); + assertNotNull(fodId.getVersion()); + } + + @Test + public void constructor_FromBase64_UnpacksAllThreeFields() throws Exception { + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload())); + + assertEquals(CANONICAL_FLAGS, fodId.getFlags()); + assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId()); + assertArrayEquals(CANONICAL_HASH, fodId.getHash()); + assertEquals(TEST_DOMAIN, fodId.getDomain()); + } + + @Test + public void constructor_FromBytes_UnpacksAllThreeFields() throws Exception { + String base64 = factory.signedOwidBase64(canonicalPayload()); + byte[] bytes = Base64.getDecoder().decode(base64); + + FodId fodId = FodId.fromByteArray(bytes); + + assertEquals(CANONICAL_FLAGS, fodId.getFlags()); + assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId()); + assertArrayEquals(CANONICAL_HASH, fodId.getHash()); + assertEquals(TEST_DOMAIN, fodId.getDomain()); + } + + @Test + public void constructor_FromOwid_UnpacksAllThreeFields() throws Exception { + Owid owid = factory.signedOwid(canonicalPayload()); + + FodId fodId = FodId.fromOwid(owid); + + assertEquals(CANONICAL_FLAGS, fodId.getFlags()); + assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId()); + assertArrayEquals(CANONICAL_HASH, fodId.getHash()); + assertEquals(owid.getDomain(), fodId.getDomain()); + assertEquals(owid.getDate(), fodId.getDate()); + assertEquals(owid.getVersion(), fodId.getVersion()); + // owid-java getters clone, so assert content equality, not identity. + assertArrayEquals(owid.getPayload(), fodId.getPayload()); + assertArrayEquals(owid.getSignature(), fodId.getSignature()); + } + + @Test + public void constructor_NullOwid_Throws() { + assertThrows(NullPointerException.class, () -> FodId.fromOwid(null)); + } + + @Test + public void licenseId_IsLittleEndian() throws Exception { + byte[] payload = canonicalPayload(); + payload[FodId.LICENSE_ID_OFFSET] = 0x01; + payload[FodId.LICENSE_ID_OFFSET + 1] = 0x00; + payload[FodId.LICENSE_ID_OFFSET + 2] = 0x00; + payload[FodId.LICENSE_ID_OFFSET + 3] = 0x00; + + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload)); + + assertEquals(1L, fodId.getLicenseId()); + } + + @Test + public void licenseId_MaxValue_IsLittleEndian() throws Exception { + byte[] payload = canonicalPayload(); + payload[FodId.LICENSE_ID_OFFSET] = (byte) 0xFF; + payload[FodId.LICENSE_ID_OFFSET + 1] = (byte) 0xFF; + payload[FodId.LICENSE_ID_OFFSET + 2] = (byte) 0xFF; + payload[FodId.LICENSE_ID_OFFSET + 3] = (byte) 0xFF; + + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload)); + + assertEquals(4294967295L, fodId.getLicenseId()); + } + + @Test + public void licenseId_HighBitSet_StaysUnsigned() throws Exception { + byte[] payload = canonicalPayload(); + // 0x80000000 little-endian: 00 00 00 80 + payload[FodId.LICENSE_ID_OFFSET] = 0x00; + payload[FodId.LICENSE_ID_OFFSET + 1] = 0x00; + payload[FodId.LICENSE_ID_OFFSET + 2] = 0x00; + payload[FodId.LICENSE_ID_OFFSET + 3] = (byte) 0x80; + + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload)); + + assertEquals(0x80000000L, fodId.getLicenseId()); + } + + @Test + public void flags_ZeroValue_Exposed() throws Exception { + byte[] payload = canonicalPayload(); + payload[FodId.FLAGS_OFFSET] = 0x00; + + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload)); + + assertEquals(0, fodId.getFlags()); + } + + @Test + public void flags_AllBitsSet_Exposed() throws Exception { + byte[] payload = canonicalPayload(); + payload[FodId.FLAGS_OFFSET] = (byte) 0xFF; + + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload)); + + assertEquals(255, fodId.getFlags()); + } + + @Test + public void hash_IsDefensiveCopy() throws Exception { + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload())); + + byte[] hash = fodId.getHash(); + hash[0] = 0x00; + hash[FodId.HASH_LENGTH - 1] = 0x00; + + // Neither the underlying payload nor a fresh getHash() is affected. + assertEquals(CANONICAL_HASH[0], fodId.getPayload()[FodId.HASH_OFFSET]); + assertArrayEquals(CANONICAL_HASH, fodId.getHash()); + } + + @Test + public void constructor_PayloadOneByteShort_Throws() throws Exception { + // 36 bytes - one short of the minimum 37 (flags 0 -> Probabilistic). + String base64 = factory.signedOwidBase64(new byte[FodId.PAYLOAD_LENGTH - 1]); + assertThrows(IllegalArgumentException.class, () -> FodId.fromBase64(base64)); + } + + @Test + public void constructor_PayloadEmpty_Throws() throws Exception { + String base64 = factory.signedOwidBase64(new byte[0]); + assertThrows(IllegalArgumentException.class, () -> FodId.fromBase64(base64)); + } + + @Test + public void constructor_NullBase64_Throws() { + assertThrows(NullPointerException.class, () -> FodId.fromBase64(null)); + } + + @Test + public void constructor_NullBuffer_Throws() { + assertThrows(NullPointerException.class, () -> FodId.fromByteArray(null)); + } + + @Test + public void constructor_InvalidBase64_Throws() { + assertThrows(OwidException.class, + () -> FodId.fromBase64("This is not valid Base64!@#$")); + } + + @Test + public void constructor_PayloadLargerThanSpec_UsesFirst37Bytes() throws Exception { + byte[] payload = new byte[64]; + System.arraycopy(canonicalPayload(), 0, payload, 0, FodId.PAYLOAD_LENGTH); + for (int i = FodId.PAYLOAD_LENGTH; i < payload.length; i++) { + payload[i] = (byte) 0xCC; + } + + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload)); + + assertEquals(CANONICAL_FLAGS, fodId.getFlags()); + assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId()); + assertArrayEquals(CANONICAL_HASH, fodId.getHash()); + assertEquals(FodId.HASH_LENGTH, fodId.getHash().length); + } + + @Test + public void fodId_IsCryptographicallyVerifiable() throws Exception { + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload())); + assertTrue(fodId.verify(factory.publicPem)); + } + + @Test + public void base64Roundtrip_PreservesAllFields() throws Exception { + FodId fodId1 = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload())); + FodId fodId2 = FodId.fromBase64(fodId1.asBase64()); + + assertEquals(fodId1.getFlags(), fodId2.getFlags()); + assertEquals(fodId1.getLicenseId(), fodId2.getLicenseId()); + assertArrayEquals(fodId1.getHash(), fodId2.getHash()); + assertEquals(fodId1.getDomain(), fodId2.getDomain()); + } + + // ----- Type model ----- + + @Test + public void type_DecodedFromTopTwoFlagBits() throws Exception { + assertEquals(IdType.PROBABILISTIC, typeForFlags((byte) 0b0000_0101)); + assertEquals(IdType.HASHED_EMAIL, typeForFlags((byte) 0b1000_0101)); + assertEquals(IdType.RESERVED, typeForFlags((byte) 0b1100_0101)); + } + + private IdType typeForFlags(byte flags) throws Exception { + byte[] payload = canonicalPayload(); + payload[FodId.FLAGS_OFFSET] = flags; + return FodId.fromBase64(factory.signedOwidBase64(payload)).getType(); + } + + @Test + public void type_RandomWhenBits01() throws Exception { + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalRandomPayload())); + assertEquals(IdType.RANDOM, fodId.getType()); + } + + @Test + public void constructor_RandomPayload21Bytes_Parses() throws Exception { + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalRandomPayload())); + + assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId()); + assertEquals(FodId.GUID_LENGTH, fodId.getHash().length); + byte[] expected = new byte[FodId.GUID_LENGTH]; + for (int i = 0; i < expected.length; i++) { + expected[i] = (byte) (0x40 + i); + } + assertArrayEquals(expected, fodId.getHash()); + } + + @Test + public void constructor_RandomPayloadOneByteShort_Throws() throws Exception { + byte[] payload = Arrays.copyOf( + canonicalRandomPayload(), FodId.RANDOM_PAYLOAD_LENGTH - 1); + String base64 = factory.signedOwidBase64(payload); + assertThrows(IllegalArgumentException.class, () -> FodId.fromBase64(base64)); + } + + @Test + public void constructor_RandomPayloadLargerThanSpec_UsesFirst16ValueBytes() + throws Exception { + byte[] payload = new byte[FodId.PAYLOAD_LENGTH]; + System.arraycopy( + canonicalRandomPayload(), 0, payload, 0, FodId.RANDOM_PAYLOAD_LENGTH); + for (int i = FodId.RANDOM_PAYLOAD_LENGTH; i < payload.length; i++) { + payload[i] = (byte) 0xCC; + } + + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload)); + + assertEquals(IdType.RANDOM, fodId.getType()); + assertEquals(FodId.GUID_LENGTH, fodId.getHash().length); + } + + @Test + public void constructor_HashedEmailPayloadOneByteShort_Throws() throws Exception { + // CANONICAL_FLAGS (0xA5) carries the HashedEmail tag, so the 37-byte + // minimum still applies. + byte[] payload = Arrays.copyOf(canonicalPayload(), FodId.PAYLOAD_LENGTH - 1); + String base64 = factory.signedOwidBase64(payload); + assertThrows(IllegalArgumentException.class, () -> FodId.fromBase64(base64)); + } + + @Test + public void constructor_ReservedHeaderOnly_Parses() throws Exception { + byte[] payload = new byte[FodId.HASH_OFFSET]; + payload[FodId.FLAGS_OFFSET] = (byte) 0b1100_0000; + + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload)); + + assertEquals(IdType.RESERVED, fodId.getType()); + assertEquals(0, fodId.getHash().length); + } + + // ----- Gap tests (runbook section 6b) ----- + + @Test + 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")); + + FodId fodA = FodId.fromBase64(a.asBase64()); + FodId fodB = FodId.fromBase64(b.asBase64()); + + // The value is stable across reissues... + assertArrayEquals(fodA.getHash(), fodB.getHash()); + // ...while the envelope differs. + assertNotEquals(fodA.getDate(), fodB.getDate()); + assertFalse(Arrays.equals(fodA.getSignature(), fodB.getSignature())); + assertNotEquals(a.asBase64(), b.asBase64()); + } + + @Test + public void construction_DoesNotVerify() throws Exception { + // An OWID with a present but tampered (invalid) signature still + // constructs and exposes all three fields - construction must not + // verify. + byte[] bytes = Base64.getDecoder().decode( + factory.signedOwidBase64(canonicalPayload())); + bytes[bytes.length - 1] ^= 0xFF; // corrupt the signature + Owid tampered = Owid.fromByteArray(bytes); + + FodId fodId = FodId.fromOwid(tampered); + + assertEquals(CANONICAL_FLAGS, fodId.getFlags()); + assertEquals(CANONICAL_LICENSE_ID, fodId.getLicenseId()); + assertArrayEquals(CANONICAL_HASH, fodId.getHash()); + } + + @Test + public void fromOwid_IsDecoupledFromSourceOwid() throws Exception { + // Mutating the source OWID after construction must not affect the + // FodId (it holds an independent copy). + 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]); + } + + @Test + public void verify_WithWrongKey_ReturnsFalse() throws Exception { + FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload())); + String otherPublicPem = Crypto.generate().publicKeyPem(); + + assertFalse(fodId.verify(otherPublicPem)); + } + + @Test + public void roundtrip_ThroughBytesConstructor_PreservesAllFields() + throws Exception { + FodId fodId1 = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload())); + + FodId fodId2 = FodId.fromByteArray(fodId1.asByteArray()); + + assertEquals(fodId1.getFlags(), fodId2.getFlags()); + assertEquals(fodId1.getLicenseId(), fodId2.getLicenseId()); + assertArrayEquals(fodId1.getHash(), fodId2.getHash()); + assertEquals(fodId1.getDomain(), fodId2.getDomain()); + } +} diff --git a/pom.xml b/pom.xml index 043e3bbd5..38eb3c851 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ pipeline.common pipeline.core + pipeline.did pipeline.engines pipeline.caching pipeline.cloudrequestengine