From 3a59f69f144c46eb5a65f860ac131a97b4fd7899 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 6 Sep 2026 17:40:31 +0100 Subject: [PATCH 1/2] FEAT: Name the usage a 51Did was created for, as the highest granted FodId exposed the flags byte and a type read from bits 6-7, and nothing for the usage in bits 0-2, so a caller deciding whether an identifier may be passed to a demand source had to mask the byte by hand. The three usages are cumulative in the byte, non-marketing 0b001, standard 0b011 and personalized 0b111, so a caller masking for the non-marketing bit alone would read every marketing identifier as non-marketing, the wrong way round for a data protection decision. Usage names the four states, NONE where no bit is set, and getUsage() answers with the highest granted. isUsageFromConsent() reads bit 3, which records that the usage came from a consent string rather than being stated. The names match the cloud's id.usage values, which getIdUsage() gives, and are the same in every 51Did package, which all gain the accessor together. Found by the Trusted Server work, which must work only through this package. pipeline.did tests: 129 run, 0 failures, two new. --- .../java/fiftyone/pipeline/did/FodId.java | 21 +++++ .../java/fiftyone/pipeline/did/Usage.java | 92 +++++++++++++++++++ .../pipeline/did/FodIdParseTests.java | 38 ++++++++ 3 files changed, 151 insertions(+) create mode 100644 pipeline.did/src/main/java/fiftyone/pipeline/did/Usage.java 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 996095347..eb893a14b 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java @@ -408,6 +408,27 @@ public IdType getType() { return IdType.fromFlags(flags); } + /** + * @return the usage carried in bits 0-2 of {@link #getFlags()}, as the + * highest usage granted; see {@link Usage} for why it is read + * that way + */ + public Usage getUsage() { + return Usage.fromFlags(flags); + } + + /** + * Whether the usage was derived from an IAB consent string the caller + * sent, rather than stated by the caller directly. Bit 3 of + * {@link #getFlags()}. Both are legitimate ways to arrive at a usage, + * and this says nothing about which usage it is. + * + * @return whether the usage came from a consent string + */ + public boolean isUsageFromConsent() { + return (flags & 0b1000) != 0; + } + /** * The 4-byte little-endian License Id field (0 to 4294967295). *

diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/Usage.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/Usage.java new file mode 100644 index 000000000..3a5d070a4 --- /dev/null +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/Usage.java @@ -0,0 +1,92 @@ +/* ********************************************************************* + * 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 usage a 51Did was created for, carried in bits 0-2 of + * {@link FodId#getFlags()}. It decides where the identifier may go: one + * created for {@link #NON_MARKETING} must never be passed to a demand + * source, and one created for {@link #STANDARD} or {@link #PERSONALIZED} + * may be passed only to a recipient that has accepted the applicable + * terms. + *

+ * The three usages are cumulative rather than exclusive in the byte. + * Non-marketing sets bit 0, standard sets bits 0 and 1, and personalized + * sets bits 0, 1 and 2, so every marketing identifier also carries the + * non-marketing bit. A caller who masked the byte for that bit alone would + * read every marketing identifier as non-marketing, which is the wrong way + * round for a data protection decision. {@link FodId#getUsage()} answers + * with the highest usage granted, so that mistake cannot be made. + *

+ * The names match the cloud's {@code id.usage} values, {@code non-marketing}, + * {@code standard} and {@code personalized}, and are the same in every 51Did + * package. + */ +public enum Usage { + /** + * No usage bit is set. The cloud never issues such an identifier, so + * this is an identifier from somewhere else or a damaged one, and it + * should be treated as though it may not be passed on. + */ + NONE(null), + /** Created for use that is not marketing. Must not be passed to a demand source. */ + NON_MARKETING("non-marketing"), + /** Created for standard marketing, being targeting unrelated to the person's browsing history or interactions. */ + STANDARD("standard"), + /** Created for personalized marketing, being targeting related to the person's browsing history or interactions. */ + PERSONALIZED("personalized"); + + private final String idUsage; + + Usage(String idUsage) { + this.idUsage = idUsage; + } + + /** + * Decodes the usage from a flags byte (bits 0-2), answering the highest + * usage granted. + * + * @param flags the 1-byte flags value (0-255) + * @return the usage + */ + public static Usage fromFlags(int flags) { + if ((flags & 0b100) != 0) { + return PERSONALIZED; + } + if ((flags & 0b010) != 0) { + return STANDARD; + } + if ((flags & 0b001) != 0) { + return NON_MARKETING; + } + return NONE; + } + + /** + * @return the cloud's {@code id.usage} value for this usage, or null for + * {@link #NONE} + */ + public String getIdUsage() { + return idUsage; + } +} 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 9335549bc..76f19756b 100644 --- a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdParseTests.java +++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdParseTests.java @@ -173,6 +173,44 @@ public void tryFromBase64_EachTypeAtItsMinimum_Parsed() throws Exception { .getType()); } + /** + * The usage is the highest granted, because the bits are cumulative. + * A mask for the non-marketing bit alone would say yes for every + * marketing identifier, which is the wrong answer for a data + * protection decision. + */ + @Test + public void getUsage_IsTheHighestGranted() throws Exception { + int[] bits = {0b000, 0b001, 0b011, 0b111}; + Usage[] expected = { + Usage.NONE, Usage.NON_MARKETING, Usage.STANDARD, Usage.PERSONALIZED}; + String[] idUsage = {null, "non-marketing", "standard", "personalized"}; + for (int i = 0; i < bits.length; i++) { + byte[] payload = canonicalRandomPayload(); + payload[FodId.FLAGS_OFFSET] = (byte) ((1 << 6) | bits[i]); + FodId fodId = assertParsed(FodId.tryFromBase64( + factory.signedOwidAt(payload, DATE).asBase64())); + assertEquals("usage bits " + bits[i], expected[i], fodId.getUsage()); + assertEquals(idUsage[i], fodId.getUsage().getIdUsage()); + assertEquals(IdType.RANDOM, fodId.getType()); + assertFalse(fodId.isUsageFromConsent()); + } + } + + /** + * Bit 3 records that the usage came from a consent string rather than + * being stated, and reads independently of which usage it is. + */ + @Test + public void isUsageFromConsent_IsBitThree() throws Exception { + byte[] payload = canonicalRandomPayload(); + payload[FodId.FLAGS_OFFSET] = (byte) ((1 << 6) | 0b1011); + FodId fodId = assertParsed(FodId.tryFromBase64( + factory.signedOwidAt(payload, DATE).asBase64())); + assertTrue(fodId.isUsageFromConsent()); + assertEquals(Usage.STANDARD, fodId.getUsage()); + } + @Test public void tryFromBase64_ReservedHeaderOnly_ParsedBestEffort() throws Exception { From 6f092a63ad2c1746878e639c1d0d8edc483f6d2b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 6 Sep 2026 21:34:03 +0100 Subject: [PATCH 2/2] REF: Take the raw byte surface off FodId so a 51Did is read only by name A 51Did gave a caller two ways to read the same thing, being a typed accessor for each field and the raw bytes with the offsets to walk them by hand. The second way is the one that produces the mistake the usage accessor was added to stop, because the usage bits are cumulative, so non-marketing sets one bit, standard sets two and personalized sets three, and code that masks the byte for the non-marketing bit alone reads every marketing identifier as non-marketing, which is backwards for a rule that says a non-marketing identifier must never reach a demand source. These packages are not widely deployed, so the raw surface is removed now rather than deprecated, and the same removal is being made in every language so that the six packages keep one surface. Gone from the public surface. 1. getFlags() is now package-private. The byte itself stays because getType(), getUsage() and isUsageFromConsent() are built on it and this package's own tests assert the exact byte, and those tests sit in the same package so they keep working unchanged. 2. getHash(), HASH_OFFSET and HASH_LENGTH are deleted. They were aliases left over from the match key rename and nothing called them. The two tests that only asserted the aliases matched their new names go with them. 3. FLAGS_OFFSET, LICENSE_ID_OFFSET, LICENSE_ID_LENGTH, MATCH_KEY_OFFSET, MATCH_KEY_LENGTH, HEADER_LENGTH, GUID_LENGTH, RANDOM_PAYLOAD_LENGTH and PAYLOAD_LENGTH keep their names and become package-private. The only reason to want an offset is to read a bit that now has a name. 4. getDateMinutes() is deleted. Nothing in any of the seven 51Degrees repositories called it, the Rust package never had it, and its one documented purpose, being the value the OWID public-key date parameter takes, is built by DidClient itself. Nothing inside the package needed the minutes, so the constant that converted them went too and no helper replaced it. The two tests that asserted on the minutes now assert the same round trip through the typed getDate(), including that the high bit of the envelope's unsigned 32-bit minutes field does not read back as a date before 2020. Every typed accessor is unchanged, being getUsage(), isUsageFromConsent(), getType(), getLicenseId(), getMatchKey() and the OWID level fields. The 51Did example builds a payload byte by byte because it stands in for the cloud, which is a writer's job rather than a reader's, so it now spells out the three offsets it needs as its own constants and says why. It prints the usage and whether the usage came from a consent string in place of the raw flags byte, and its sample payload now carries standard marketing usage so the accessor shows something. Documentation. The class javadoc and the README no longer repeat the byte layout table. Both name the 51Did specification as the authority for it, at https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md and https://github.com/51Degrees/specifications/blob/main/did-specification/package-surface.md keeping only the short summary of the identifier type that changes how the package behaves. The README gains a section on what a 51Did may be used for, covering getUsage() and isUsageFromConsent(), the four usage values and the cumulative bits, and it loses the lines that used the deleted accessors. Verified with mvn -pl pipeline.did,pipeline.developer-examples/pipeline.developer-examples.fodid -am test on JDK 21, which builds with -Xlint:all -Werror. 127 tests pass in pipeline.did with 2 live cloud tests skipped, and 9 pass in the 51Did example. Written with AI assistance and needs human review. Closes #128 --- .../developerexamples/fodid/Main.java | 48 ++++-- .../developerexamples/fodid/ExampleTests.java | 25 ++-- pipeline.did/README.md | 83 ++++++++--- .../java/fiftyone/pipeline/did/FodId.java | 137 ++++++------------ .../java/fiftyone/pipeline/did/Usage.java | 4 +- .../fiftyone/pipeline/did/FodIdTests.java | 43 ++---- 6 files changed, 173 insertions(+), 167 deletions(-) diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/Main.java b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/Main.java index ada0f49d9..4612ed689 100644 --- a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/Main.java +++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/main/java/pipeline/developerexamples/fodid/Main.java @@ -44,6 +44,18 @@ public class Main { private static final String DOMAIN = "51degrees.com"; + // This example stands in for the cloud, so it builds a payload byte by + // byte, which is a writer's job and not a reader's. The layout is not + // part of the reader's public surface, so the offsets are spelled out + // here from the specification at + // https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md + // Code that reads a 51Did should use the typed accessors instead. + private static final int LICENSE_ID_OFFSET = 1; + private static final int MATCH_KEY_OFFSET = 5; + private static final int MATCH_KEY_LENGTH = 32; + private static final int PAYLOAD_LENGTH = + MATCH_KEY_OFFSET + MATCH_KEY_LENGTH; + public static class Example { public void run() throws Exception { @@ -57,13 +69,15 @@ public void run() throws Exception { 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(" Match key : " + toHex(fodId.getMatchKey())); - System.out.println(" Verifies : " + System.out.println(" Domain : " + fodId.getDomain()); + System.out.println(" Type : " + fodId.getType()); + System.out.println(" Usage : " + fodId.getUsage()); + System.out.println(" From consent : " + + fodId.isUsageFromConsent()); + System.out.println(" LicenseId : " + fodId.getLicenseId()); + System.out.println(" Match key : " + + toHex(fodId.getMatchKey())); + System.out.println(" Verifies : " + fodId.verify(crypto.publicKeyPem())); // Issue the SAME payload again: a separate envelope, same match @@ -99,18 +113,20 @@ private String issue(Creator creator, byte[] payload) } /** - * A canonical 37-byte Probabilistic payload: flags 0x00, License Id + * A canonical 37-byte Probabilistic payload with flags 0x03, License Id * 0x12345678 (little-endian) and a 32-byte match key 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.MATCH_KEY_LENGTH; i++) { - payload[FodId.MATCH_KEY_OFFSET + i] = (byte) (0x20 + i); + byte[] payload = new byte[PAYLOAD_LENGTH]; + // Flags 0b0000_0011, being standard marketing usage stated by + // the caller, on a Probabilistic identifier (bits 6-7 zero). + payload[0] = 0b0000_0011; + payload[LICENSE_ID_OFFSET] = 0x78; + payload[LICENSE_ID_OFFSET + 1] = 0x56; + payload[LICENSE_ID_OFFSET + 2] = 0x34; + payload[LICENSE_ID_OFFSET + 3] = 0x12; + for (int i = 0; i < MATCH_KEY_LENGTH; i++) { + payload[MATCH_KEY_OFFSET + i] = (byte) (0x20 + i); } return payload; } diff --git a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java index f5f87e5d9..2b239abab 100644 --- a/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java +++ b/pipeline.developer-examples/pipeline.developer-examples.fodid/src/test/java/pipeline/developerexamples/fodid/ExampleTests.java @@ -240,17 +240,24 @@ private static String keyList(Crypto crypto) { } /** - * A canonical 37-byte Probabilistic payload: flags 0x00, License Id - * 0x12345678 (little-endian) and a 32-byte match key 0x20..0x3F. + * A canonical 37-byte Probabilistic payload with flags 0x00, License Id + * 0x12345678 (little-endian) and a 32-byte match key 0x20..0x3F. The + * offsets are spelled out here because writing a payload is the cloud's + * job rather than a reader's, so the layout is not part of the reader's + * public surface. It is specified at + * https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md */ private static byte[] samplePayload() { - byte[] payload = new byte[FodId.PAYLOAD_LENGTH]; - payload[FodId.LICENSE_ID_OFFSET] = 0x78; - payload[FodId.LICENSE_ID_OFFSET + 1] = 0x56; - payload[FodId.LICENSE_ID_OFFSET + 2] = 0x34; - payload[FodId.LICENSE_ID_OFFSET + 3] = 0x12; - for (int i = 0; i < FodId.MATCH_KEY_LENGTH; i++) { - payload[FodId.MATCH_KEY_OFFSET + i] = (byte) (0x20 + i); + final int licenseIdOffset = 1; + final int matchKeyOffset = 5; + final int matchKeyLength = 32; + byte[] payload = new byte[matchKeyOffset + matchKeyLength]; + payload[licenseIdOffset] = 0x78; + payload[licenseIdOffset + 1] = 0x56; + payload[licenseIdOffset + 2] = 0x34; + payload[licenseIdOffset + 3] = 0x12; + for (int i = 0; i < matchKeyLength; i++) { + payload[matchKeyOffset + i] = (byte) (0x20 + i); } return payload; } diff --git a/pipeline.did/README.md b/pipeline.did/README.md index 9fc74c88f..1b7d54dc3 100644 --- a/pipeline.did/README.md +++ b/pipeline.did/README.md @@ -23,15 +23,18 @@ envelopes.** ## Payload layout -The header is shared by every identifier type. Bits 6-7 of Flags select the -type and the length of the match key 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 | Match key | SHA-256 (Probabilistic, HashedEmail) or GUID (Random) | -| after | any | Context | Optional creator context section, readable only by 51Degrees | +The byte layout of a 51Did is specified in +[identifier-layout.md](https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md), +and the surface every 51Did package offers is specified in +[package-surface.md](https://github.com/51Degrees/specifications/blob/main/did-specification/package-surface.md). +Those two pages are the authority, so read them rather than working the +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. | Bits 7-6 | `IdType` | Match key length | Minimum payload | |---------:|-----------------|-------------:|----------------:| @@ -52,6 +55,12 @@ License Id bytes hold an encrypted value that only 51Degrees can turn back into a licence identifier, so `getLicenseId()` is the field's raw value and identifies nothing outside 51Degrees. +The package gives no way to read the payload by hand. There is no raw flags +accessor and the offsets and lengths are package-private, because every +field and every bit already has a typed accessor and reading the bytes by +hand is how the usage gets misread. See the usage section below for the +mistake this closes off. + ## OWID dependency `FodId` builds on the OWID envelope library @@ -167,17 +176,20 @@ apart from "the signature could not be checked" (`KEY_UNAVAILABLE`, ```java import fiftyone.pipeline.did.FodId; import fiftyone.pipeline.did.IdType; +import fiftyone.pipeline.did.Usage; +import java.time.Instant; FodId fodId = FodId.fromBase64(base64FromCloudService); -int flags = fodId.getFlags(); -IdType type = fodId.getType(); // PROBABILISTIC / RANDOM / HASHED_EMAIL -long licenseId = fodId.getLicenseId(); -byte[] matchKey = fodId.getMatchKey(); // SHA-256 or GUID bytes, see type +IdType type = fodId.getType(); // PROBABILISTIC / RANDOM / HASHED_EMAIL +Usage usage = fodId.getUsage(); // what the identifier may be used for +boolean fromConsent = fodId.isUsageFromConsent(); +long licenseId = fodId.getLicenseId(); +byte[] matchKey = fodId.getMatchKey(); // SHA-256 or GUID bytes, see type // Delegated OWID-level fields and operations. String domain = fodId.getDomain(); -long minutes = fodId.getDateMinutes(); // the envelope's own date field +Instant date = fodId.getDate(); // when the cloud issued it boolean verified = fodId.verify(publicKeyPem); String base64 = fodId.asBase64(); // standard alphabet, padded String forUrl = fodId.asBase64Url(); // URL-safe alphabet, no padding @@ -194,15 +206,42 @@ FodId b = FodId.fromBase64(idprobglobalB); boolean sameMatchKey = java.util.Arrays.equals(a.getMatchKey(), b.getMatchKey()); ``` -Use `getMatchKey()` as the cache / dedup key. `getHash()` remains as a -deprecated alias of `getMatchKey()`, returning the same bytes, and will be -removed in a future release. +Use `getMatchKey()` as the cache / dedup key. + +## What a 51Did may be used for -The payload constants follow the same naming. `MATCH_KEY_OFFSET` and -`MATCH_KEY_LENGTH` give the position and the size of the match key inside the -payload, and `HASH_OFFSET` and `HASH_LENGTH` remain as deprecated aliases of -the same two values so that code written against the earlier names keeps -compiling. The aliases will be removed in a future release. +`getUsage()` answers what the identifier was created for, and it is the +accessor a data protection decision turns on. + +| `Usage` | Cloud `id.usage` | What it means | +|---|---|---| +| `NON_MARKETING` | `non-marketing` | Created for use that is not marketing. Must never be passed to a demand source. | +| `STANDARD` | `standard` | Created for standard marketing, being targeting unrelated to the person's browsing history or interactions. | +| `PERSONALIZED` | `personalized` | Created for personalized marketing, being targeting related to the person's browsing history or interactions. | +| `NONE` | none | No usage bit is set. The cloud never issues such an identifier, so treat it as one that may not be passed on. | + +`STANDARD` and `PERSONALIZED` may be passed only to a recipient that has +accepted the applicable terms. + +The three usages are cumulative in the byte rather than exclusive, because +non-marketing sets one bit, standard sets two and personalized sets three, +so every marketing identifier also carries the non-marketing bit. Code that +masked the byte for that bit alone would read every marketing identifier as +non-marketing, which is the wrong way round for a rule that says a +non-marketing identifier must never reach a demand source. `getUsage()` +answers with the highest usage granted, so that mistake cannot be made, and +the package offers no raw flags accessor with which to make it. + +`isUsageFromConsent()` says whether the usage came from an IAB consent +string the caller sent rather than being stated by the caller directly. +Both are legitimate ways to arrive at a usage and this says nothing about +which usage it is. + +```java +if (fodId.getUsage() == Usage.NON_MARKETING) { + // Do not pass this identifier to a demand source. +} +``` ## Verifying on your server 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 eb893a14b..9f12be7cf 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/FodId.java @@ -28,7 +28,6 @@ import com.swancommunity.owid.OwidVerificationResult; import com.swancommunity.owid.Version; -import java.time.Duration; import java.time.Instant; import java.util.Arrays; import java.util.Base64; @@ -48,21 +47,19 @@ * same match key even though their envelopes differ on every issue. * Compare match keys, 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 match key - * that follows: - *

+ * 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 + * 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 + * lengths belong to the cloud, and this reader therefore puts no upper + * bound on a payload. 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 + * package-surface.md. *

* Reading and verifying are two separate steps. {@link #tryFromBase64(String)} * and {@link #tryFromByteArray(byte[])} read a 51Did from external input @@ -87,72 +84,52 @@ */ public final class FodId { + // The byte layout below is not part of the public surface. A caller + // reads a 51Did through the typed accessors, because every field and + // every bit already has a name, and reading the payload by hand is how + // the usage bits get misread. The constants stay package-private so + // that this package's own readers and tests can build and walk a + // payload. The layout itself is specified at + // https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md + /** Byte offset of the Flags field within the payload. */ - public static final int FLAGS_OFFSET = 0; + static final int FLAGS_OFFSET = 0; /** Byte offset of the License Id field within the payload. */ - public static final int LICENSE_ID_OFFSET = 1; + static final int LICENSE_ID_OFFSET = 1; /** Byte length of the License Id field. */ - public static final int LICENSE_ID_LENGTH = 4; + static final int LICENSE_ID_LENGTH = 4; /** Byte offset of the match key field within the payload. */ - public static final int MATCH_KEY_OFFSET = 5; + static final int MATCH_KEY_OFFSET = 5; /** Byte length of the match key field (SHA-256). */ - public static final int MATCH_KEY_LENGTH = 32; - - /** - * Deprecated alias for {@link #MATCH_KEY_OFFSET}. The stable, comparable - * part of a 51Did is now called the match key, mirroring the Model Terms - * for Marketing vocabulary. This alias will be removed in a future - * release. - * - * @deprecated renamed to {@link #MATCH_KEY_OFFSET} - */ - @Deprecated - public static final int HASH_OFFSET = MATCH_KEY_OFFSET; - - /** - * Deprecated alias for {@link #MATCH_KEY_LENGTH}. The stable, comparable - * part of a 51Did is now called the match key, mirroring the Model Terms - * for Marketing vocabulary. This alias will be removed in a future - * release. - * - * @deprecated renamed to {@link #MATCH_KEY_LENGTH} - */ - @Deprecated - public static final int HASH_LENGTH = MATCH_KEY_LENGTH; + static final int MATCH_KEY_LENGTH = 32; /** * Byte length of the payload header (Flags + License Id) common to every * identifier type. */ - public static final int HEADER_LENGTH = MATCH_KEY_OFFSET; + static final int HEADER_LENGTH = MATCH_KEY_OFFSET; /** Byte length of the GUID match key carried by Random identifiers. */ - public static final int GUID_LENGTH = 16; + 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; + static final int RANDOM_PAYLOAD_LENGTH = HEADER_LENGTH + GUID_LENGTH; /** * Minimum byte length of a Probabilistic or HashedEmail 51Did payload - * (Flags + License Id + match key). Random payloads are shorter - see + * (Flags + License Id + match key). Random payloads are shorter, see * {@link #RANDOM_PAYLOAD_LENGTH}. */ - public static final int PAYLOAD_LENGTH = + static final int PAYLOAD_LENGTH = MATCH_KEY_OFFSET + MATCH_KEY_LENGTH; - /** - * The origin the envelope's date counts from, 2020-01-01T00:00:00Z, as - * epoch seconds. See {@link #getDateMinutes()}. - */ - private static final long DATE_ORIGIN_EPOCH_SECONDS = 1_577_836_800L; - private final Owid owid; private final int flags; private final long licenseId; @@ -395,21 +372,27 @@ private static FodId valueOrThrow(FodIdParseResult result, String paramName) // ----- Fields ----- /** - * @return the 1-byte usage flags bit-mask from the payload (0-255) + * The raw Flags byte. Package-private on purpose, because + * {@link #getType()}, {@link #getUsage()} and + * {@link #isUsageFromConsent()} name every bit a caller needs and + * masking the byte by hand is how the cumulative usage bits get read + * backwards. Kept because those three accessors are built on it. + * + * @return the 1-byte flags bit-mask from the payload (0-255) */ - public int getFlags() { + int getFlags() { return flags; } /** - * @return the identifier type carried in bits 6-7 of {@link #getFlags()} + * @return the identifier type carried in bits 6-7 of the Flags byte */ public IdType getType() { return IdType.fromFlags(flags); } /** - * @return the usage carried in bits 0-2 of {@link #getFlags()}, as the + * @return the usage carried in bits 0-2 of the Flags byte, as the * highest usage granted; see {@link Usage} for why it is read * that way */ @@ -419,8 +402,8 @@ public Usage getUsage() { /** * Whether the usage was derived from an IAB consent string the caller - * sent, rather than stated by the caller directly. Bit 3 of - * {@link #getFlags()}. Both are legitimate ways to arrive at a usage, + * sent, rather than stated by the caller directly. This is bit 3 of + * the Flags byte. Both are legitimate ways to arrive at a usage, * and this says nothing about which usage it is. * * @return whether the usage came from a consent string @@ -457,20 +440,6 @@ public byte[] getMatchKey() { return matchKey.clone(); } - /** - * Deprecated alias for {@link #getMatchKey()}. The stable, comparable - * part of a 51Did is now called the match key, mirroring the Model Terms - * for Marketing vocabulary. This alias will be removed in a future - * release. - * - * @return the same bytes as {@link #getMatchKey()} - * @deprecated renamed to {@link #getMatchKey()} - */ - @Deprecated - public byte[] getHash() { - return getMatchKey(); - } - /** @return the OWID version. */ public Version getVersion() { return owid.getVersion(); @@ -482,8 +451,10 @@ public String getDomain() { } /** - * The envelope's creation date, to the minute. See - * {@link #getDateMinutes()} for the same date as the envelope stores it. + * The envelope's creation date, to the minute. The envelope stores it + * as a count of minutes since 2020-01-01T00:00:00Z, and this reader + * hands back the date itself rather than that count, because two dates + * compare exactly as well as two counts do. * * @return the OWID creation date */ @@ -491,20 +462,6 @@ public Instant getDate() { return owid.getDate(); } - /** - * The envelope's own date field, the unsigned 32-bit count of minutes - * since 2020-01-01T00:00:00Z. It is the value the OWID - * {@code public-key?date=} parameter takes, and the integer a caller - * comparing creation times wants rather than a converted date. - * - * @return minutes since 2020-01-01T00:00:00Z, 0 to 4294967295 - */ - public long getDateMinutes() { - return Duration.between( - Instant.ofEpochSecond(DATE_ORIGIN_EPOCH_SECONDS), - owid.getDate()).toMinutes(); - } - /** @return a copy of the OWID payload bytes. */ public byte[] getPayload() { return owid.getPayload(); diff --git a/pipeline.did/src/main/java/fiftyone/pipeline/did/Usage.java b/pipeline.did/src/main/java/fiftyone/pipeline/did/Usage.java index 3a5d070a4..7f324b82b 100644 --- a/pipeline.did/src/main/java/fiftyone/pipeline/did/Usage.java +++ b/pipeline.did/src/main/java/fiftyone/pipeline/did/Usage.java @@ -23,8 +23,8 @@ package fiftyone.pipeline.did; /** - * The usage a 51Did was created for, carried in bits 0-2 of - * {@link FodId#getFlags()}. It decides where the identifier may go: one + * The usage a 51Did was created for, carried in bits 0-2 of the Flags + * byte. It decides where the identifier may go, so one * created for {@link #NON_MARKETING} must never be passed to a demand * source, and one created for {@link #STANDARD} or {@link #PERSONALIZED} * may be passed only to a recipient that has accepted the applicable 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 ff207546a..1bca88b54 100644 --- a/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java +++ b/pipeline.did/src/test/java/fiftyone/pipeline/did/FodIdTests.java @@ -201,27 +201,6 @@ public void matchKey_IsDefensiveCopy() throws Exception { assertArrayEquals(CANONICAL_MATCH_KEY, fodId.getMatchKey()); } - @Test - @SuppressWarnings("deprecation") - public void getHash_DeprecatedAlias_ReturnsMatchKey() throws Exception { - // getHash() stays as a deprecated alias so that callers written - // against the earlier name keep compiling and get the same bytes. - FodId fodId = FodId.fromBase64(factory.signedOwidBase64(canonicalPayload())); - - assertArrayEquals(fodId.getMatchKey(), fodId.getHash()); - assertArrayEquals(CANONICAL_MATCH_KEY, fodId.getHash()); - } - - @Test - @SuppressWarnings("deprecation") - public void hashConstants_DeprecatedAliases_MatchNewNames() { - // HASH_OFFSET and HASH_LENGTH stay as deprecated aliases so that - // callers written against the earlier names keep compiling and read - // the same values as the match key constants they now point at. - assertEquals(FodId.MATCH_KEY_OFFSET, FodId.HASH_OFFSET); - assertEquals(FodId.MATCH_KEY_LENGTH, FodId.HASH_LENGTH); - } - @Test public void constructor_PayloadOneByteShort_Throws() throws Exception { // 36 bytes - one short of the minimum 37 (flags 0 -> Probabilistic). @@ -538,27 +517,35 @@ public void asBase64Url_RoundTrips() throws Exception { } @Test - public void dateMinutes_IsTheEnvelopeDateField() throws Exception { + public void date_ReadsTheEnvelopeDateField() throws Exception { Instant date = Instant.parse("2026-01-01T00:00:00Z"); FodId fodId = factory.fodIdAt(canonicalPayload(), date); - // 2020 through 2025 is 2192 days, 2020 and 2024 being leap years. - assertEquals(2192L * 24 * 60, fodId.getDateMinutes()); - assertEquals(3_156_480L, fodId.getDateMinutes()); + // The envelope stores the date as minutes since 2020-01-01, and the + // factory writes exactly that field, so reading the date back is + // reading the field. 2020 through 2025 is 2192 days, 2020 and 2024 + // being leap years, which is 3,156,480 minutes. assertEquals(date, fodId.getDate()); + assertEquals(FodIdTestFactory.DATE_ORIGIN.plus( + Duration.ofMinutes(2192L * 24 * 60)), fodId.getDate()); + assertEquals(FodIdTestFactory.DATE_ORIGIN.plus( + Duration.ofMinutes(3_156_480L)), fodId.getDate()); } @Test - public void dateMinutes_HighBitStaysUnsigned() throws Exception { + public void date_HighBitOfTheMinutesFieldStaysUnsigned() + throws Exception { // 0x80000000 minutes after 2020 is the year 6103, inside the uint32 - // range the envelope stores, and must not read back negative. + // range the envelope stores. Were the field read as signed the date + // would come back before 2020 instead. Instant date = FodIdTestFactory.DATE_ORIGIN.plus( Duration.ofMinutes(0x80000000L)); FodId fodId = factory.fodIdAt(canonicalPayload(), date); - assertEquals(0x80000000L, fodId.getDateMinutes()); + assertEquals(date, fodId.getDate()); + assertTrue(fodId.getDate().isAfter(FodIdTestFactory.DATE_ORIGIN)); } @Test