Skip to content

Choose certificate offsets with a CSPRNG rather than Mersenne Twister - #23

Merged
ESultanik merged 1 commit into
masterfrom
system-random
Aug 7, 2026
Merged

Choose certificate offsets with a CSPRNG rather than Mersenne Twister#23
ESultanik merged 1 commit into
masterfrom
system-random

Conversation

@ESultanik

Copy link
Copy Markdown
Owner

Every ciphertext block records a certificate offset picked at random from the offsets where that nibble-gram occurs. Those choices are the ciphertext, so the generator behind them is part of the security story, not an implementation detail. They came from module-level random — Mersenne Twister — whose state is recoverable from enough output, after which every subsequent choice is predictable.

Why not plain SystemRandom

It is far too slow to be the default, because a choice happens roughly once per plaintext byte. Measured over 200,000 choice() calls on a 4096-element array:

Random (Mersenne Twister)   0.134 us
SystemRandom                1.911 us    14x slower
BufferedSystemRandom        0.502 us    3.8x faster than SystemRandom

BufferedSystemRandom draws os.urandom in 64 KiB blocks instead of per call, recovering most of the gap while keeping the CSPRNG. It inherits SystemRandom's no-op seed and refusal to pickle, so it cannot be mistaken for a reproducible generator.

Explicit rather than global

The generator is now an rng parameter on Encrypter rather than module-level state, and LengthChecksumEncrypter.get_header forwards it into the nested Encrypter it builds for the length header — easy to miss, and without it a seeded run would still vary. Two consequences:

  • Nothing else in the process can perturb a ciphertext by touching random. Anything that called random.seed() for its own reasons used to change lenticrypt's output.

  • --seed is the only route to reproducible output, and it now says so:

    WARNING  --seed makes this ciphertext reproducible, and therefore predictable.
             Use it for testing, not for anything you need kept secret.
    

This is correctness of intent, not a claim that the cryptosystem is now secure. The README's warning stands.

pad_nibble_gram also takes one bulk randbytes draw instead of length separate randint calls.

Verification

22 new tests cover the buffered generator's bit widths, block refills, distribution and unseedability, and that encryption differs between default runs, matches between equal seeds, differs between unequal seeds, and is unaffected by module-level seeding. 256 passed.

Note for reviewers: several existing tests previously relied on random.seed() to make two encryptions agree. They now pass an explicit seeded_rng(...), which is the new contract.

🤖 Generated with Claude Code

https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy

Every ciphertext block records a certificate offset picked at random from the
offsets where that nibble-gram occurs. Those choices *are* the ciphertext, so the
generator behind them is part of the security story, not an implementation
detail. They came from module-level `random` -- Mersenne Twister -- whose state
is recoverable from enough output, after which every subsequent choice is
predictable.

`random.SystemRandom` is the obvious answer but is far too slow to be the
default, because a choice happens roughly once per plaintext byte. Measured over
200,000 `choice()` calls on a 4096-element array:

    Random (Mersenne Twister)   0.134 us
    SystemRandom                1.911 us    14x slower
    BufferedSystemRandom        0.502 us    3.8x faster than SystemRandom

`BufferedSystemRandom` draws `os.urandom` in 64 KiB blocks instead of per call,
which recovers most of the gap while keeping the CSPRNG. It inherits
SystemRandom's no-op `seed` and its refusal to pickle, so it cannot be mistaken
for a reproducible generator.

The generator is now an explicit `rng` parameter on `Encrypter` rather than
module-level state, and `LengthChecksumEncrypter.get_header` forwards it to the
nested `Encrypter` it builds for the length header -- easy to miss, and without
it a seeded run would still vary. Two consequences:

  * Nothing else in the process can perturb a ciphertext by touching `random`.
    Anything that called `random.seed()` for its own reasons used to change
    lenticrypt's output.
  * `--seed` is the only way to get reproducible output, and it now says so:
    seeding warns that the result is reproducible and therefore predictable.

This is correctness of intent, not a claim that the cryptosystem is now secure.
The README's warning stands.

`pad_nibble_gram` also takes one bulk `randbytes` draw instead of `length`
separate `randint` calls.

22 new tests cover the buffered generator's bit widths, block refills,
distribution and unseedability, and that encryption differs between default runs,
matches between equal seeds, differs between unequal seeds, and is unaffected by
module-level seeding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy
@ESultanik
ESultanik merged commit 715a77c into master Aug 7, 2026
11 checks passed
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