Skip to content

Harden 51Did parsing to answer with a reason instead of throwing - #68

Merged
jwrosewell merged 6 commits into
mainfrom
harden/51did-parse-without-throwing
Aug 31, 2026
Merged

Harden 51Did parsing to answer with a reason instead of throwing#68
jwrosewell merged 6 commits into
mainfrom
harden/51did-parse-without-throwing

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What changed and why

The OWID libraries were hardened on the branch harden/parse-without-throwing in each SWAN-community repository. An OWID now reaches a caller only from a successful non-throwing parse or from a creator that signs it, and the throwing parse factories and the public constructor are gone. The Python 51Did package, fiftyone_pipeline_did, builds on that library, so this pull request adapts the package to the hardened API and gives FodId a non-throwing parse of its own, layered on the OWID one.

A malformed identifier arriving from outside is an expected data result, not a fault, and the package now says why a read failed instead of raising for it.

The parse contract

  1. The OWID library parses the envelope structurally through Owid.parse or Owid.parse_bytes.
  2. An OWID failure is carried through with its status unchanged. No OWID status is mapped down to a generic one and no exception text is inspected.
  3. The payload must hold the 5 byte header (flags and licence id) before the type can be read, otherwise PAYLOAD_TOO_SHORT. Random then needs 16 GUID bytes and Probabilistic and HashedEmail need 32 hash bytes, otherwise INVALID_TYPE_PAYLOAD_LENGTH. Reserved keeps the documented best-effort reading.
  4. Longer payloads, longer creator domains and longer envelopes are accepted. The bytes after the value are a creator context section whose lengths belong to the cloud, so the package places no upper bound of its own and names no size beyond the header and the value.
  5. The parsed FodId is returned without its signature being checked, and the names and documentation say so.

Public API

Added:

  • FodId.try_from_base64(value) and FodId.try_from_byte_array(buffer), returning a FodIdParseResult.
  • FodIdParseResult, an immutable named tuple with ok, value (the FodId, or None on failure) and status, truthy on success.
  • FodIdParseStatus, the OWID ParseStatus vocabulary member for member and value for value, plus PAYLOAD_TOO_SHORT and INVALID_TYPE_PAYLOAD_LENGTH.
  • FodId.signature_status(public_pem), returning the OWID SignatureStatus, so a caller can tell SIGNATURE_INVALID from KEY_UNAVAILABLE, INVALID_KEY and VERIFICATION_ERROR.
  • SignatureStatus, FodIdParseResult and FodIdParseStatus re-exported from the package root.

Kept, with the same exception types as before: FodId.from_base64, FodId.from_byte_array, FodId.from_owid and the FodId(owid) constructor. They read through the same logic as the new surface, so there is one walk and not two. TypeError for None or a wrong input type, ValueError for the two payload statuses with the messages the package has always given, and OwidError for every other status with the message naming the status.

Changed: the constructor used to copy the OWID by round tripping it through the removed Owid.from_byte_array, and it now reads the envelope back through Owid.parse_bytes. The removed setters mean an OWID cannot be changed after it is handed over, so the two tests that mutated the source envelope are replaced by one that pins that fact.

DidClient now rejects a malformed identifier before any transport or key retrieval on every surface. The offline surfaces (verify_signature, verify_signature_detailed, public_key_for) already parsed before any key was fetched, and a test now proves that no key request is made for malformed text. The cloud surfaces (verify and redeem) used to send a string identifier to the cloud as given so the cloud reported its own parse error. They now parse the string after the unchanged 4096 character guard and refuse a non-51Did with the existing DidArgumentError (still a ValueError, the type callers already catch), with the message naming the FodIdParseStatus and no status code, before any HTTP call. A string that parses here but the cloud refuses still raises DidArgumentError with the cloud's message and status code 400, as before. A string that parses is still sent as given, in whichever alphabet and padding it arrived. The 4096 character guard stays exactly as it is, at the client boundary, as client policy. Key acquisition failures remain errors and are never reported as a bad signature.

