diff --git a/pipeline.did/README.md b/pipeline.did/README.md index 5561edb3a..227c01eb2 100644 --- a/pipeline.did/README.md +++ b/pipeline.did/README.md @@ -32,9 +32,9 @@ layout out from this package. What follows is a summary of the part that changes how this package behaves. The identifier carries a five byte header of Flags and License Id, then the -match key, then an optional creator context section. Bits 6-7 of Flags -select the type, which decides how long the match key is and so what the -least a payload can hold is. +match key, then the Terms byte, then an optional creator context section. +Bits 6-7 of Flags select the type, which decides how long the match key is +and so what the least a payload can hold is. | Bits 7-6 | `IdType` | Match key length | Minimum payload | |---------:|-----------------|-------------:|----------------:| @@ -46,6 +46,29 @@ least a payload can hold is. Identifiers issued before the type tag existed have bits 6-7 zeroed and decode as `PROBABILISTIC`. +The Terms byte is not counted in those minimums. An identifier whose +payload ends at the match key has no Terms byte, and a missing byte is read +as index zero, which says the terms are not stated in the identifier. +Absence and zero mean the same thing, so no reader has to tell them apart +and no presence flag exists. See the terms section below. + +## The payload version + +Bits 4 and 5 of the Flags byte say which payload layout the identifier +follows, and this package reads version 0. A payload naming version 1, 2 +or 3 is refused with `FodIdParseStatus.UNSUPPORTED_PAYLOAD_VERSION`, and +the throwing readers name the version they found in the message. + +The fields are never read under the layout this package knows once the +version says otherwise. A later version exists precisely because a field +moved, so reading such a payload here would answer with values that are +wrong rather than absent, which is worse than refusing. A version that +nothing checks protects nothing. + +The version is not exposed. Either this package read the layout, in which +case the accessors are the answer, or it did not, in which case there is +no identifier to read fields from. + 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 @@ -187,6 +210,10 @@ Usage usage = fodId.getUsage(); // what the identifier may be used fo boolean fromConsent = fodId.isUsageFromConsent(); long licenseId = fodId.getLicenseId(); byte[] matchKey = fodId.getMatchKey(); // SHA-256 or GUID bytes, see type +String terms = fodId.getTerms(); // address of the terms document + // it was created under, null + // where it names none this + // package knows // Delegated OWID-level fields and operations. String domain = fodId.getDomain(); @@ -244,6 +271,60 @@ if (fodId.getUsage() == Usage.NON_MARKETING) { } ``` +## Which terms a 51Did was created under + +`getTerms()` answers with the address of the terms document the identifier +was created under. The answer travels inside the identifier, so a receiver +always has it, rather than depending on the surrounding protocol to carry +the terms alongside the identifier where any hop can drop them without the +identifier looking any different. The package turns the index into the +address, so a caller never handles the byte. + +The byte after the match key is an index into a table in the specification +and is not a version number, so that a later document can live at any +address rather than only at one a number could compose. An index is never +reused and never repointed once published, because repointing one would +rewrite what an identifier already issued says it agreed to. + +| Index | Document | `getTerms()` | +|---|---|---| +| `0` | Not stated in the identifier | `null` | +| `1` | Model Terms for Marketing, version 2 | `https://m4ow.uk/mtm/2.txt` | +| any other | One this package cannot name | `null` | + +An index added to the specification after this package was released answers +with no address, and the package never builds an address from the index, +because that would name a document nobody wrote and a receiver would record +having accepted terms that do not exist. A caller therefore cannot tell an +index of zero from an index this package cannot name, which is deliberate, +since both lead to the same place. + +```java +if (fodId.getTerms() == null) { + // The identifier does not say which terms it was created under, so the + // answer has to come from the data accompanying it. +} +``` + +No address does not mean the identifier is unrestricted. It means only +that the identifier does not carry the answer, so the answer has to come +from somewhere else, being the Terms Document Locator in an OpenRTB request +or whatever the surrounding protocol provides. Carrying the terms in the +identifier does not remove the need to carry a locator where a protocol has +one, and where the two disagree the identifier's own value is the one that +describes the identifier, because it is inside the signature and the +accompanying data is not. + +The terms and the usage answer different questions and a receiver needs +both. `getUsage()` says where an identifier may go and `getTerms()` says +which document it was created under. An identifier created for non-marketing +carries index zero and so answers with no address, because the Model Terms +govern marketing use and a non-marketing identifier is not created under +them, and it stays barred from a demand source by its usage. + +This package never fetches the address. It returns it and the receiver +decides what to do with it. + ## Verifying on your server `DidClient` handles every manipulation of a 51Did a server needs against the 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 9f12be7cf..3136751b6 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java @@ -50,12 +50,25 @@ * Payload layout. Read a 51Did through the typed accessors below, never by * walking the payload bytes. The identifier carries a five byte header of * Flags and License Id, then the match key, whose length the identifier - * type in bits 6-7 of Flags decides, and then an optional creator context + * type in bits 6-7 of Flags decides, then the Terms byte naming the terms + * document it was created under, and then an optional creator context * section that binds the identifier to the browser and connection it was * created on. Only 51Degrees can read that section, so this reader exposes - * it only as the part of {@link #getPayload()} beyond the match key, its + * it only as the part of {@link #getPayload()} beyond the Terms, its * lengths belong to the cloud, and this reader therefore puts no upper - * bound on a payload. The byte layout is specified at + * bound on a payload. A payload that ends at the match key has no Terms + * byte, and a missing byte reads as a Terms of zero, so absence and zero + * mean the same thing. + *
+ * Bits 4 and 5 of the Flags byte say which payload layout the identifier + * follows, and this package reads version 0. A payload naming any other + * version is refused with + * {@link FodIdParseStatus#UNSUPPORTED_PAYLOAD_VERSION} rather than read + * under the layout this package knows, because a later version exists + * precisely because a field moved, so reading one here would answer with + * values that are wrong rather than absent. The version is not exposed, + * because a caller has nothing to decide with it. + * The byte layout is specified at * identifier-layout.md, * which is the authority for it, and the surface every 51Did package * offers is specified at @@ -130,21 +143,42 @@ public final class FodId { static final int PAYLOAD_LENGTH = MATCH_KEY_OFFSET + MATCH_KEY_LENGTH; + /** + * Byte length of the Terms field, which follows the match key. It is + * not part of any minimum above, because a payload that ends at the + * match key reads as a Terms of zero. + */ + static final int TERMS_LENGTH = 1; + + /** + * The payload layout version this package reads, carried in bits 4 and + * 5 of the Flags byte. Any other version is refused rather than read + * under this layout. + */ + static final int SUPPORTED_PAYLOAD_VERSION = 0; + private final Owid owid; private final int flags; private final long licenseId; private final byte[] matchKey; + private final int termsIndex; /** * 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[] matchKey) { + private FodId( + Owid owid, + int flags, + long licenseId, + byte[] matchKey, + int termsIndex) { this.owid = owid; this.flags = flags; this.licenseId = licenseId; this.matchKey = matchKey; + this.termsIndex = termsIndex; } // ----- Reading without throwing ----- @@ -206,8 +240,9 @@ private static FodIdParseResult read(OwidParseResult envelope) { * 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 match key are a creator context section whose shape the cloud - * judges. + * the match key are the Terms and then a creator context section whose + * shape the cloud judges. The Terms adds nothing to those bounds, since + * a payload that ends at the match key reads as a Terms of zero. */ private static FodIdParseResult read(Owid owid) { byte[] payload = owid.getPayload(); @@ -215,6 +250,16 @@ private static FodIdParseResult read(Owid owid) { return FodIdParseResult.failed(FodIdParseStatus.PAYLOAD_TOO_SHORT); } int flags = payload[FLAGS_OFFSET] & 0xFF; + // The version is read before any field, because a later version + // exists precisely because a field moved. Reading a payload of a + // version this package does not know under the layout it does know + // would answer with values that are wrong rather than absent, + // which is worse than refusing, and a version that nothing checks + // protects nothing. + int payloadVersion = payloadVersionOf(flags); + if (payloadVersion != SUPPORTED_PAYLOAD_VERSION) { + return FodIdParseResult.unsupportedPayloadVersion(payloadVersion); + } int matchKeyLength; switch (IdType.fromFlags(flags)) { case RANDOM: @@ -246,8 +291,17 @@ private static FodIdParseResult read(Owid owid) { // bytes. byte[] matchKey = Arrays.copyOfRange( payload, MATCH_KEY_OFFSET, MATCH_KEY_OFFSET + matchKeyLength); + // The Terms byte follows the match key, wherever the type put its + // end. A payload that stops there has no Terms byte, and a missing + // byte is read as zero, which says the terms are not stated in the + // identifier. Absence and zero therefore mean the same thing and + // nothing has to tell them apart. + int termsOffset = MATCH_KEY_OFFSET + matchKeyLength; + int termsIndex = payload.length > termsOffset + ? payload[termsOffset] & 0xFF + : 0; return FodIdParseResult.parsed( - new FodId(owid, flags, licenseId, matchKey)); + new FodId(owid, flags, licenseId, matchKey, termsIndex)); } // ----- Reading with exceptions ----- @@ -349,6 +403,19 @@ public static FodId fromOwid(Owid owid) throws OwidException { * 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. */ + /** + * Bits 4 and 5 of the Flags byte, being the version of the payload + * layout the identifier follows. The envelope carries a version of its + * own at its first byte, which versions the envelope, whilst this one + * versions the payload. + * + * @param flags the Flags byte + * @return the payload layout version (0 to 3) + */ + private static int payloadVersionOf(int flags) { + return (flags >> 4) & 0b11; + } + private static FodId valueOrThrow(FodIdParseResult result, String paramName) throws OwidException { switch (result.getStatus()) { @@ -362,6 +429,11 @@ private static FodId valueOrThrow(FodIdParseResult result, String paramName) throw new IllegalArgumentException( "51Did payload is shorter than the minimum for its " + "identifier type (" + paramName + ")."); + case UNSUPPORTED_PAYLOAD_VERSION: + throw new IllegalArgumentException( + "51Did payload version " + result.getPayloadVersion() + + " is not one this package can read (" + + paramName + ")."); default: throw new OwidException( "The value is not an OWID envelope: " @@ -440,6 +512,36 @@ public byte[] getMatchKey() { return matchKey.clone(); } + /** + * The address of the terms document this 51Did was created under, from + * the Terms byte that follows the match key. + *
+ * The byte is an index into a table in the specification and this + * package turns the index into the address, so a caller never handles + * the byte. The address is answered and never fetched, and the receiver + * decides what to do with the document. + *
+ * Null covers both an index of zero, which says the terms are not + * stated in the identifier, and an index added to the specification + * after this package was released, which it cannot name. A caller + * cannot tell those two apart, which is deliberate, because both lead + * to the same place, being that the identifier does not say which terms + * it was created under and the answer has to come from somewhere else. + * No package may build an address from an index it does not know, since + * that would name a document nobody wrote. + *
+ * No address does not mean the identifier is unrestricted. Where an + * identifier may go is a separate question {@link #getUsage()} answers, + * which still bars a non-marketing identifier from a demand source. + * + * @return the address of the terms document, or null where the + * identifier names no document this package knows, which is + * never an empty string and is never built from the index + */ + public String getTerms() { + return Terms.fromIndex(termsIndex).getUrl(); + } + /** @return the OWID version. */ public Version getVersion() { return owid.getVersion(); diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseResult.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseResult.java index 545a2a92b..e282b880b 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseResult.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseResult.java @@ -49,17 +49,49 @@ public final class FodIdParseResult { private final FodIdParseStatus status; - private FodIdParseResult(FodId value, FodIdParseStatus status) { + private final int payloadVersion; + + private FodIdParseResult( + FodId value, FodIdParseStatus status, int payloadVersion) { this.value = value; this.status = status; + this.payloadVersion = payloadVersion; } static FodIdParseResult parsed(FodId value) { - return new FodIdParseResult(value, FodIdParseStatus.PARSED); + return new FodIdParseResult( + value, FodIdParseStatus.PARSED, FodId.SUPPORTED_PAYLOAD_VERSION); } static FodIdParseResult failed(FodIdParseStatus status) { - return new FodIdParseResult(null, status); + return new FodIdParseResult( + null, status, FodId.SUPPORTED_PAYLOAD_VERSION); + } + + /** + * A read refused because the payload names a layout version this + * package does not know, carrying the version so that the throwing + * readers can name it in their message. The version is not public, + * because a caller has nothing to decide with it. + * + * @param payloadVersion the version the payload named + * @return the refused read + */ + static FodIdParseResult unsupportedPayloadVersion(int payloadVersion) { + return new FodIdParseResult( + null, + FodIdParseStatus.UNSUPPORTED_PAYLOAD_VERSION, + payloadVersion); + } + + /** + * The payload layout version a refused read found, for the message the + * throwing readers give. Zero for every other outcome. + * + * @return the version the payload named + */ + int getPayloadVersion() { + return payloadVersion; } /** diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseStatus.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseStatus.java index 447f1c07a..64858b810 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseStatus.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodIdParseStatus.java @@ -114,7 +114,16 @@ public enum FodIdParseStatus { * anything past the match key is a creator context section whose lengths * belong to the cloud. */ - INVALID_TYPE_PAYLOAD_LENGTH; + INVALID_TYPE_PAYLOAD_LENGTH, + + /** + * Bits 4 and 5 of the Flags byte name a payload layout version this + * package does not know, so no field is read. A later version exists + * precisely because a field moved, so reading the payload under the + * layout this package knows would answer with values that are wrong + * rather than absent. + */ + UNSUPPORTED_PAYLOAD_VERSION; /** * Carries an OWID status across unchanged. diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/Terms.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/Terms.java new file mode 100644 index 000000000..56a2fb14c --- /dev/null +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/Terms.java @@ -0,0 +1,166 @@ +/* ********************************************************************* + * This Original Work is copyright of 51 Degrees Mobile Experts Limited. + * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, + * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. + * + * This Original Work is licensed under the European Union Public Licence + * (EUPL) v.1.2 and is subject to its terms as set out below. + * + * If a copy of the EUPL was not distributed with this file, You can obtain + * one at https://opensource.org/licenses/EUPL-1.2. + * + * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be + * amended by the European Commission) shall be deemed incompatible for + * the purposes of the Work and the provisions of the compatibility + * clause in Article 5 of the EUPL shall not apply. + * + * If using the Work as, or as part of, a network application, by + * including the attribution notice(s) required under Article 5 of the EUPL + * in the end user terms of the application under an appropriate heading, + * such notice(s) shall fulfill the requirements of that article. + * ********************************************************************* */ + +package fiftyone.pipeline.did; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * The terms document a 51Did was created under, carried in the byte after + * the match key. It travels inside the identifier so that a receiver always + * has the terms the identifier was created under, rather than depending on + * the surrounding protocol to carry them alongside it, which any hop can + * drop without the identifier looking any different. + *
+ * The byte is an index into a table in the specification and is not a + * version number, so that a later document can live at any address rather + * than only at one a number could compose. An index is never reused and + * never repointed once published, because repointing one would rewrite what + * an identifier already issued says it agreed to. A new document is a new + * index, and every 51Did package has to be released to know it, which is + * the cost of a receiver being able to trust what it reads. + *
+ * This enumeration is not public, and neither is the index behind it. The + * package turns the index into the address that {@link FodId#getTerms()} + * answers with, so a caller never handles the byte, and the names here are + * the ones the specification gives so that every package describes one + * document the same way. + *
+ * {@link #UNKNOWN} is an index added to the specification after this + * package was released, so the package cannot name the document. It + * answers with no address, as {@link #NOT_STATED} does, because no package + * may build an address from an index it does not know, since that would + * name a document nobody wrote. + *
+ * {@link #NOT_STATED} does not mean the identifier is unrestricted. It + * means only that the identifier does not carry the answer, so the answer + * has to come from somewhere else, being the Terms Document Locator in an + * OpenRTB request or whatever the surrounding protocol provides. Carrying + * the terms in the identifier does not remove the need to carry a locator + * where a protocol has one, and where the two disagree the identifier's own + * value is the one that describes the identifier, because it is inside the + * signature and the accompanying data is not. + *
+ * The terms and the {@link Usage} answer different questions and a receiver + * needs both. The usage says where an identifier may go and the terms say + * which document it was created under. An identifier created for + * non-marketing carries {@link #NOT_STATED}, because the Model Terms govern + * marketing use and a non-marketing identifier is not created under them, + * and it stays barred from a demand source by its usage. + *
+ * The values are the same in every 51Did package. The table is specified at
+ * identifier-layout.md,
+ * which is the authority for it.
+ */
+enum Terms {
+ /**
+ * Index 0, the terms are not stated in the identifier and the receiver
+ * has to take them from the data accompanying it. An identifier whose
+ * payload ends at the match key reads as this, so absence and zero
+ * mean the same thing.
+ */
+ NOT_STATED(0, null),
+
+
+ /**
+ * Index 1, the Model Terms for Marketing, version 2.
+ */
+ MODEL_TERMS_FOR_MARKETING_2(1, "https://m4ow.uk/mtm/2.txt"),
+
+ /**
+ * An index added to the specification after this package was released.
+ * Terms are stated and this package cannot name them, so it answers
+ * with no address rather than building one from the index.
+ */
+ UNKNOWN(-1, null);
+
+ /**
+ * The Terms index, and -1 for {@link #UNKNOWN}, which stands for every
+ * index this package does not name and so has no index of its own. A
+ * Terms index read from a payload is one byte, so it is 0 to 255 and
+ * can never be negative, which is what makes -1 safe as the value that
+ * is not in the table. A negative index is left out of
+ * {@link #BY_INDEX} for that reason.
+ */
+ private final int index;
+
+ private final String url;
+
+ Terms(int index, String url) {
+ this.index = index;
+ this.url = url;
+ }
+
+ /**
+ * The table above, read by index. It is built from the members rather
+ * than written out a second time, so a member and its index can never
+ * disagree, and a new terms document is one new member above and
+ * nothing here.
+ */
+ private static final Map
* {@link fiftyone.pipeline.did.DidClient} is what a server uses against the
* 51Degrees cloud: it fetches and holds the published signing keys, verifies
diff --git a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdParseTests.java b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdParseTests.java
index 76f19756b..6be4959b2 100644
--- a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdParseTests.java
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdParseTests.java
@@ -39,8 +39,14 @@
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.canonicalPayloadWithTerms;
+import static fiftyone.pipeline.did.FodIdTestFactory.canonicalPayloadWithTermsAndSection;
import static fiftyone.pipeline.did.FodIdTestFactory.canonicalRandomPayload;
+import static fiftyone.pipeline.did.FodIdTestFactory.canonicalRandomPayloadWithTerms;
+import static fiftyone.pipeline.did.FodIdTestFactory.payloadEndingAtMatchKey;
+import static fiftyone.pipeline.did.FodIdTestFactory.randomPayloadEndingAtMatchKey;
import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.fail;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
@@ -211,6 +217,312 @@ public void isUsageFromConsent_IsBitThree() throws Exception {
assertEquals(Usage.STANDARD, fodId.getUsage());
}
+ /**
+ * An identifier whose payload ends at the match key has no Terms byte.
+ * A missing byte is read as index zero, which says the terms are not
+ * stated in the identifier, so absence and zero mean the same thing and
+ * nothing has to tell them apart, and the identifier answers with no
+ * address.
+ */
+ @Test
+ public void getTerms_NoByteAfterTheMatchKey_HasNoAddress()
+ throws Exception {
+ for (byte[] payload : new byte[][] {
+ payloadEndingAtMatchKey(),
+ randomPayloadEndingAtMatchKey() }) {
+ FodId fodId = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(payload, DATE).asBase64()));
+
+ assertNull(fodId.getTerms());
+ }
+ }
+
+ /**
+ * A Terms byte holding zero answers exactly as no byte at all does, so
+ * the two never have to be told apart.
+ */
+ @Test
+ public void getTerms_ZeroByte_AnswersAsAbsenceDoes() throws Exception {
+ FodId absent = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(payloadEndingAtMatchKey(), DATE)
+ .asBase64()));
+ FodId zero = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(canonicalPayloadWithTerms(0), DATE)
+ .asBase64()));
+
+ assertEquals(absent.getTerms(), zero.getTerms());
+ assertNull(zero.getTerms());
+ }
+
+ /**
+ * Index one is the Model Terms for Marketing version 2, whose address is
+ * answered exactly as the specification writes it and is never fetched.
+ * The byte is read after the match key, whose length the type sets, so
+ * both match key lengths are checked and neither loses a byte to the
+ * Terms.
+ */
+ @Test
+ public void getTerms_IndexOne_ModelTermsAddressForBothKeyLengths()
+ throws Exception {
+ FodId probabilistic = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(canonicalPayloadWithTerms(1), DATE)
+ .asBase64()));
+ FodId random = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(canonicalRandomPayloadWithTerms(1), DATE)
+ .asBase64()));
+
+ for (FodId fodId : new FodId[] { probabilistic, random }) {
+ assertEquals("https://m4ow.uk/mtm/2.txt", fodId.getTerms());
+ }
+ // The 32-byte match key and the 16-byte one are both the same as
+ // they are without the Terms byte, so nothing was taken from either.
+ assertEquals(IdType.HASHED_EMAIL, probabilistic.getType());
+ assertArrayEquals(CANONICAL_MATCH_KEY, probabilistic.getMatchKey());
+ assertEquals(IdType.RANDOM, random.getType());
+ assertEquals(FodId.GUID_LENGTH, random.getMatchKey().length);
+ assertArrayEquals(
+ assertParsed(FodId.tryFromBase64(factory
+ .signedOwidAt(randomPayloadEndingAtMatchKey(), DATE)
+ .asBase64()))
+ .getMatchKey(),
+ random.getMatchKey());
+ }
+
+ /**
+ * An index added to the specification after this package was released
+ * answers with no address, and no address is ever built from the index,
+ * because that would name a document nobody wrote. A caller cannot tell
+ * such an index from zero, which is deliberate, since both say the
+ * identifier does not give the terms and the answer has to come from
+ * somewhere else. The byte is unsigned in the read, so 255 is 255 and
+ * not a negative number.
+ */
+ @Test
+ public void getTerms_IndexThisPackageDoesNotKnow_HasNoAddress()
+ throws Exception {
+ FodId notStated = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(canonicalPayloadWithTerms(0), DATE)
+ .asBase64()));
+
+ for (int index : new int[] { 2, 127, 200, 255 }) {
+ FodId unknown = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(canonicalPayloadWithTerms(index), DATE)
+ .asBase64()));
+
+ assertNull("terms index " + index, unknown.getTerms());
+ assertEquals("terms index " + index,
+ Terms.UNKNOWN, Terms.fromIndex(index));
+ }
+ assertNull(notStated.getTerms());
+ }
+
+ /**
+ * Every index the table does not carry is unknown and has no address,
+ * across the whole byte, so none of them can be read as zero.
+ */
+ @Test
+ public void terms_EveryIndexOutsideTheTable_IsUnknownWithNoUrl() {
+ assertEquals(Terms.NOT_STATED, Terms.fromIndex(0));
+ assertNull(Terms.NOT_STATED.getUrl());
+ assertEquals(Terms.MODEL_TERMS_FOR_MARKETING_2, Terms.fromIndex(1));
+ assertEquals("https://m4ow.uk/mtm/2.txt",
+ Terms.MODEL_TERMS_FOR_MARKETING_2.getUrl());
+ for (int index = 2; index <= 255; index++) {
+ assertEquals("terms index " + index,
+ Terms.UNKNOWN, Terms.fromIndex(index));
+ assertNull("terms index " + index,
+ Terms.fromIndex(index).getUrl());
+ }
+ }
+
+ // ----- The payload version -----
+
+ /**
+ * The payload with its version bits set to the given version, leaving
+ * every other bit of the Flags byte alone.
+ */
+ private static byte[] withVersion(byte[] payload, int version) {
+ byte[] withVersion = payload.clone();
+ withVersion[FodId.FLAGS_OFFSET] = (byte) (
+ (payload[FodId.FLAGS_OFFSET] & 0b1100_1111)
+ | (version << 4));
+ return withVersion;
+ }
+
+ /**
+ * A Flags byte with bits 4 and 5 clear is version 0, which is the
+ * layout this package reads, so every field reads as it does on the
+ * canonical payload.
+ */
+ @Test
+ public void version_Zero_ReadsEveryField() throws Exception {
+ FodId fodId = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(canonicalPayload(), DATE).asBase64()));
+
+ assertEquals(IdType.HASHED_EMAIL, fodId.getType());
+ assertEquals(Usage.PERSONALIZED, fodId.getUsage());
+ assertArrayEquals(CANONICAL_MATCH_KEY, fodId.getMatchKey());
+ assertEquals("https://m4ow.uk/mtm/2.txt", fodId.getTerms());
+ }
+
+ /**
+ * Versions 1, 2 and 3 are not assigned, so a payload naming one is
+ * refused rather than read under the layout this package knows.
+ */
+ @Test
+ public void version_NotZero_IsRefused() throws Exception {
+ for (int version : new int[] { 1, 2, 3 }) {
+ FodIdParseResult result = FodId.tryFromBase64(factory
+ .signedOwidAt(withVersion(canonicalPayload(), version), DATE)
+ .asBase64());
+
+ assertFalse("version " + version, result.isSuccess());
+ assertEquals("version " + version,
+ FodIdParseStatus.UNSUPPORTED_PAYLOAD_VERSION,
+ result.getStatus());
+ // Nothing is handed back, rather than a value with some fields
+ // filled in, because there is no identifier to expose fields
+ // for when the layout was not understood.
+ assertNull("version " + version, result.getValue());
+ }
+ }
+
+ /**
+ * The throwing readers name the version they found, so whoever reads
+ * the message knows which layout the identifier claims rather than only
+ * that some version was refused.
+ */
+ @Test
+ public void version_NotZero_MessageNamesTheVersion() throws Exception {
+ for (int version : new int[] { 1, 2, 3 }) {
+ String base64 = factory
+ .signedOwidAt(withVersion(canonicalPayload(), version), DATE)
+ .asBase64();
+ try {
+ FodId.fromBase64(base64);
+ fail("version " + version + " should have been refused");
+ } catch (IllegalArgumentException thrown) {
+ assertTrue(thrown.getMessage(),
+ thrown.getMessage().contains("version " + version));
+ }
+ }
+ }
+
+ /**
+ * The version bits are read on their own, so an identifier of version 0
+ * still reads whatever its usage and type bits hold, and one of another
+ * version is refused whatever they hold. A reader masking the wrong
+ * bits would fail one of these.
+ */
+ @Test
+ public void version_IsReadApartFromTheUsageAndTypeBits()
+ throws Exception {
+ for (int usage : new int[] { 0b000, 0b001, 0b011, 0b111 }) {
+ for (int type : new int[] { 0b00, 0b10, 0b11 }) {
+ int flags = (type << 6) | usage;
+ byte[] payload = payloadEndingAtMatchKey();
+ payload[FodId.FLAGS_OFFSET] = (byte) flags;
+
+ assertTrue("flags " + flags, FodId.tryFromBase64(
+ factory.signedOwidAt(payload, DATE).asBase64())
+ .isSuccess());
+
+ for (int version : new int[] { 1, 2, 3 }) {
+ FodIdParseResult refused = FodId.tryFromBase64(factory
+ .signedOwidAt(withVersion(payload, version), DATE)
+ .asBase64());
+
+ assertEquals("flags " + flags + " version " + version,
+ FodIdParseStatus.UNSUPPORTED_PAYLOAD_VERSION,
+ refused.getStatus());
+ assertNull(refused.getValue());
+ }
+ }
+ }
+ }
+
+ /**
+ * The Terms sits before the creator context section, so a payload
+ * carrying both still reads the match key and the Terms from the places
+ * they are written at, and the section is exposed as it always was.
+ */
+ @Test
+ public void getTerms_ByteThenContextSection_ReadAtTheRightOffset()
+ throws Exception {
+ byte[] payload = canonicalPayloadWithTermsAndSection(1, 512);
+
+ FodId fodId = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(payload, DATE).asBase64()));
+
+ assertArrayEquals(CANONICAL_MATCH_KEY, fodId.getMatchKey());
+ assertEquals("https://m4ow.uk/mtm/2.txt", fodId.getTerms());
+ assertArrayEquals(payload, fodId.getPayload());
+ assertTrue(fodId.verify(factory.publicPem));
+ }
+
+ /**
+ * The terms table is the one place the package says which index is
+ * which document, and the index to member map is built from the
+ * members rather than written out again. This walks every member and
+ * fails if one does not read back from its own index, or if a member
+ * that names a document has no address, which is what would happen if
+ * a member and its address were ever added apart.
+ */
+ @Test
+ public void terms_EveryMemberAgreesWithTheTable() {
+ for (Terms terms : Terms.values()) {
+ if (terms == Terms.UNKNOWN) {
+ // It stands for every index not in the table, so it has no
+ // index of its own and no address.
+ assertEquals(-1, terms.getIndex());
+ assertNull(terms.getUrl());
+ continue;
+ }
+ assertEquals(
+ "member " + terms + " does not read back from its index",
+ terms,
+ Terms.fromIndex(terms.getIndex()));
+ if (terms == Terms.NOT_STATED) {
+ // Names no document, so it has no address.
+ assertEquals(0, terms.getIndex());
+ assertNull(terms.getUrl());
+ } else {
+ assertNotNull(
+ "member " + terms + " names a document with no address",
+ terms.getUrl());
+ assertTrue(
+ "address for " + terms + " is not an https address",
+ terms.getUrl().startsWith("https://"));
+ }
+ }
+ }
+
+ /**
+ * The Reserved type has no defined match key length, so the reader
+ * takes every byte after the header as the match key and leaves none
+ * to read as the Terms. Such an identifier therefore states no terms,
+ * which is the right answer rather than a gap to close, because an
+ * identifier of a type this package cannot lay out is one whose Terms
+ * it cannot place either.
+ */
+ @Test
+ public void getTerms_ReservedType_StatesNoTerms() throws Exception {
+ byte[] payload = canonicalPayloadWithTerms(1);
+ payload[FodId.FLAGS_OFFSET] =
+ (byte) ((CANONICAL_FLAGS & 0b0011_1111) | 0b1100_0000);
+
+ FodId fodId = assertParsed(FodId.tryFromBase64(
+ factory.signedOwidAt(payload, DATE).asBase64()));
+
+ assertEquals(IdType.RESERVED, fodId.getType());
+ assertNull(fodId.getTerms());
+ // The byte that would have been the Terms is inside the match key,
+ // which is what taking every byte after the header means.
+ assertEquals(
+ payload.length - FodId.HEADER_LENGTH,
+ fodId.getMatchKey().length);
+ }
+
@Test
public void tryFromBase64_ReservedHeaderOnly_ParsedBestEffort()
throws Exception {
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 083c9f26f..971e017ca 100644
--- a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTestFactory.java
@@ -49,7 +49,20 @@ final class FodIdTestFactory {
static final String TEST_DOMAIN = "51degrees.com";
- static final int CANONICAL_FLAGS = 0xA5;
+ static final int CANONICAL_FLAGS = 0x85;
+
+ /**
+ * The Terms index a marketing identifier carries, being the Model Terms
+ * for Marketing version 2.
+ */
+ static final int MARKETING_TERMS_INDEX = 1;
+
+ /**
+ * The Terms index a non-marketing identifier carries, since the Model
+ * Terms govern marketing use and a non-marketing identifier is not
+ * created under them.
+ */
+ static final int NON_MARKETING_TERMS_INDEX = 0;
static final long CANONICAL_LICENSE_ID = 0x12345678L;
@@ -81,7 +94,23 @@ private static byte[] canonicalMatchKey() {
return matchKey;
}
+ /**
+ * The canonical payload as an issuer writes one, carrying the payload
+ * version 0 in its Flags byte and the Terms byte of the document a
+ * personalized marketing identifier is created under. This is the
+ * creating side, so it writes every field an issuer writes.
+ */
static byte[] canonicalPayload() {
+ return canonicalPayloadWithTerms(MARKETING_TERMS_INDEX);
+ }
+
+ /**
+ * The canonical payload cut off at the end of the match key, so it
+ * carries no Terms byte. A reader takes that as a Terms of zero, and
+ * this is the fixture for that rule rather than anything an issuer
+ * would write.
+ */
+ static byte[] payloadEndingAtMatchKey() {
byte[] payload = new byte[FodId.PAYLOAD_LENGTH];
payload[FodId.FLAGS_OFFSET] = (byte) CANONICAL_FLAGS;
writeCanonicalLicenseId(payload);
@@ -91,7 +120,20 @@ static byte[] canonicalPayload() {
return payload;
}
+ /**
+ * The canonical Random payload as an issuer writes one, carrying the
+ * payload version 0 and the zero Terms byte a non-marketing identifier
+ * carries.
+ */
static byte[] canonicalRandomPayload() {
+ return canonicalRandomPayloadWithTerms(NON_MARKETING_TERMS_INDEX);
+ }
+
+ /**
+ * The canonical Random payload cut off at the end of its GUID, so it
+ * carries no Terms byte.
+ */
+ static byte[] randomPayloadEndingAtMatchKey() {
byte[] payload = new byte[FodId.RANDOM_PAYLOAD_LENGTH];
payload[FodId.FLAGS_OFFSET] = (byte) ((1 << 6) | 0b001);
writeCanonicalLicenseId(payload);
@@ -101,10 +143,53 @@ static byte[] canonicalRandomPayload() {
return payload;
}
+ /**
+ * The canonical payload with a Terms byte written after its 32-byte
+ * match key, which is how the cloud issues one.
+ */
+ static byte[] canonicalPayloadWithTerms(int termsIndex) {
+ byte[] payload =
+ new byte[FodId.PAYLOAD_LENGTH + FodId.TERMS_LENGTH];
+ System.arraycopy(
+ payloadEndingAtMatchKey(), 0, payload, 0, FodId.PAYLOAD_LENGTH);
+ payload[FodId.PAYLOAD_LENGTH] = (byte) termsIndex;
+ return payload;
+ }
+
+ /**
+ * The canonical Random payload with a Terms byte written after its
+ * 16-byte match key, so that the byte sits at a different offset from
+ * the one {@link #canonicalPayloadWithTerms(int)} puts it at.
+ */
+ static byte[] canonicalRandomPayloadWithTerms(int termsIndex) {
+ byte[] payload =
+ new byte[FodId.RANDOM_PAYLOAD_LENGTH + FodId.TERMS_LENGTH];
+ System.arraycopy(
+ randomPayloadEndingAtMatchKey(), 0, payload, 0,
+ FodId.RANDOM_PAYLOAD_LENGTH);
+ payload[FodId.RANDOM_PAYLOAD_LENGTH] = (byte) termsIndex;
+ return payload;
+ }
+
+ /**
+ * The canonical payload with a Terms byte and then a creator context
+ * section after it, which is the order the two sit in.
+ */
+ static byte[] canonicalPayloadWithTermsAndSection(
+ int termsIndex, int sectionLength) {
+ byte[] withTerms = canonicalPayloadWithTerms(termsIndex);
+ byte[] payload = new byte[withTerms.length + sectionLength];
+ System.arraycopy(withTerms, 0, payload, 0, withTerms.length);
+ for (int i = withTerms.length; i < payload.length; i++) {
+ payload[i] = (byte) 0xCC;
+ }
+ return payload;
+ }
+
static byte[] canonicalPayloadWithSection(int sectionLength) {
byte[] payload = new byte[FodId.PAYLOAD_LENGTH + sectionLength];
System.arraycopy(
- canonicalPayload(), 0, payload, 0, FodId.PAYLOAD_LENGTH);
+ payloadEndingAtMatchKey(), 0, payload, 0, FodId.PAYLOAD_LENGTH);
for (int i = FodId.PAYLOAD_LENGTH; i < payload.length; i++) {
payload[i] = (byte) 0xCC;
}
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 1bca88b54..b3234176b 100644
--- a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java
+++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java
@@ -178,13 +178,18 @@ public void flags_ZeroValue_Exposed() throws Exception {
}
@Test
- public void flags_AllBitsSet_Exposed() throws Exception {
+ public void flags_EveryBitOutsideTheVersionSet_Exposed()
+ throws Exception {
+ // Bits 4 and 5 are the payload version and only version 0 is read,
+ // so every other bit is set and those two are left clear. A payload
+ // with them set is refused rather than read, which FodIdParseTests
+ // covers.
byte[] payload = canonicalPayload();
- payload[FodId.FLAGS_OFFSET] = (byte) 0xFF;
+ payload[FodId.FLAGS_OFFSET] = (byte) 0xCF;
FodId fodId = FodId.fromBase64(factory.signedOwidBase64(payload));
- assertEquals(255, fodId.getFlags());
+ assertEquals(0xCF, fodId.getFlags());
}
@Test