Skip to content

Harden decrypt and stop it returning truncated plaintexts - #20

Merged
ESultanik merged 1 commit into
masterfrom
decrypt-hardening
Aug 7, 2026
Merged

Harden decrypt and stop it returning truncated plaintexts#20
ESultanik merged 1 commit into
masterfrom
decrypt-hardening

Conversation

@ESultanik

@ESultanik ESultanik commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Stacked on #19.

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.

Defects that came from checking the length bound in four places

_NibbleAssembler owns the carry and the declared-length bound, which fixes:

  • 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.
  • Its inner for index in range(...) shadowed the outer index from dictionary[dict_index], and indexed nibbles[index + 1] without bounds — an 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 a stdlib traceback. Previously:

input was
truncated length header struct.error
truncated dictionary TypeError: 'NoneType' object cannot be interpreted as an integer
truncated gzip envelope EOFError from inside gzip

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 behavior changes worth flagging

  • 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".

Other

_load_certificate reads in 4 KiB blocks instead of one byte at a time. 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.

Verification

All three remaining xfail markers removed: 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; 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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy

`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
Base automatically changed from cli-fixes to master August 7, 2026 20:00
@ESultanik
ESultanik merged commit 15ec47e into master Aug 7, 2026
11 checks passed
@ESultanik
ESultanik deleted the decrypt-hardening branch August 7, 2026 20:12
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