Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions fiftyone_pipeline_did/examples/fodid_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,25 @@

DOMAIN = "51degrees.com"

# Only the cloud issues real 51Dids, so this example writes a sample
# payload by hand and states the layout here rather than reading it
# from the package, which does not publish it. The layout is
# specified at
# https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md
SAMPLE_FLAGS = 0b0000_0011 # standard usage, Probabilistic type
SAMPLE_PAYLOAD_LENGTH = 37 # 1 flags byte, 4 licence id, 32 key
SAMPLE_MATCH_KEY_OFFSET = 5
SAMPLE_MATCH_KEY_LENGTH = 32


def sample_payload():
"""A canonical 37-byte Probabilistic payload: flags 0x00, License Id
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] = \
bytes([0x78, 0x56, 0x34, 0x12])
for i in range(FodId.MATCH_KEY_LENGTH):
payload[FodId.MATCH_KEY_OFFSET + i] = 0x20 + i
"""A canonical 37-byte Probabilistic payload: the flags byte, License
Id 0x12345678 (little-endian) and a 32-byte match key 0x20..0x3F."""
payload = bytearray(SAMPLE_PAYLOAD_LENGTH)
payload[0] = SAMPLE_FLAGS
payload[1:5] = bytes([0x78, 0x56, 0x34, 0x12])
for i in range(SAMPLE_MATCH_KEY_LENGTH):
payload[SAMPLE_MATCH_KEY_OFFSET + i] = 0x20 + i
return bytes(payload)


Expand All @@ -69,7 +78,9 @@ def run():
print("51Did parsed from base64:")
print(" Domain :", fod_id.domain)
print(" Type :", fod_id.type.name)
print(" Flags : 0x{:02x}".format(fod_id.flags))
print(" Usage :", fod_id.usage.name)
print(" Id usage :", fod_id.usage.id_usage)
print(" Consent :", fod_id.usage_from_consent)
print(" LicenseId :", fod_id.license_id)
print(" Match key :", fod_id.match_key.hex())
print(" Verifies :", fod_id.verify(crypto.public_key_pem()))
Expand Down
88 changes: 59 additions & 29 deletions fiftyone_pipeline_did/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,28 @@ envelopes.**

## Payload layout

| 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) |

| Bits 7-6 | `IdType` | Match key length | Minimum payload |
|---------:|-----------------|-----------------:|----------------:|
| `00` | `PROBABILISTIC` | 32 | 37 |
| `01` | `RANDOM` | 16 | 21 |
| `10` | `HASHED_EMAIL` | 32 | 37 |
| `11` | `RESERVED` | remainder | 5 |

Identifiers issued before the type tag existed have bits 6-7 zeroed and decode
as `PROBABILISTIC`.
The bytes a 51Did payload holds, and what each bit of them means, are
specified once for every language in
[identifier-layout.md](https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md),
and the surface each package offers over those bytes 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 this summary
where the two ever disagree.

In short, the payload opens with a header carrying the flags byte and the
License Id, and the identifier type in that byte then fixes the length of
the match key that follows, being 32 bytes for `PROBABILISTIC` and
`HASHED_EMAIL`, 16 for `RANDOM`, and whatever remains for `RESERVED`.
Identifiers issued before the type tag existed decode as `PROBABILISTIC`.

This package does not publish the offsets or the raw flags byte, and it
does not need to, because every field has a typed accessor that reads it
correctly. The usage is the clearest reason why, as its bits are
cumulative rather than exclusive, so a caller masking the byte for the
non-marketing bit alone reads every marketing identifier as non-marketing,
which is exactly backwards for a rule that says a non-marketing identifier
must never be passed to a demand source. `fod_id.usage` answers with the
highest usage granted and that mistake cannot be made.

## OWID dependency

Expand Down Expand Up @@ -76,18 +83,20 @@ way to hold an unsigned or partly built envelope.
## Usage

```python
from fiftyone_pipeline_did import FodId, IdType
from fiftyone_pipeline_did import FodId, IdType, Usage

fod_id = FodId.from_base64(base64_from_cloud_service) # either alphabet

flags = fod_id.flags
type_ = fod_id.type # IdType.PROBABILISTIC / RANDOM / HASHED_EMAIL
usage = fod_id.usage # Usage.NON_MARKETING / STANDARD / PERSONALIZED
from_consent = fod_id.usage_from_consent # True when read from a consent
# string the caller sent
license_id = fod_id.license_id
match_key = fod_id.match_key # SHA-256 or GUID bytes, see type

# Delegated OWID-level fields and operations.
domain = fod_id.domain
minutes = fod_id.date_minutes # the date field: minutes since 2020-01-01Z
created = fod_id.date # aware UTC datetime, to the minute
verified = fod_id.verify(public_key_pem)
base64 = fod_id.as_base64() # standard alphabet, padded, as the cloud
url_safe = fod_id.as_base64_url() # URL-safe alphabet, no padding, for a link
Expand All @@ -100,16 +109,37 @@ 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`.

The class constants naming the match key field follow the same
vocabulary, being `FodId.MATCH_KEY_OFFSET` and `FodId.MATCH_KEY_LENGTH`.
`FodId.HASH_OFFSET` and `FodId.HASH_LENGTH` remain as deprecated aliases
holding the same values, and a class constant cannot warn when it is
read, so move callers to the new names before the aliases are removed in
a future release.
### The usage an identifier was created for

`fod_id.usage` says what the identifier may be used for, as a `Usage`
carrying `NON_MARKETING`, `STANDARD` or `PERSONALIZED`, and `NONE` for an
identifier with no usage bit set, which the cloud never issues. It decides
where the identifier may go, because one created for non-marketing must
never be passed to a demand source, and one created for standard or
personalized marketing may be passed only to a recipient that has accepted
the applicable terms. `usage.id_usage` gives the cloud's own `id.usage`
wording, being `non-marketing`, `standard` or `personalized`, and `None`
for `NONE`.

The three usages are cumulative in the bits that carry them, so every
marketing identifier also carries the non-marketing bit. `fod_id.usage`
answers with the highest usage granted, which is why it is the only
supported way to read the usage and why the package does not hand out the
byte. `fod_id.usage_from_consent` says whether the usage was worked out
from an IAB consent string the caller sent rather than stated by the
caller directly. Both are legitimate ways to arrive at a usage and it says
nothing about which usage was reached.

The raw flags byte, the byte layout constants and the old `hash` names are
not part of this package. `fod_id.flags`, `fod_id.hash`,
`fod_id.date_minutes`, `FodId.MATCH_KEY_OFFSET` and every other offset and
length were removed, in this package and in the .NET, Java, Node, PHP and
Rust ones together, so the surface stays the same in every language. Read
`type`, `usage`, `usage_from_consent`, `license_id`, `match_key` and `date`
instead, which read the same bytes and cannot get the cumulative usage bits
wrong. The layout is still specified, in
[identifier-layout.md](https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md),
for anyone implementing a reader rather than using one.

## Parsing without exceptions

Expand Down Expand Up @@ -178,7 +208,7 @@ same whichever language parsed the bytes.
The payload must hold the 5 byte header before the type can be read, and
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
layout specification says. `RESERVED` keeps the best-effort reading, being
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
Expand Down
17 changes: 13 additions & 4 deletions fiftyone_pipeline_did/src/fiftyone_pipeline_did/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@
Identifier) value.

: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 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
OWID form in either alphabet, exposes a typed accessor for every field it
carries (the identifier :class:`~fiftyone_pipeline_did.id_type.IdType`, the
:class:`~fiftyone_pipeline_did.usage.Usage` it was created for and whether
that usage came from a consent string, the License Id and the match key),
and delegates OWID-level concerns to the wrapped envelope. The raw bytes
and offsets behind those accessors are not part of this surface, which is
specified at
https://github.com/51Degrees/specifications/blob/main/did-specification/package-surface.md
and the layout it reads at
https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md
``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
Expand Down Expand Up @@ -65,12 +72,14 @@
from ._owid import Owid, OwidError, SignatureStatus
from .fod_id import DATE_EPOCH, FodId, FodIdParseResult, FodIdParseStatus
from .id_type import IdType
from .usage import Usage

__all__ = [
"FodId",
"FodIdParseResult",
"FodIdParseStatus",
"IdType",
"Usage",
"DATE_EPOCH",
"DidClient",
"RedeemResult",
Expand Down
63 changes: 63 additions & 0 deletions fiftyone_pipeline_did/src/fiftyone_pipeline_did/_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# *********************************************************************
# 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.
# *********************************************************************

"""The byte layout of a 51Did payload, used inside this package only.

