protocol: drop a frame that fails signature verification (ibx#275) - #354
protocol: drop a frame that fails signature verification (ibx#275)#354userFRM wants to merge 4 commits into
Conversation
`unsign` returned the message alongside a validity flag, and all twelve callers discarded the flag — `_valid` or `_` at every site, with a thirteenth doing the same on a direct `fix_unsign` in the auth loop. Nothing in the tree acted on the result, so a frame that failed verification was parsed and applied exactly like an authentic one: an order ack, a fill, an account push. It now returns `Option`, and a frame that does not verify yields nothing. That is the same information in a form a caller cannot ignore, which matters more than any individual site: thirteen out of thirteen discarding a flag is a signature the type should not have had. A failed frame also no longer advances the read IV. That half needs no adversary. Undistortion XORs byte positions from the IV, so one damaged frame moved the chain on and every genuine frame after it arrived silently corrupted, with nothing surfaced. A connection that hit one bad frame stayed broken. A frame carrying no signature tag is still accepted, as the reference client does. Whether the gateway ever sends one on a keyed connection is not established here, and refusing them on that assumption would drop real traffic to protect against nothing; the warning makes the case visible so the question can be settled from logs rather than guessed. Requiring the tag is the remaining half of ibx#275 and wants that evidence first. Closes deepentropy#275.
Two corrections to the previous commit, both of which made it worse than the behaviour it replaced. Holding the IV back on a failed frame was wrong. The next IV is `iv ^ HMAC(key, iv || body)` — derived from the body, not from the signature. So a frame whose transmitted signature alone is damaged still yields the sender's true next IV, and withholding it desynchronises the following authentic frame: exactly the poisoning the change claimed to prevent, caused by the change. When the body is damaged the derived IV is wrong, but the held one is equally wrong, because the sender advanced using the true body. Withholding is never better and sometimes strictly worse, so the chain advances either way and only the frame is dropped. The test that covered this replayed the same frame rather than a following one signed from the chained IV, so it asserted recovery that had not been demonstrated. It now builds two frames as a sender would and damages only the first's signature. The signature was also located by searching for `8349=` anywhere in the message, which matches the same text inside a field *value* — a reject reason quoting it, say. The body boundary then landed mid-message and a legitimate frame reported invalid, which was harmless while the verdict was discarded and drops the frame now that it is not. Matched with its leading delimiter at all three sites. The caller inventory was also wrong: `tests/ib_paper_compat/main.rs` uses the flag in diagnostic output rather than discarding it, and `tests/depth_wire_test.rs` destructures the pair. Both compile against the new signature, along with every other test target. Closes deepentropy#275.
|
Updated in The read IV advances again on a failed frame. Holding it back was the wrong side of the trade: the next IV is The test covering this replayed the same frame rather than a following one signed from the chained IV, so it asserted a recovery it had not demonstrated. It now builds two frames as a sender would. The signature is matched as a field, not a substring. Every test target compiles. The caller census in the first message counted 806 pass plus the two Separately: #275's threat model states that every socket runs inside TLS. That is not the case — |
…uch for Two reviews reached opposite conclusions on whether a failed frame should advance the read IV, and each was right about the case it considered. An injected frame between two authentic ones poisons the rest if the chain advances, because the IV would be derived from bytes the attacker chose. A genuine frame whose signature alone was damaged leaves the connection stuck if it does not, because its body is intact and the derived IV would have been the sender's true next one. The two are indistinguishable at the point of decision, so the question is not which is likelier. `new_iv` is `iv ^ HMAC(key, iv || body)` over the received body, and a failed MAC is exactly the statement that this body cannot be vouched for. Advancing therefore takes cryptographic state from unauthenticated input, and lets one injected frame steer the receiver's chain. The chain does not advance. The cost is real and is stated in the code: a genuine frame corrupted in exactly its signature leaves the connection unable to verify what follows. A channel where a MAC failure has occurred is not one whose state can be inferred either way, and the honest resolution is to tear the connection down rather than guess — which is a larger change than this one and wants a decision rather than an assumption. The test now drives the case that separates the two: good, bad, good, with the third frame signed from the IV the first chained to. Also carried from the previous commit: the signature is matched as a delimited field rather than a substring, so a legitimate frame quoting `8349=` in a value is no longer read as invalid; and every test target compiles, `depth_wire_test` having been broken by the signature change while `ib_paper_compat` returns to its base error count. Closes deepentropy#275.
|
The read-IV question here has two defensible answers, and the branch has now taken both, so the reasoning is written where the decision is made rather than asserted in a message.
Both hold. They are also indistinguishable at the point of decision, so the tiebreak is not which is likelier: Neither answer is fully satisfying. A channel that has seen a MAC failure is not one whose state can be inferred, and tearing the connection down rather than picking an IV is probably the right end state — a larger change than this, and a decision rather than an assumption, so it is flagged rather than taken. The other two changes: the signature is matched as a delimited field, so a legitimate frame quoting Known gap: the thirteenth site in the auth loop is a hand copy of the same logic, and removing its guard leaves the suite green. 806 pass plus the two |
The public doc on `unsign` still carried the rationale this branch disproved — that withholding the IV prevents a damaged frame from desynchronising the chain, needing no adversary. Under the policy the branch settled on, that case is withholding's *cost*, not its justification, which the comment twenty lines below states correctly. So the two argued opposite reasons for the same lines, and the doc is the one rustdoc renders. The pre-check that decides whether a frame is verified at all was also unpinned: reverting it to the un-anchored needle passed every test. The uncovered case is an unsigned frame quoting the tag in a field value — the anchored needle correctly declines to verify it, while the bare one routes it into verification, finds no signature field, and drops a legitimate frame. The signed-frame test cannot catch this, since its pre-check passes either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Updated in The The pre-check is pinned. Reverting it to the un-anchored needle passed every test. The uncovered case is an unsigned frame quoting the tag in a field value: the anchored needle correctly declines to verify it, while the bare one routes it into verification, finds no signature field, and drops a legitimate frame. The signed-frame test cannot catch that, since its pre-check passes under either needle. Two further confirmations worth recording: the needle change is byte-identical on well-formed frames, because the undistortion XOR range can never reach the signature tag bytes, so the pre-check and the post-undistortion field search cannot disagree; and 807 pass plus the two Still open: the auth-loop copy of this logic has no coverage — its policy has changed twice with no test impact. And a channel that has seen a MAC failure arguably wants tearing down rather than having its IV guessed; that is a design decision rather than something to assume. |
Summary
Connection::unsignreturned(Vec<u8>, bool)and every caller discarded the flag —_validor_at twelve sites, plus a thirteenth doing the same on a directfix_unsignin the auth loop. No site in the tree acted on the result, so a frame that failed verification was parsed and applied exactly like an authentic one.Option<Vec<u8>>. A frame that does not verify yields nothing, which is the same information in a form a caller cannot ignore. Thirteen out of thirteen discarding a flag is a signature the type should not have had.What is NOT included
A frame carrying no
8349tag is still accepted, as the reference client does. #275 asks for the tag to be required whenever a read key is set, and that is the right end state — but whether the gateway ever sends an untagged frame on a keyed connection is not established here, and refusing them on that assumption would drop real traffic to protect against nothing. A warning now makes the case visible, so the question can be settled from logs rather than guessed.The issue's related finding — that
do_srpnever checks M2 — is untouched. It needs an M2 function thatsrp.rsdoes not have, and belongs in its own change.Closes #275.
Correction, and an open question
Two reviews reached opposite conclusions on whether a failed frame should advance the read IV, and each was right about the case it considered:
They are indistinguishable at the point of decision, so the question is not which is likelier.
new_ivisiv ^ HMAC(key, iv || body)over the received body, and a failed MAC is exactly the statement that this body cannot be vouched for. Advancing therefore takes cryptographic state from unauthenticated input. The chain does not advance.The cost is real and stated in the code: a genuine frame corrupted in precisely its signature leaves the connection unable to verify what follows. I think the honest resolution is that a channel which has seen a MAC failure should be torn down rather than have its state guessed — but that is a larger change than this one and wants your decision rather than my assumption.
Two other things the reviews found, both fixed:
8349=anywhere, which matches the same text inside a field value — a reject reason quoting it, say. That put the body boundary mid-message and made a legitimate frame report invalid: harmless while the verdict was discarded, lost traffic once enforced. Matched with its delimiter at all three sites.tests/ib_paper_compat/main.rsuses the flag in diagnostic output rather than discarding it, andtests/depth_wire_test.rsdestructures the pair — that target compiles at base and did not at my first HEAD. All eight test targets are enumerated and checked now.Known gap: the thirteenth site, in the auth loop, is a hand copy of the same logic and is not covered — removing its guard leaves the suite green.
Note on #275's threat model: that issue claimed every socket runs inside TLS. It does not —
connect_farmruns over plain TCP, so on farm channels this MAC was the only integrity control there was. Corrected on the issue.Test plan
cargo test --offline --lib— 805 passed. The 2 failures areconfig::expiry_tests::{named_zone_converts_with_dst, instant_round_trips_to_wire}, which fail on the base commit too: the host has no legacy timezone files (fixed separately in config: resolve the legacy timezone names IB states its times in (ibx#335) #336).cargo check --offlineclean on every offline target:--lib,--features python,--bins,--examples,--test control_plane,--test scenarios,--test hot_loop_lifecycle.a_frame_that_fails_verification_is_not_returnedanda_failed_frame_does_not_advance_the_read_iv; advancing the IV on failure fails the latter; refusing untagged frames failsunsigned_connections_and_untagged_frames_still_pass. All by name.8349tag on a keyed connection, pending log evidence that no legitimate frame arrives without one.🤖 Generated with Claude Code