Before and after for a caller

# Before, reading external input by catching what the reader raised.
from fiftyone_pipeline_did import FodId, OwidError
try:
    fod_id = FodId.from_base64(text)
except (OwidError, ValueError):
    fod_id = None

# After, asking for the result and its reason.
from fiftyone_pipeline_did import FodId
result = FodId.try_from_base64(text)
fod_id = result.value if result else None    # result.status names the reason

A caller who built an envelope by hand through this package, Owid(domain=..., payload=...) then creator.sign(owid), now writes creator.create(payload). The readme carries both examples.

The OWID pin

The owid-python submodule moves from 9f773d6 to 09443229, the squash merged hardening commit on main of the 51Degrees/owid-python fork (the merge of SWAN-community/owid-python pull request 2, SWAN-community/owid-python#2). The temporary branch that was pushed to the fork to make the earlier pin fetchable has been deleted. ci/copy-owid-source.ps1 copies the hardened source into the package as before.

Tests

Package suite through tox (python -m tox -e py, pytest, the same command CI runs):

Result
Before, on the old pin 109 passed, 2 skipped
After, Python 3.14.5 141 passed, 2 skipped
After, Python 3.9.1 (the package floor), unittest discover from source with the OWID submodule on the path 143 tests OK, 2 skipped

The 2 skipped tests are the live cloud tests, which need a resource key. The tox py39 environment on the build machine could not import the cryptography 43.0.3 wheel it installed (a DLL load failure specific to that environment), so the 3.9 run above used the machine's Python 3.9.1 directly with the tests unchanged.

New or rewritten tests cover a longer self-hosted creator domain, a longer creator context section, a far longer payload, too short Random, Probabilistic and HashedEmail payloads, a payload shorter than the header, invalid base64, a declaration mismatch and other OWID failures carried through unchanged, absent and wrongly typed input, a tampered signature that parses and then verifies as invalid, a missing or unusable key that is not reported as a forgery, the raising readers over the same inputs, the client refusing malformed text before any key fetch, key fetch failures remaining errors, and the guard tested separately from parser conformance. Every failure case asserts the three facts (not ok, value is None, the specific status) and every success asserts ok, a value and PARSED.

Neutralisation

Each new check was undone in turn, the suite run, and the check restored (the source was confirmed identical afterwards).

Check neutralised Failing tests
Header rule removed (PAYLOAD_TOO_SHORT) 2: test_payload_empty_raises, test_payload_shorter_than_the_header_reports_payload_too_short
Type length rule removed (INVALID_TYPE_PAYLOAD_LENGTH) 10, including test_short_random_payload_reports_invalid_type_payload_length, test_short_hashed_email_payload_reports_invalid_type_length, test_short_probabilistic_payload_reports_invalid_type_length, test_header_only_random_payload_reports_invalid_type_length, the three existing one byte short tests, test_raising_readers_keep_their_documented_exception_types and the two client malformed input tests
OWID status collapsed to MALFORMED_ENVELOPE 6: test_declaration_mismatch_is_propagated_unchanged, test_other_owid_failures_are_propagated_unchanged, test_invalid_base64_reports_the_owid_invalid_base64_status, test_absent_input_reports_missing_input, test_wrong_input_type_reports_invalid_input_type, test_parser_names_the_reason_before_the_client_is_asked
Raising reader gives OwidError instead of TypeError for a wrong input type 1: test_raising_readers_keep_their_documented_exception_types
An upper bound on the payload reintroduced 9, including test_far_longer_payload_is_not_rejected_for_its_length, test_longer_creator_context_section_is_accepted, test_long_envelope_parses_and_keeps_the_header_fields and the client tests for longer payloads and a long creator domain
Parse check on the cloud surfaces (verify, redeem) removed 2: test_cloud_surfaces_refuse_malformed_text_before_any_transport, test_cloud_surface_refusal_names_the_parse_status

Three existing cloud tests that sent a deliberately malformed string to the scripted cloud ("zzz", "AwB+/x==", "AwB-_x") now use real signed identifiers, because the client no longer lets such a string reach the transport, and the test of the cloud's own 400 answer now sends a string that parses.

Readme examples

All 10 python blocks in the package readme were executed against the code in one shared namespace with real signed envelopes and a scripted transport, and all passed. The offline example examples/fodid_example.py runs and prints a verified identifier and a reissue with a different envelope and the same value.

Checked with no issue

  • The whole repository was searched for the removed OWID API (Owid(...), Owid.from_base64, Owid.from_byte_array, creator.sign, the setters). The only uses were in this package's source, tests and offline example, all changed here. The web example creator_context_web/server.py only calls FodId.from_base64 and is unchanged.
  • No constant, status, message or test names an envelope, payload, encoded length or creator context size beyond the header and value lengths the package already documented.
  • Comments and documentation no longer say an OWID can be built directly, exist unsigned, or be copied to guard against later mutation.

What remains

Produced with AI assistance under James Rosewell's direction and needs human review.

The 51Degrees owid-python fork now carries the harden/parse-without-throwing
branch at f22ac41, the tip of SWAN-community/owid-python pull request 2.
The pin is temporary and moves to the merged commit on main once that
pull request lands.
The hardened OWID library no longer offers a throwing parse or a public
constructor, so FodId now reads through Owid.parse and Owid.parse_bytes.
FodId.try_from_base64 and FodId.try_from_byte_array read external data
without raising and answer with a FodIdParseResult carrying whether the
parse succeeded, the value and a FodIdParseStatus. The status vocabulary
is the OWID one, carried through unchanged, plus PayloadTooShort for a
payload shorter than the five byte header and InvalidTypePayloadLength
for a payload shorter than its type needs. Longer payloads, domains and
envelopes are accepted, as the lengths beyond the value belong to the
cloud. Parsing never checks the signature, and FodId.signature_status
exposes the OWID SignatureStatus for callers who want the reason a
verification could not be decided.

from_base64, from_byte_array, from_owid and the constructor read through
the same logic and keep their exception types. The constructor no longer
round trips through the removed Owid.from_byte_array and reads the
envelope back through parse_bytes instead.
The test envelope builder writes the wire fields with the OWID library
own helpers and signs them by hand, because a Creator always writes
version 3 and the current time and the removed constructor is gone. The
FodId factory and the offline example create envelopes through
Creator.create. New tests assert the three facts on every result for
longer domains, longer creator context sections, each payload rule, each
OWID failure carried through unchanged, absent and wrongly typed input,
a tampered signature that parses and then verifies as invalid, a missing
key that is not a forgery, the raising readers over the same inputs, and
the client refusing malformed text before any key is fetched.
Adds the parse result and its three facts, the status meanings, the type
specific lower bounds and the absence of a package upper bound, the 4096
character client guard as client policy rather than a format limit,
which failures are data results and which remain exceptions, and a
before and after for callers who used the removed OWID API through this
package.
DidClient.verify and DidClient.redeem used to send a string identifier
to the cloud as given so the cloud reported its own parse error. They now
parse the string after the unchanged encoded size guard and refuse text
that is not a 51Did with the existing DidArgumentError, naming the parse
status, before any request is made, which is the same rule the offline
surfaces already followed. A string that parses is still sent as given,
and a string the cloud itself refuses still raises DidArgumentError with
the cloud message and status code. The tests that sent a malformed string
to the scripted cloud now send real identifiers, and two new tests prove
no request is made for a malformed one and that the error names the
status.
The owid-python submodule now records
09443229f98f81bd1cedc624eec74f9486b9042d, the squash merged hardening
commit on main of the 51Degrees/owid-python fork. The temporary pin at
f22ac41, the tip of the branch harden/parse-without-throwing, is gone.
@jwrosewell
jwrosewell merged commit 3b16617 into main Aug 31, 2026
1 check passed
@jwrosewell
jwrosewell deleted the harden/51did-parse-without-throwing branch August 31, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant