Repository navigation
Decide "is this encrypted" by decrypting, not by sniffing a byte - #90
Merged
Merged
Conversation
…g a byte
is_encrypted_blob asked a security question and answered it from one byte. The
envelope is nonce(24) || ciphertext, so its first byte is the first byte of a
random nonce — one of `{`, `[`, space or newline in 4 cases out of 256. The old
test (`head not in (b"{", b"[", b" ", b"\n")`) therefore called real, readable
ciphertext "plain JSON". Measured over 4096 draws: 69 misclassifications, 1.68%.
That is the wrong direction for this predicate to fail — it turns a readable
secret into a missing one.
Detection now attempts decryption: a blob is encrypted when it decrypts under
the given key, and nothing else counts. Truncated blobs, filler bytes, plain
JSON, tampered ciphertext and blobs encrypted under a foreign key all report
False, each for the right reason.
Breaking, in shared/crypto.py: is_encrypted_blob(blob_head: bytes) becomes
is_encrypted_blob(blob: bytes, master_key: bytes). A one-byte prefix cannot be
authenticated, so the old signature was incapable of a correct answer; pass the
whole blob. The path-based helpers — shared.master_key.is_encrypted_blob(path)
and shared.saga.impl.crypto.is_encrypted_blob(path) — keep their signatures and
are decrypt-based too. Nothing inside the package used any of them to decide
anything: the saga read path already decrypted first, which is why this was a
trap for callers rather than a live defect. They are exported utilities, so the
trap was real for anyone outside.
The bug had already been worked around rather than fixed, and the workaround is
now gone. tests/test_shared/test_crypto_smoke.py redrew up to 32 blobs until one
looked encrypted, because a single draw flaked about 1.6% of runs; it now
asserts the opposite — it waits for the `{`-first nonce and requires detection to
survive it. tests/test_secrets.py had been proving encryption with filler bytes
(b"\xab\xcd" * 30), which only ever passed because anything not `{` counted as
encrypted; it now writes a real envelope.
Red/green on a single live blob: with a `{`-first nonce the old logic returns
False for a blob that decrypts, the new one returns True.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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.
Follow-up to #89, which reported this and left it alone because it changes a public
helper's contract. You said fix it.
The bug
is_encrypted_blobasked a security question and answered it from one byte.The envelope is
nonce(24) || ciphertext, so its first byte is the first byte of arandom nonce —
{,[, space or newline in 4 cases out of 256. The old testwas
head not in (b"{", b"[", b" ", b"\n"), so real, readable ciphertext was called"plain JSON". Measured over 4096 draws: 69 misclassifications, 1.68%.
That is the wrong direction for this predicate to fail — it turns a readable secret
into a missing one.
The fix
Detection attempts decryption. A blob is encrypted when it decrypts under the given
key, and nothing else counts. Truncated blobs, filler bytes, plain JSON, tampered
ciphertext and blobs encrypted under a foreign key all report
False, each for theright reason.
Breaking, and why it should be
shared/crypto.py:is_encrypted_blob(blob_head: bytes)→is_encrypted_blob(blob: bytes, master_key: bytes).A one-byte prefix cannot be authenticated, so the old signature was incapable of a
correct answer. Keeping it would mean keeping the lie. The path-based helpers —
shared.master_key.is_encrypted_blob(path)andshared.saga.impl.crypto.is_encrypted_blob(path)— keep their signatures and are decrypt-based now too.
Nothing inside the package used any of these to decide anything: the saga read path
already decrypted first. They are exported utilities, which is exactly why the wrong
answer mattered for callers rather than for us.
The workaround this bug had accumulated
Two tests had adapted to the bug instead of fixing it, and both are now honest:
tests/test_shared/test_crypto_smoke.pyredrew up to 32 blobs until one lookedencrypted, because a single draw flaked about 1.6% of runs. It now asserts the
opposite: it waits for the
{-first nonce and requires detection to survive it.tests/test_secrets.pyproved encryption with filler bytes (b"\xab\xcd" * 30),which only ever passed because anything not
{counted as encrypted. It writes a realenvelope now.
Verification
{-first nonce the old logic returnsFalsefor a blob that decrypts; the new one returns
True.ruff check,ruff format --check,mypy— clean.Not mine, reported instead
tests/test_hypothesis.py::{TestSagaCompensationLogic::test_compensation_reverts_core_memory,TestMemoryStateMachine::test_remember_forget_invariant}fail when selected in isolation (
sqlite3.OperationalError: no such table: core_memory)but pass in the full run. Verified pre-existing: they fail identically on
masterwiththese changes stashed. That is test-order dependence, unrelated to this work, and left
alone.