These offsets and lengths are not part of the package's public surface.
The only reason to hold an offset is to read a field by hand, and every
field has a typed accessor on
:class:`~fiftyone_pipeline_did.FodId` that reads it correctly, so a caller
who reaches for the bytes is taking the way that produces wrong answers.
The usage bits are the clearest case, being cumulative rather than
exclusive, so a mask for the non-marketing bit alone reads every marketing
identifier as non-marketing. Use :attr:`~fiftyone_pipeline_did.FodId.usage`
and the other accessors instead.

The layout itself is specified at
https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md
and the surface every 51Did package offers is specified at
https://github.com/51Degrees/specifications/blob/main/did-specification/package-surface.md
which is where a change to either belongs first.

This module is imported by the package's own code and its own tests, which
build payloads byte by byte. It is not exported from the package's
``__init__``.
"""

#: Byte offset of the Flags field within the payload.
FLAGS_OFFSET = 0
#: Byte offset of the License Id field within the payload.
LICENSE_ID_OFFSET = 1
#: Byte length of the License Id field.
LICENSE_ID_LENGTH = 4
#: Byte offset of the match key field within the payload.
MATCH_KEY_OFFSET = 5
#: Byte length of the match key field (SHA-256).
MATCH_KEY_LENGTH = 32
#: Byte length of the header (Flags + License Id) common to every type.
HEADER_LENGTH = MATCH_KEY_OFFSET
#: 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
#: Minimum byte length of a Probabilistic or HashedEmail 51Did payload.
PAYLOAD_LENGTH = MATCH_KEY_OFFSET + MATCH_KEY_LENGTH
16 changes: 10 additions & 6 deletions fiftyone_pipeline_did/src/fiftyone_pipeline_did/did_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@

from ._owid import Version

from .fod_id import DATE_EPOCH, FodId
from ._layout import GUID_LENGTH, HEADER_LENGTH, MATCH_KEY_LENGTH
from .fod_id import DATE_EPOCH, FodId, _date_minutes
from .id_type import IdType

#: The public cloud API base, used when neither the ``endpoint`` argument
Expand Down Expand Up @@ -784,8 +785,11 @@ def _ensure_encoded_size(value: str) -> None:

def _date_of(fod_id: FodId) -> datetime:
"""The identifier's creation moment, from the minutes the envelope
carries, as an aware UTC datetime."""
return DATE_EPOCH + timedelta(minutes=fod_id.date_minutes)
carries, as an aware UTC datetime. Read through the package's own
private helper rather than a public accessor, because the whole minute
the envelope holds is what picks the signing key and a caller has no
use for the wire form."""
return DATE_EPOCH + timedelta(minutes=_date_minutes(fod_id))


def _payload_length_valid(fod_id: FodId) -> bool:
Expand All @@ -794,9 +798,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."""
match_key_length = FodId.GUID_LENGTH if fod_id.type is IdType.RANDOM \
else FodId.MATCH_KEY_LENGTH
return len(fod_id.payload) >= FodId.HEADER_LENGTH + match_key_length
match_key_length = GUID_LENGTH if fod_id.type is IdType.RANDOM \
else MATCH_KEY_LENGTH
return len(fod_id.payload) >= HEADER_LENGTH + match_key_length


def _in_force_at(keys: List[PublicKeyEntry],
Expand Down
Loading
Loading