Skip to content

Stop the ciphertext leaking its own output filename - #14

Merged
ESultanik merged 2 commits into
masterfrom
fix-gzip-filename-leak
Aug 7, 2026
Merged

Stop the ciphertext leaking its own output filename#14
ESultanik merged 2 commits into
masterfrom
fix-gzip-filename-leak

Conversation

@ESultanik

Copy link
Copy Markdown
Owner

Stacked on #13.

gzip.GzipFile(fileobj=args.outfile, ...) was given only a file object, so gzip derived the header FNAME field from fileobj.name. The ciphertext therefore recorded, in cleartext, the path it was written to:

$ lenticrypt -o my-secret-plans.enc -e key1 p1 -e key2 p2
$ dd if=my-secret-plans.enc bs=1 skip=10 count=19
my-secret-plans.enc

For a cryptosystem whose stated purpose is plausible deniability — one that goes out of its way not to reveal even how many plaintexts a ciphertext holds — an attacker recovering the output name from the first bytes defeats the point before any cryptanalysis begins.

Change

Passing filename='' suppresses FNAME. Two things fall out of it:

  • Output no longer depends on the output path, which on its own prevented --seed from producing byte-identical results: the same inputs written to a.enc and to a-considerably-longer-name.enc differed in length.
  • mode='wb' is now explicit, silencing the Python 3.14 FutureWarning that inferring write mode from fileobj will stop working — without it this would eventually become a read-mode open.

Verification

tests/test_gzip_container.py covers the FNAME flag, filename independence, the pinned mtime, and cross-process reproducibility under differing PYTHONHASHSEED. The first two were confirmed failing before the fix.

Ciphertexts written by earlier versions still carry FNAME and still decrypt; the format fixtures cover that.

🤖 Generated with Claude Code

https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy

Base automatically changed from fix-dictionary-hang to master August 7, 2026 19:16
ESultanik and others added 2 commits August 7, 2026 15:21
`gzip.GzipFile(fileobj=args.outfile, ...)` was given only a file object, so gzip
derived the header FNAME field from `fileobj.name`. The ciphertext therefore
recorded, in cleartext, the path it was written to:

    $ lenticrypt -o my-secret-plans.enc -e key1 p1 -e key2 p2
    $ dd if=my-secret-plans.enc bs=1 skip=10 count=19
    my-secret-plans.enc

For a cryptosystem whose stated purpose is plausible deniability -- one that goes
out of its way not to reveal even how many plaintexts a ciphertext holds -- an
attacker recovering the output name from the first bytes defeats the point before
any cryptanalysis begins.

Passing `filename=''` suppresses FNAME. Two things fall out of it:

  * Output no longer depends on the output path, which on its own prevented
    `--seed` from producing byte-identical results: the same inputs written to
    a.enc and to a-considerably-longer-name.enc differed in length.
  * `mode='wb'` is now explicit, silencing the Python 3.14 FutureWarning that
    inferring write mode from `fileobj` will stop working -- without it this
    would eventually become a read-mode open.

Ciphertexts written by earlier versions still carry FNAME and still decrypt; the
format fixtures cover that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy
Two CI problems, one real.

**The lockfile was environment-specific.** It was generated on a machine with a
site-wide `/etc/uv/uv.toml` setting `exclude-newer = "1 week"`, and uv records
that in the lock:

    exclude-newer = "2026-07-30T15:33:53.483359Z"
    exclude-newer-span = "P1W"

CI has no such policy, so uv re-resolved, found the lock stale, and `--locked`
failed every job it managed to start:

    Resolving despite existing lockfile due to removal of global exclude newer
    error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.

The lock is regenerated with `uv --no-config lock`, so it carries no policy and
matches a vanilla environment. `--locked` is dropped as well: pinning CI to a lock
that a contributor's local policy can invalidate trades a real failure mode for
drift detection that a zero-runtime-dependency project barely needs.

Note for regenerating it under that policy: use `uv --no-config lock`, or a plain
`uv lock` will re-add the exclude-newer fields.

**macOS is dropped from the matrix.** For pure Python it duplicates Linux --
same POSIX semantics, same path handling, same availability of the modules this
package touches. The leg that earns its place is windows-latest, which catches
the `fcntl`/`termios` import class of bug, and that joins once progress.py stops
importing them at module scope.

The other 15-20 minute "failures" in that run were not failures: every
ubuntu-latest and build job was cancelled with zero steps, having never been
assigned a runner. A smaller matrix should help there too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy
@ESultanik
ESultanik force-pushed the fix-gzip-filename-leak branch from 6a6e634 to d89f488 Compare August 7, 2026 19:23
@ESultanik
ESultanik merged commit 02635ed into master Aug 7, 2026
6 checks passed
@ESultanik
ESultanik deleted the fix-gzip-filename-leak branch August 7, 2026 19:29
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