Harden 51Did parsing to answer with a reason instead of throwing - #68
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed and why
The OWID libraries were hardened on the branch
harden/parse-without-throwingin 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 givesFodIda 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
Owid.parseorOwid.parse_bytes.PAYLOAD_TOO_SHORT. Random then needs 16 GUID bytes and Probabilistic and HashedEmail need 32 hash bytes, otherwiseINVALID_TYPE_PAYLOAD_LENGTH. Reserved keeps the documented best-effort reading.FodIdis returned without its signature being checked, and the names and documentation say so.Public API
Added:
FodId.try_from_base64(value)andFodId.try_from_byte_array(buffer), returning aFodIdParseResult.FodIdParseResult, an immutable named tuple withok,value(theFodId, orNoneon failure) andstatus, truthy on success.FodIdParseStatus, the OWIDParseStatusvocabulary member for member and value for value, plusPAYLOAD_TOO_SHORTandINVALID_TYPE_PAYLOAD_LENGTH.FodId.signature_status(public_pem), returning the OWIDSignatureStatus, so a caller can tellSIGNATURE_INVALIDfromKEY_UNAVAILABLE,INVALID_KEYandVERIFICATION_ERROR.SignatureStatus,FodIdParseResultandFodIdParseStatusre-exported from the package root.Kept, with the same exception types as before:
FodId.from_base64,FodId.from_byte_array,FodId.from_owidand theFodId(owid)constructor. They read through the same logic as the new surface, so there is one walk and not two.TypeErrorforNoneor a wrong input type,ValueErrorfor the two payload statuses with the messages the package has always given, andOwidErrorfor 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 throughOwid.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.DidClientnow 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 (verifyandredeem) 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 existingDidArgumentError(still aValueError, the type callers already catch), with the message naming theFodIdParseStatusand no status code, before any HTTP call. A string that parses here but the cloud refuses still raisesDidArgumentErrorwith 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
A caller who built an envelope by hand through this package,
Owid(domain=..., payload=...)thencreator.sign(owid), now writescreator.create(payload). The readme carries both examples.The OWID pin
The
owid-pythonsubmodule moves from9f773d6to09443229, the squash merged hardening commit onmainof 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.ps1copies the hardened source into the package as before.Tests
Package suite through tox (
python -m tox -e py, pytest, the same command CI runs):unittest discoverfrom source with the OWID submodule on the pathThe 2 skipped tests are the live cloud tests, which need a resource key. The tox
py39environment on the build machine could not import thecryptography43.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 andPARSED.Neutralisation
Each new check was undone in turn, the suite run, and the check restored (the source was confirmed identical afterwards).
PAYLOAD_TOO_SHORT)test_payload_empty_raises,test_payload_shorter_than_the_header_reports_payload_too_shortINVALID_TYPE_PAYLOAD_LENGTH)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_typesand the two client malformed input testsMALFORMED_ENVELOPEtest_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_askedOwidErrorinstead ofTypeErrorfor a wrong input typetest_raising_readers_keep_their_documented_exception_typestest_far_longer_payload_is_not_rejected_for_its_length,test_longer_creator_context_section_is_accepted,test_long_envelope_parses_and_keeps_the_header_fieldsand the client tests for longer payloads and a long creator domainverify,redeem) removedtest_cloud_surfaces_refuse_malformed_text_before_any_transport,test_cloud_surface_refusal_names_the_parse_statusThree 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.pyruns and prints a verified identifier and a reissue with a different envelope and the same value.Checked with no issue
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 examplecreator_context_web/server.pyonly callsFodId.from_base64and is unchanged.What remains
Produced with AI assistance under James Rosewell's direction and needs human review.