Harden decrypt and stop it returning truncated plaintexts - #20
Merged
Conversation
`decrypt` was one ~85-line function with four near-identical copies of the
half-byte carry logic, and it reported malformed input by letting stdlib
exceptions escape. Split into `_NibbleAssembler` plus `_load_certificate`,
`_certificate_nibbles`, `_read_block_header`, `_read_dictionary`,
`_decrypt_dictionary` and `_decrypt_blocks`, each well under the size limits.
`_NibbleAssembler` owns the carry *and* the declared-length bound, which fixes
the defects that came from checking that bound in four places:
* **An empty plaintext decrypted to one byte.** The length was compared only
after yielding, so a declared length of zero still produced output.
* `_decrypt_dictionary` checked the bound only *between* blocks, so a final
multi-nibble block could overshoot the declared length.
* Its inner `for index in range(...)` shadowed the outer `index` from
`dictionary[dict_index]`, and indexed `nibbles[index + 1]` without bounds,
which raised IndexError when `cert[index:index + length]` was truncated near
the end of the certificate.
* The invalid-certificate-index path emitted `length` zero *bytes* where the
gram was `length` *nibbles*, desynchronising everything after it.
Malformed input now raises `MalformedCiphertextError` instead of surfacing as a
stdlib traceback. Previously: a truncated length header gave `struct.error`, a
truncated dictionary gave `TypeError: 'NoneType' object cannot be interpreted as
an integer`, and a truncated gzip envelope gave an `EOFError` from inside gzip.
The compression errors are caught narrowly -- `EOFError`, `gzip.BadGzipFile`,
`zlib.error` -- because a blanket `OSError` here would relabel a genuine disk
failure as malformed input.
Two behaviour changes worth noting:
* **A ciphertext that declares more bytes than it delivers is now an error.**
It previously returned the short plaintext silently, handing back a truncated
file as though it were the whole thing.
* **An unknown format version is refused** rather than warned about and then
decoded as v1/v2, which produced garbage. The warning also printed
`version / 10.0`, so version 4 was reported as "version 0.4".
`_load_certificate` reads in 4 KiB blocks instead of one byte at a time, and the
`cert=`/`file_length=` keyword parameters are gone from the public signature --
they existed only for the recursion that read the 8-byte length header, which
`_decrypt_blocks` now does directly.
All three remaining xfail markers are removed; the suite is 188 passed, 0
xfailed. The two truncation tests had asserted `ValueError` as a stand-in for
"some clean error" because they predate the exception hierarchy, and were
retargeted at `LenticryptError`. Truncation is now exercised by cutting *real*
ciphertexts at several points, because a hand-written malformed header is easily
not malformed at all -- one of mine decoded to a declared length of zero, which
legitimately yields nothing.
The committed format fixtures still decrypt byte-for-byte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy
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.
Stacked on #19.
decryptwas one ~85-line function with four near-identical copies of the half-byte carry logic, and it reported malformed input by letting stdlib exceptions escape. Split into_NibbleAssemblerplus_load_certificate,_certificate_nibbles,_read_block_header,_read_dictionary,_decrypt_dictionary, and_decrypt_blocks— each well under the size limits.Defects that came from checking the length bound in four places
_NibbleAssemblerowns the carry and the declared-length bound, which fixes:_decrypt_dictionarychecked the bound only between blocks, so a final multi-nibble block could overshoot.for index in range(...)shadowed the outerindexfromdictionary[dict_index], and indexednibbles[index + 1]without bounds — anIndexErrorwhencert[index:index + length]was truncated near the end of the certificate.lengthzero bytes where the gram waslengthnibbles, desynchronising everything after it.Malformed input
Now raises
MalformedCiphertextErrorinstead of a stdlib traceback. Previously:struct.errorTypeError: 'NoneType' object cannot be interpreted as an integerEOFErrorfrom inside gzipCompression errors are caught narrowly —
EOFError,gzip.BadGzipFile,zlib.error— because a blanketOSErrorhere would relabel a genuine disk failure as malformed input.Two behavior changes worth flagging
version / 10.0, so version 4 was reported as "version 0.4".Other
_load_certificatereads in 4 KiB blocks instead of one byte at a time. Thecert=/file_length=keyword parameters are gone from the public signature — they existed only for the recursion that read the 8-byte length header, which_decrypt_blocksnow does directly.Verification
All three remaining
xfailmarkers removed: 188 passed, 0 xfailed.The two truncation tests had asserted
ValueErroras a stand-in for "some clean error" because they predate the exception hierarchy; retargeted atLenticryptError. Truncation is now exercised by cutting real ciphertexts at several points, because a hand-written malformed header is easily not malformed at all — one of mine decoded to a declared length of zero, which legitimately yields nothing.The committed format fixtures still decrypt byte-for-byte.
🤖 Generated with Claude Code
https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy