From 74a8ea3f5bda24f681e77193eafa4aeece2c5f68 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 15:20:26 +0100 Subject: [PATCH 1/3] Rename FodId.hash to FodId.match_key with a deprecated alias The stable, comparable part of a 51Did, being the payload bytes after the flags and licence id, is called the match key in the Model Terms for Marketing and in the patent, so the reader now uses the same word. FodId.match_key is the new property. FodId.hash stays as an alias that returns the same bytes and warns with DeprecationWarning, and will be removed in a future release. The internal field, the payload reader's locals and the length helper move to match key naming as well. HASH_OFFSET and HASH_LENGTH keep their names because the SHA-256 wording behind them is still true, and their docstrings now describe the match key field. The package docstring and the PyPI description say match key. --- fiftyone_pipeline_did/setup.py | 2 +- .../src/fiftyone_pipeline_did/__init__.py | 6 +- .../src/fiftyone_pipeline_did/did_client.py | 4 +- .../src/fiftyone_pipeline_did/fod_id.py | 108 +++++++++++------- 4 files changed, 72 insertions(+), 48 deletions(-) diff --git a/fiftyone_pipeline_did/setup.py b/fiftyone_pipeline_did/setup.py index 63661d2..07ad7c9 100644 --- a/fiftyone_pipeline_did/setup.py +++ b/fiftyone_pipeline_did/setup.py @@ -42,7 +42,7 @@ def read(file_name): author="51Degrees Engineering", author_email="engineering@51degrees.com", url="https://51degrees.com/?utm_source=pypi&utm_medium=package&utm_campaign=pipeline-python&utm_content=fiftyone_pipeline_did-setup.py&utm_term=url", - description=("Strongly typed reader and cloud client for the 51Did (51Degrees Identifier) value returned by the 51Degrees Cloud service. Parses the OWID envelope in either base64 alphabet and exposes the Flags, License Id and value (Hash) plus the identifier type, and verifies a 51Did's signature offline or through the cloud and redeems a sealed creator context result on the server. Compare values, never envelopes."), + description=("Strongly typed reader and cloud client for the 51Did (51Degrees Identifier) value returned by the 51Degrees Cloud service. Parses the OWID envelope in either base64 alphabet and exposes the Flags, License Id and match key plus the identifier type, and verifies a 51Did's signature offline or through the cloud and redeems a sealed creator context result on the server. Compare match keys, never envelopes."), long_description=read("readme.md"), long_description_content_type='text/markdown', python_requires=">=3.9", diff --git a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py index 8d609a0..e071af7 100644 --- a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py +++ b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py @@ -25,14 +25,14 @@ :class:`~fiftyone_pipeline_did.fod_id.FodId` parses a 51Did from its base64 OWID form in either alphabet, exposes the three payload fields (Flags, -License Id and the value Hash) and the identifier +License Id and the match key) and the identifier :class:`~fiftyone_pipeline_did.id_type.IdType`, and delegates OWID-level concerns to the wrapped envelope. ``FodId.try_from_base64`` and ``FodId.try_from_byte_array`` read external data without raising and answer with a :class:`~fiftyone_pipeline_did.fod_id.FodIdParseResult` naming the :class:`~fiftyone_pipeline_did.fod_id.FodIdParseStatus` either way. Parsing -never checks the signature. Compare 51Dids by their value (``hash``), never -by their envelopes. +never checks the signature. Compare 51Dids by their match key +(``match_key``), never by their envelopes. :class:`~fiftyone_pipeline_did.did_client.DidClient` handles every manipulation of a 51Did a server needs against the 51Degrees cloud: the diff --git a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/did_client.py b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/did_client.py index b523424..39f5b8d 100644 --- a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/did_client.py +++ b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/did_client.py @@ -763,9 +763,9 @@ def _payload_length_valid(fod_id: FodId) -> bool: identifier. Anything beyond the base is a creator context section, whose exact lengths belong to the cloud, so any longer payload is accepted here.""" - value_length = FodId.GUID_LENGTH if fod_id.type is IdType.RANDOM \ + match_key_length = FodId.GUID_LENGTH if fod_id.type is IdType.RANDOM \ else FodId.HASH_LENGTH - return len(fod_id.payload) >= FodId.HEADER_LENGTH + value_length + return len(fod_id.payload) >= FodId.HEADER_LENGTH + match_key_length def _in_force_at(keys: List[PublicKeyEntry], diff --git a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/fod_id.py b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/fod_id.py index 57190df..16f1941 100644 --- a/fiftyone_pipeline_did/src/fiftyone_pipeline_did/fod_id.py +++ b/fiftyone_pipeline_did/src/fiftyone_pipeline_did/fod_id.py @@ -25,6 +25,7 @@ from datetime import datetime, timezone from enum import Enum from typing import NamedTuple, Optional, Tuple +import warnings from ._owid import ( Owid, @@ -91,9 +92,10 @@ class FodIdParseStatus(Enum): #: header (flags and licence id), so the identifier type cannot even be #: read. PAYLOAD_TOO_SHORT = "PayloadTooShort" - #: The header was read and names a type whose value needs more bytes - #: than the payload holds, being 16 GUID bytes after the header for - #: Random and 32 hash bytes for Probabilistic and HashedEmail. + #: The header was read and names a type whose match key needs more + #: bytes than the payload holds, being a 16 byte GUID match key after + #: the header for Random and a 32 byte SHA-256 match key for + #: Probabilistic and HashedEmail. INVALID_TYPE_PAYLOAD_LENGTH = "InvalidTypePayloadLength" @classmethod @@ -141,18 +143,18 @@ class FodId: whole. The **envelope** is the signed :class:`~fiftyone_pipeline_did.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 :attr:`hash`. Two 51Dids for the same inputs - share the same value even though their envelopes differ. *Compare values, - never envelopes.* + The **match key** is the stable, comparable part of the payload after + the Flags and License Id, exposed as :attr:`match_key`. Two 51Dids for + the same inputs share the same match key even though their envelopes + differ. *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 :class:`IdType` and the length of the - value that follows (32-byte SHA-256 for Probabilistic and HashedEmail, or - 16 GUID bytes for Random). A payload longer than the header and value is - accepted, because the bytes after the value are a creator context - section whose lengths belong to the cloud, so this package places no - upper bound on a payload or an envelope. + match key that follows (32-byte SHA-256 for Probabilistic and + HashedEmail, or 16 GUID bytes for Random). A payload longer than the + header and match key is accepted, because the bytes after the match key + are a creator context section whose lengths belong to the cloud, so + this package places no upper bound on a payload or an envelope. Reading and verifying are separate steps. :meth:`try_from_base64` and :meth:`try_from_byte_array` read external data without raising and @@ -172,13 +174,13 @@ class FodId: LICENSE_ID_OFFSET = 1 #: Byte length of the License Id field. LICENSE_ID_LENGTH = 4 - #: Byte offset of the value (Hash) field within the payload. + #: Byte offset of the match key field within the payload. HASH_OFFSET = 5 - #: Byte length of the SHA-256 value. + #: Byte length of the match key field (SHA-256). HASH_LENGTH = 32 #: Byte length of the header (Flags + License Id) common to every type. HEADER_LENGTH = HASH_OFFSET - #: Byte length of the GUID value carried by Random identifiers. + #: Byte length of the GUID match key carried by Random identifiers. GUID_LENGTH = 16 #: Minimum byte length of a Random 51Did payload. RANDOM_PAYLOAD_LENGTH = HEADER_LENGTH + GUID_LENGTH @@ -209,19 +211,19 @@ def __init__(self, owid: Owid) -> None: self._assign(read.owid, *_unpack_or_raise(read.owid.payload)) def _assign(self, owid: Owid, flags: int, license_id: int, - value: bytes) -> None: + match_key: bytes) -> None: self._owid = owid self._flags = flags self._license_id = license_id - self._hash = value + self._match_key = match_key @classmethod def _build(cls, owid: Owid, flags: int, license_id: int, - value: bytes) -> "FodId": + match_key: bytes) -> "FodId": """An identifier over fields :func:`_read_payload` has already checked, so the constructor's read is not repeated.""" fod_id = cls.__new__(cls) - fod_id._assign(owid, flags, license_id, value) + fod_id._assign(owid, flags, license_id, match_key) return fod_id @classmethod @@ -231,11 +233,12 @@ def _from_read(cls, read: ParseResult) -> FodIdParseResult: builds the identifier only when both have passed.""" if not read.ok: return _failed(FodIdParseStatus.of(read.status)) - status, flags, license_id, value = _read_payload(read.owid.payload) + status, flags, license_id, match_key = _read_payload( + read.owid.payload) if status is not FodIdParseStatus.PARSED: return _failed(status) return FodIdParseResult( - True, cls._build(read.owid, flags, license_id, value), + True, cls._build(read.owid, flags, license_id, match_key), FodIdParseStatus.PARSED) @classmethod @@ -378,14 +381,34 @@ def license_id(self) -> int: """ return self._license_id + @property + def match_key(self) -> bytes: + """The match key from the payload, a 32-byte SHA-256 for + Probabilistic and HashedEmail identifiers, or 16 GUID bytes for + Random ones. + + The match key is the stable, comparable part of the envelope. Two + 51Dids for the same inputs share the same match key even though + their envelopes (date, signature) differ on every issue. Use the + match key as the cache key and as the key for spotting duplicates. + """ + return self._match_key + @property def hash(self) -> bytes: - """The value bytes (a 32-byte SHA-256, or 16 GUID bytes for Random). + """Deprecated alias for :attr:`match_key`. - This is the stable, comparable part of the envelope - use it as the - cache / dedup key. + The stable, comparable part of a 51Did is now called the match key, + mirroring the Model Terms for Marketing vocabulary. Reading this + property warns with :class:`DeprecationWarning` and returns the + same bytes as :attr:`match_key`. The alias will be removed in a + future release. """ - return self._hash + warnings.warn( + "FodId.hash is renamed to FodId.match_key. This alias will be " + "removed in a future release.", + DeprecationWarning, stacklevel=2) + return self._match_key @property def version(self) -> Version: @@ -476,20 +499,20 @@ def _read_payload(payload: bytes) -> Tuple[FodIdParseStatus, int, int, bytes]: """Applies the two 51Did payload rules and unpacks the three fields. The header must be present before the type can be read, and the type - then says how many value bytes must follow. Anything beyond the value is - a creator context section whose lengths belong to the cloud, so a longer - payload passes. A Reserved type has no known value length and keeps the - documented best-effort reading, being the header fields and whatever - bytes follow. + then says how many match key bytes must follow. Anything beyond the + match key is a creator context section whose lengths belong to the + cloud, so a longer payload passes. A Reserved type has no known match + key length and keeps the documented best-effort reading, being the + header fields and whatever bytes follow. Returns the status and, on success, the flags, the licence id and the - value bytes. On failure the three fields are zero and empty. + match key bytes. On failure the three fields are zero and empty. """ if payload is None or len(payload) < FodId.HEADER_LENGTH: return FodIdParseStatus.PAYLOAD_TOO_SHORT, 0, 0, b"" flags = payload[FodId.FLAGS_OFFSET] - value_length = _value_length(IdType.from_flags(flags), payload) - if len(payload) < FodId.HEADER_LENGTH + value_length: + match_key_length = _match_key_length(IdType.from_flags(flags), payload) + if len(payload) < FodId.HEADER_LENGTH + match_key_length: return FodIdParseStatus.INVALID_TYPE_PAYLOAD_LENGTH, 0, 0, b"" # Little-endian uint32, unsigned (Python ints are unbounded and # non-negative here, so the high bit never becomes negative). @@ -499,23 +522,24 @@ def _read_payload(payload: bytes) -> Tuple[FodIdParseStatus, int, int, bytes]: byteorder="little", signed=False, ) - # bytes is immutable, so slicing yields a value that cannot be used to - # change the underlying payload and no defensive copy is required. - value = bytes(payload[FodId.HASH_OFFSET:FodId.HASH_OFFSET + value_length]) - return FodIdParseStatus.PARSED, flags, license_id, value + # bytes is immutable, so slicing yields a match key that cannot be used + # to change the underlying payload and no defensive copy is required. + match_key = bytes( + payload[FodId.HASH_OFFSET:FodId.HASH_OFFSET + match_key_length]) + return FodIdParseStatus.PARSED, flags, license_id, match_key def _unpack_or_raise(payload: bytes) -> Tuple[int, int, bytes]: """The payload rules for the raising readers, with the messages they have always given.""" - status, flags, license_id, value = _read_payload(payload) + status, flags, license_id, match_key = _read_payload(payload) if status is not FodIdParseStatus.PARSED: raise ValueError(_payload_message(status, payload)) - return flags, license_id, value + return flags, license_id, match_key -def _value_length(id_type: IdType, payload: bytes) -> int: - """How many value bytes the type needs after the header.""" +def _match_key_length(id_type: IdType, payload: bytes) -> int: + """How many match key bytes the type needs after the header.""" if id_type is IdType.RANDOM: return FodId.GUID_LENGTH if id_type is IdType.RESERVED: @@ -533,5 +557,5 @@ def _payload_message(status: FodIdParseStatus, payload: bytes) -> str: return ("51Did payload for the {0} type must be at least {1} bytes; " "got {2}.".format( id_type.name, - FodId.HEADER_LENGTH + _value_length(id_type, payload), + FodId.HEADER_LENGTH + _match_key_length(id_type, payload), length)) From 2b36d6c4802c720226687d30f818b7acea48c16b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 15:20:27 +0100 Subject: [PATCH 2/3] Move the readme and the offline example to the match key vocabulary The terminology section, the payload layout tables, the usage and comparison snippets and the guarantees now say match key, and the usage section notes that hash remains as a deprecated alias of match_key. The offline example prints and compares the match key. --- .../examples/fodid_example.py | 16 ++++--- fiftyone_pipeline_did/readme.md | 44 +++++++++++-------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/fiftyone_pipeline_did/examples/fodid_example.py b/fiftyone_pipeline_did/examples/fodid_example.py index a98fc12..c9e7e17 100644 --- a/fiftyone_pipeline_did/examples/fodid_example.py +++ b/fiftyone_pipeline_did/examples/fodid_example.py @@ -27,7 +27,8 @@ ECDSA P-256 key pair, sign a canonical 37-byte payload - then parses it back and prints the three payload fields. It also shows 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. +changes), but the match key is stable. Compare match keys, never +envelopes. """ from owid import Crypto, Creator @@ -39,7 +40,7 @@ def sample_payload(): """A canonical 37-byte Probabilistic payload: flags 0x00, License Id - 0x12345678 (little-endian) and a 32-byte value 0x20..0x3F.""" + 0x12345678 (little-endian) and a 32-byte match key 0x20..0x3F.""" payload = bytearray(FodId.PAYLOAD_LENGTH) payload[FodId.FLAGS_OFFSET] = 0x00 payload[FodId.LICENSE_ID_OFFSET:FodId.LICENSE_ID_OFFSET + 4] = \ @@ -70,21 +71,22 @@ def run(): print(" Type :", fod_id.type.name) print(" Flags : 0x{:02x}".format(fod_id.flags)) print(" LicenseId :", fod_id.license_id) - print(" Hash :", fod_id.hash.hex()) + print(" Match key :", fod_id.match_key.hex()) print(" Verifies :", fod_id.verify(crypto.public_key_pem())) reissued = FodId.from_base64(issue(creator, payload)) same_envelope = fod_id.as_base64() == reissued.as_base64() - same_value = fod_id.hash == reissued.hash + same_match_key = fod_id.match_key == reissued.match_key print() print("Same payload, re-issued:") print(" Same envelope (base64) :", same_envelope) - print(" Same value (Hash) :", same_value) + print(" Same match key :", same_match_key) - if same_envelope or not same_value: + if same_envelope or not same_match_key: raise AssertionError( - "Expected a different envelope but the same value across reissues.") + "Expected a different envelope but the same match key across " + "reissues.") if __name__ == "__main__": diff --git a/fiftyone_pipeline_did/readme.md b/fiftyone_pipeline_did/readme.md index d0a80d9..a49ad94 100644 --- a/fiftyone_pipeline_did/readme.md +++ b/fiftyone_pipeline_did/readme.md @@ -10,11 +10,13 @@ Identifier) returned by the 51Degrees Cloud service. Mirrors the .NET - 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. -- 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. +- The **match key** is the stable, comparable part of the payload after + the Flags and License Id, being 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 match key even though their envelopes differ. -**Comparing two 51Dids means comparing their values, never their envelopes.** +**Comparing two 51Dids means comparing their match keys, never their +envelopes.** ## Payload layout @@ -22,10 +24,10 @@ Identifier) returned by the 51Degrees Cloud service. Mirrors the .NET |-------:|-------:|------------|-------------------------------------------------| | 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) | +| 5 | 16/32 | Match key | SHA-256 (Probabilistic, HashedEmail) or GUID (Random) | -| Bits 7-6 | `IdType` | Value length | Minimum payload | -|---------:|-----------------|-------------:|----------------:| +| Bits 7-6 | `IdType` | Match key length | Minimum payload | +|---------:|-----------------|-----------------:|----------------:| | `00` | `PROBABILISTIC` | 32 | 37 | | `01` | `RANDOM` | 16 | 21 | | `10` | `HASHED_EMAIL` | 32 | 37 | @@ -81,7 +83,7 @@ fod_id = FodId.from_base64(base64_from_cloud_service) # either alphabet flags = fod_id.flags type_ = fod_id.type # IdType.PROBABILISTIC / RANDOM / HASHED_EMAIL license_id = fod_id.license_id -value = fod_id.hash # SHA-256 or GUID bytes, see type +match_key = fod_id.match_key # SHA-256 or GUID bytes, see type # Delegated OWID-level fields and operations. domain = fod_id.domain @@ -98,6 +100,10 @@ encrypted value that only 51Degrees can turn back into a licence identifier, so `license_id` is the field's raw value and identifies nothing outside 51Degrees. +`fod_id.hash` remains as a deprecated alias of `match_key`. Reading the +alias returns the same bytes and warns with `DeprecationWarning`, and the +alias will be removed in a future release, so move callers to `match_key`. + ## Parsing without exceptions An identifier arriving from outside, in a query string, a header or a @@ -158,21 +164,21 @@ same whichever language parsed the bytes. | `ABSENT_NODE` | The version 0 marker, which stands for an absent envelope | | `MALFORMED_ENVELOPE` | Malformed in a way none of the above describes | | `PAYLOAD_TOO_SHORT` | The envelope was read but the payload is shorter than the 5 byte header, so the type cannot be read | -| `INVALID_TYPE_PAYLOAD_LENGTH` | The header names a type whose value needs more bytes than the payload holds | +| `INVALID_TYPE_PAYLOAD_LENGTH` | The header names a type whose match key needs more bytes than the payload holds | ### Lower bounds and no upper bound The payload must hold the 5 byte header before the type can be read, and -the type then says how many value bytes must follow, being 16 for +the type then says how many match key bytes must follow, being 16 for `RANDOM` and 32 for `PROBABILISTIC` and `HASHED_EMAIL`, as the payload layout table above shows. `RESERVED` keeps the best-effort reading, being -the header fields and whatever bytes follow. Anything beyond the value is -a creator context section whose lengths belong to the cloud, so a longer -payload, a longer creator domain (a self-hosted container may sign with -one) or a longer envelope is accepted and this package places no upper -bound of its own on any of them. An older reader meeting a context +the header fields and whatever bytes follow. Anything beyond the match key +is a creator context section whose lengths belong to the cloud, so a +longer payload, a longer creator domain (a self-hosted container may sign +with one) or a longer envelope is accepted and this package places no +upper bound of its own on any of them. An older reader meeting a context section of a version it does not know still reads the header and the -value. +match key. `DidClient` refuses text longer than 4096 characters before it parses it, fetches a key or calls the cloud. That figure is client policy, @@ -234,8 +240,8 @@ a = FodId.from_base64(idprobglobal_a) b = FodId.from_base64(idprobglobal_b) # The envelope (date, signature, base64) differs across reissues. -# The value inside the payload is stable - this is what you compare: -same_value = a.hash == b.hash +# The match key inside the payload is stable, so compare match keys: +same_match_key = a.match_key == b.match_key ``` ## Verifying on your server @@ -491,7 +497,7 @@ is refreshed by common-ci's `update-example-assets` step. be genuine. Call `verify(public_key_pem)`, `signature_status(public_key_pem)` or a `DidClient` check when needed. - **No upper bound on the size of an identifier.** The lengths beyond the - header and value belong to the cloud. The 4096 character figure in + header and match key belong to the cloud. The 4096 character figure in `DidClient` is client policy against obviously malformed text, not a format limit. - **No creation of new 51Dids.** This is a parser; new 51Dids are issued by the From 7b4a99ef7c38b7f7b3c175b94554097b4738a532 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Mon, 31 Aug 2026 15:20:28 +0100 Subject: [PATCH 3/3] Update the tests to match_key and cover the deprecated hash alias Every assertion on the stable part of a 51Did reads match_key, and the canonical test bytes are named as the match key. One new test reads the deprecated hash alias, asserts the same bytes come back and that a DeprecationWarning is raised. --- fiftyone_pipeline_did/tests/test_fodid.py | 75 +++++++++++++---------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/fiftyone_pipeline_did/tests/test_fodid.py b/fiftyone_pipeline_did/tests/test_fodid.py index 048c3be..cf04beb 100644 --- a/fiftyone_pipeline_did/tests/test_fodid.py +++ b/fiftyone_pipeline_did/tests/test_fodid.py @@ -42,7 +42,7 @@ # 0xA5: usage bits plus the HashedEmail type tag in bits 6-7. CANONICAL_FLAGS = 0xA5 CANONICAL_LICENSE_ID = 0x12345678 -CANONICAL_HASH = bytes((0x20 + i) for i in range(FodId.HASH_LENGTH)) +CANONICAL_MATCH_KEY = bytes((0x20 + i) for i in range(FodId.HASH_LENGTH)) #: A creator domain longer than the one the cloud signs with, as a #: self-hosted container may be configured to use. @@ -62,7 +62,7 @@ def canonical_payload(): payload[FodId.FLAGS_OFFSET] = CANONICAL_FLAGS _write_license_id(payload) payload[FodId.HASH_OFFSET:FodId.HASH_OFFSET + FodId.HASH_LENGTH] = \ - CANONICAL_HASH + CANONICAL_MATCH_KEY return bytearray(payload) @@ -123,7 +123,7 @@ def test_from_base64_unpacks_all_three_fields(self): self.factory.signed_owid_base64(canonical_payload())) self.assertEqual(CANONICAL_FLAGS, fod.flags) self.assertEqual(CANONICAL_LICENSE_ID, fod.license_id) - self.assertEqual(CANONICAL_HASH, fod.hash) + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) self.assertEqual(TEST_DOMAIN, fod.domain) def test_from_byte_array_unpacks_all_three_fields(self): @@ -131,7 +131,7 @@ def test_from_byte_array_unpacks_all_three_fields(self): fod = FodId.from_byte_array(buffer) self.assertEqual(CANONICAL_FLAGS, fod.flags) self.assertEqual(CANONICAL_LICENSE_ID, fod.license_id) - self.assertEqual(CANONICAL_HASH, fod.hash) + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) self.assertEqual(TEST_DOMAIN, fod.domain) def test_from_owid_unpacks_all_three_fields(self): @@ -139,7 +139,7 @@ def test_from_owid_unpacks_all_three_fields(self): fod = FodId.from_owid(owid) self.assertEqual(CANONICAL_FLAGS, fod.flags) self.assertEqual(CANONICAL_LICENSE_ID, fod.license_id) - self.assertEqual(CANONICAL_HASH, fod.hash) + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) self.assertEqual(owid.domain, fod.domain) self.assertEqual(owid.date, fod.date) self.assertEqual(owid.version, fod.version) @@ -183,14 +183,25 @@ def test_flags_all_bits_set_exposed(self): fod = FodId.from_base64(self.factory.signed_owid_base64(payload)) self.assertEqual(255, fod.flags) - def test_hash_is_immutable_value(self): + def test_match_key_is_immutable(self): fod = FodId.from_base64( self.factory.signed_owid_base64(canonical_payload())) - self.assertEqual(CANONICAL_HASH, fod.hash) - self.assertIsInstance(fod.hash, bytes) - # bytes is immutable, so the value cannot be used to mutate the OWID. + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) + self.assertIsInstance(fod.match_key, bytes) + # bytes is immutable, so the match key cannot be used to mutate the + # OWID. with self.assertRaises(TypeError): - fod.hash[0] = 0x00 + fod.match_key[0] = 0x00 + + def test_deprecated_hash_alias_returns_match_key_and_warns(self): + # The stable, comparable part of a 51Did is now called the match + # key. The old name stays for a release as an alias that warns. + fod = FodId.from_base64( + self.factory.signed_owid_base64(canonical_payload())) + with self.assertWarns(DeprecationWarning): + alias = fod.hash + self.assertEqual(fod.match_key, alias) + self.assertEqual(CANONICAL_MATCH_KEY, alias) def test_payload_one_byte_short_raises(self): base64 = self.factory.signed_owid_base64( @@ -223,8 +234,8 @@ def test_payload_larger_than_spec_uses_first_37_bytes(self): fod = FodId.from_base64(self.factory.signed_owid_base64(payload)) self.assertEqual(CANONICAL_FLAGS, fod.flags) self.assertEqual(CANONICAL_LICENSE_ID, fod.license_id) - self.assertEqual(CANONICAL_HASH, fod.hash) - self.assertEqual(FodId.HASH_LENGTH, len(fod.hash)) + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) + self.assertEqual(FodId.HASH_LENGTH, len(fod.match_key)) def test_long_envelope_parses_and_keeps_the_header_fields(self): # No upper bound belongs in the reader: a creator domain is a @@ -238,8 +249,8 @@ def test_long_envelope_parses_and_keeps_the_header_fields(self): self.assertEqual(LONG_DOMAIN, fod.domain) self.assertEqual(CANONICAL_FLAGS, fod.flags) self.assertEqual(CANONICAL_LICENSE_ID, fod.license_id) - self.assertEqual(CANONICAL_HASH, fod.hash) - self.assertEqual(FodId.HASH_LENGTH, len(fod.hash)) + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) + self.assertEqual(FodId.HASH_LENGTH, len(fod.match_key)) def test_is_cryptographically_verifiable(self): fod = FodId.from_base64( @@ -254,7 +265,7 @@ def test_base64_roundtrip_preserves_all_fields(self): fod2 = FodId.from_base64(fod1.as_base64()) self.assertEqual(fod1.flags, fod2.flags) self.assertEqual(fod1.license_id, fod2.license_id) - self.assertEqual(fod1.hash, fod2.hash) + self.assertEqual(fod1.match_key, fod2.match_key) self.assertEqual(fod1.domain, fod2.domain) # ----- Type model ----- @@ -279,9 +290,9 @@ def test_random_payload_21_bytes_parses(self): fod = FodId.from_base64( self.factory.signed_owid_base64(canonical_random_payload())) self.assertEqual(CANONICAL_LICENSE_ID, fod.license_id) - self.assertEqual(FodId.GUID_LENGTH, len(fod.hash)) + self.assertEqual(FodId.GUID_LENGTH, len(fod.match_key)) self.assertEqual(bytes((0x40 + i) for i in range(FodId.GUID_LENGTH)), - fod.hash) + fod.match_key) def test_random_payload_one_byte_short_raises(self): payload = canonical_random_payload()[:FodId.RANDOM_PAYLOAD_LENGTH - 1] @@ -296,7 +307,7 @@ def test_random_payload_larger_than_spec_uses_first_16_value_bytes(self): payload[i] = 0xCC fod = FodId.from_base64(self.factory.signed_owid_base64(payload)) self.assertEqual(IdType.RANDOM, fod.type) - self.assertEqual(FodId.GUID_LENGTH, len(fod.hash)) + self.assertEqual(FodId.GUID_LENGTH, len(fod.match_key)) def test_hashed_email_payload_one_byte_short_raises(self): payload = canonical_payload()[:FodId.PAYLOAD_LENGTH - 1] @@ -309,13 +320,13 @@ def test_reserved_header_only_parses(self): payload[FodId.FLAGS_OFFSET] = 0b1100_0000 fod = FodId.from_base64(self.factory.signed_owid_base64(payload)) self.assertEqual(IdType.RESERVED, fod.type) - self.assertEqual(0, len(fod.hash)) + self.assertEqual(0, len(fod.match_key)) # ----- Gap tests (runbook section 6b) ----- def test_compare_two_51dids_same_payload(self): - # Two reissues of the same value at different times: the envelope - # differs and the value inside is the same. + # Two reissues of the same payload at different times. The envelope + # differs and the match key inside is the same. payload = canonical_payload() a = signed_envelope( self.factory.crypto, payload, @@ -327,7 +338,7 @@ def test_compare_two_51dids_same_payload(self): fa = FodId.from_base64(a.as_base64()) fb = FodId.from_base64(b.as_base64()) - self.assertEqual(fa.hash, fb.hash) # value is stable + self.assertEqual(fa.match_key, fb.match_key) # match key is stable self.assertNotEqual(fa.date, fb.date) # envelope differs self.assertNotEqual(fa.signature, fb.signature) self.assertNotEqual(a.as_base64(), b.as_base64()) @@ -341,7 +352,7 @@ def test_construction_does_not_verify(self): fod = FodId.from_byte_array(bytes(raw)) self.assertEqual(CANONICAL_FLAGS, fod.flags) self.assertEqual(CANONICAL_LICENSE_ID, fod.license_id) - self.assertEqual(CANONICAL_HASH, fod.hash) + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) self.assertFalse(fod.verify(self.factory.public_pem)) def test_source_envelope_cannot_be_changed_after_construction(self): @@ -355,7 +366,7 @@ def test_source_envelope_cannot_be_changed_after_construction(self): owid.payload = bytes(FodId.PAYLOAD_LENGTH) with self.assertRaises(AttributeError): owid.signature = bytes(64) - self.assertEqual(CANONICAL_HASH, fod.hash) + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) self.assertEqual(0x20, fod.payload[FodId.HASH_OFFSET]) def test_constructor_reads_the_envelope_back_through_the_parser(self): @@ -365,7 +376,7 @@ def test_constructor_reads_the_envelope_back_through_the_parser(self): fod = FodId(owid) self.assertEqual(owid.as_byte_array(), fod.as_byte_array()) self.assertEqual(CANONICAL_FLAGS, fod.flags) - self.assertEqual(CANONICAL_HASH, fod.hash) + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) def test_verify_with_wrong_key_returns_false(self): fod = FodId.from_base64( @@ -381,7 +392,7 @@ def test_roundtrip_through_bytes_constructor_preserves_all_fields(self): fod2 = FodId.from_byte_array(fod1.as_byte_array()) self.assertEqual(fod1.flags, fod2.flags) self.assertEqual(fod1.license_id, fod2.license_id) - self.assertEqual(fod1.hash, fod2.hash) + self.assertEqual(fod1.match_key, fod2.match_key) self.assertEqual(fod1.domain, fod2.domain) @@ -418,7 +429,7 @@ def assert_failed(self, result, status): def assert_canonical(self, fod): self.assertEqual(CANONICAL_FLAGS, fod.flags) self.assertEqual(CANONICAL_LICENSE_ID, fod.license_id) - self.assertEqual(CANONICAL_HASH, fod.hash) + self.assertEqual(CANONICAL_MATCH_KEY, fod.match_key) # ----- Vocabulary ----- @@ -471,12 +482,12 @@ def test_longer_self_hosted_creator_domain_is_accepted(self): def test_longer_creator_context_section_is_accepted(self): # An older reader meets a context section of a version it does not - # know. The header and value are read and the rest is kept. + # know. The header and match key are read and the rest is kept. payload = bytes(canonical_payload()) + bytes(range(64)) fod = self.assert_parsed(FodId.try_from_base64( self.factory.signed_owid_base64(payload))) self.assert_canonical(fod) - self.assertEqual(FodId.HASH_LENGTH, len(fod.hash)) + self.assertEqual(FodId.HASH_LENGTH, len(fod.match_key)) self.assertEqual(payload, fod.payload) def test_far_longer_payload_is_not_rejected_for_its_length(self): @@ -490,7 +501,7 @@ def test_random_identifier_parses_with_a_sixteen_byte_value(self): fod = self.assert_parsed(FodId.try_from_base64( self.factory.signed_owid_base64(canonical_random_payload()))) self.assertEqual(IdType.RANDOM, fod.type) - self.assertEqual(FodId.GUID_LENGTH, len(fod.hash)) + self.assertEqual(FodId.GUID_LENGTH, len(fod.match_key)) def test_reserved_header_only_parses_best_effort(self): payload = bytearray(FodId.HEADER_LENGTH) @@ -498,7 +509,7 @@ def test_reserved_header_only_parses_best_effort(self): fod = self.assert_parsed(FodId.try_from_base64( self.factory.signed_owid_base64(payload))) self.assertEqual(IdType.RESERVED, fod.type) - self.assertEqual(b"", fod.hash) + self.assertEqual(b"", fod.match_key) def test_success_does_not_verify_the_signature(self): # All zero signature: the shape is right, the signature is not. @@ -662,7 +673,7 @@ def test_raising_and_non_raising_readers_agree_on_success(self): result = FodId.try_from_base64(standard) self.assertEqual(raising.as_byte_array(), result.value.as_byte_array()) - self.assertEqual(raising.hash, result.value.hash) + self.assertEqual(raising.match_key, result.value.match_key) if __name__ == "__main__":