Choose certificate offsets with a CSPRNG rather than Mersenne Twister - #23
Merged
Conversation
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
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.
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:BufferedSystemRandomdrawsos.urandomin 64 KiB blocks instead of per call, recovering most of the gap while keeping the CSPRNG. It inheritsSystemRandom's no-opseedand refusal to pickle, so it cannot be mistaken for a reproducible generator.Explicit rather than global
The generator is now an
rngparameter onEncrypterrather than module-level state, andLengthChecksumEncrypter.get_headerforwards it into the nestedEncrypterit 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 calledrandom.seed()for its own reasons used to change lenticrypt's output.--seedis the only route to reproducible output, and it now says so:This is correctness of intent, not a claim that the cryptosystem is now secure. The README's warning stands.
pad_nibble_gramalso takes one bulkrandbytesdraw instead oflengthseparaterandintcalls.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 explicitseeded_rng(...), which is the new contract.🤖 Generated with Claude Code
https://claude.ai/code/session_01367hFob9sd4xpDmVT4uoFy