Skip to content

test(sec-2391): prove one anomalous frame cannot tear down a peer link - #64

Merged
MichaelTaylor3d merged 7 commits into
mainfrom
test/2391-frame-tolerance-rederive
Aug 12, 2026
Merged

test(sec-2391): prove one anomalous frame cannot tear down a peer link#64
MichaelTaylor3d merged 7 commits into
mainfrom
test/2391-frame-tolerance-rederive

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — draft; the orchestrator gates and merges this.

Closes the dig-gossip half of DIG-Network/dig_ecosystem#2391.

What this is

A re-derivation of #60 on top of post-#63 main, not a rebase. #60's central premise — that
the crate is written against chia_protocol::Message in ~80 places and needs a translation
seam — stopped being true when #63 migrated the crate onto dig_peer_protocol::DigLink with
raw u8 opcodes. src/connection/link_adapter.rs is therefore dropped, not ported. What
survives is the valuable part: the acceptance test.

DigLink's inbound loop already removes three former one-frame kill switches, and a pre-merge
security audit confirmed it. Nothing in this crate proved it, so a future change could silently
restore one. This is that proof.

Blast radius checked

tests/ only. git diff origin/main -- src/ is EMPTY — including src/relay/, which
dig-relay vendors byte-identically. The only shared file touched is
tests/common/wss_full_node.rs, which gains a new hostile-node acceptor and leaves every
existing acceptor untouched; its three consumers (con_001, dsc_004, dsc_005) were re-run
green. No src/ change proved necessary — the ported tests pass on main as-is, which is the
expected result and is itself the finding.

Tests

test property
unmatched_correlation_id_does_not_drop_the_link a reply for an id nobody awaits is routed to the application
unknown_opcode_does_not_drop_the_link an opcode this build has no meaning for costs one frame
malformed_frame_does_not_drop_the_link an undecodable frame is skipped, not fatal
torn_down_link_fails_the_survival_assertion negative control: the survival assertion detects a dead link
hostile_fixtures_still_have_the_property_they_are_named_for fixture guard against a fixture that stops being hostile

Two design properties are preserved deliberately from #60:

  1. The assertion is not "the frame was accepted" — a torn-down link swallows a frame just
    as silently. It asserts a subsequent correlated request is still served: connect_to
    returns a peer id only after its RequestPeers gets a RespondPeers over the same link,
    sent after the hostile frame. The stream is ordered, so the reply cannot have been read
    without the hostile frame having been read and survived first.
  2. Frames go over a real TLS websocket, not a mock link — a symmetric encode/decode double
    would pass here while the real wire stayed broken.

Non-vacuity evidence

Reverting the tolerance by hand (server closes the websocket immediately after injecting the
hostile frame — exactly what a restored kill switch looks like) turns all three red, and the
control stays green:

test result: FAILED. 1 passed; 3 failed
a UnknownOpcode frame took down the link: dial failed: LinkError(WebSocket(Protocol(SendAfterClosing)))
a UnmatchedCorrelationId frame took down the link: dial failed: LinkError(WebSocket(Protocol(SendAfterClosing)))

Unmutated, with the injected byte counts printed by the server so delivery is observable:

running 5 tests
[hostile-node] injected 12 bytes of UnmatchedCorrelationId before RespondPeers
[hostile-node] injected 42 bytes of UnknownOpcode before RespondPeers
[hostile-node] injected 8 bytes of MalformedFrame before RespondPeers
[control] torn-down link correctly rejected: dial failed: LinkError(WebSocket(Protocol(SendAfterClosing)))
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 24.73s

Deliberately not covered

DigLink also drops rather than queues an inbound frame when the application channel is
full. Driving that here means racing a burst against a service task that drains the channel
continuously — no deterministic outcome, so it is left to a dig-peer-protocol unit test where
the channel can be held without a race. A flaky test is worse than an absent one.

#2391 also asks for a sweep across dig-relay / dig-nat / dig-peer / dig-app IPC. That sweep
is out of scope for this PR
— other lanes own those repos.

Version

0.23.00.23.1 (patch: test-only, no behaviour change). Cargo.toml + Cargo.lock both
bumped; cargo metadata --locked exits 0.

The fixture guard: 1 -> 3 -> 23

The hostile_fixtures_still_have_the_property_they_are_named_for guard exists so the hostile
fixtures cannot silently stop exercising what their names claim. It took three passes to actually
do that, and the first two both passed review:

Revision Opcodes actually checked Blind to
original 1 (is_dig_message is a bare == 220, plus RespondPeers) every other opcode
eaae16d (Copilot) 3 (DIG_MESSAGE, STORE_MELTED, HOLDINGS_ANNOUNCE) the other 20 assigned DIG opcodes
458831d (final) 23 (dig_peer_protocol::ALL_DIG_OPCODES) -

A guard that checks one member of the set it claims to cover is vacuous by construction, and
nobody noticed until someone counted. The canonical set was already in the crate for exactly this
purpose (src/connection/inbound_limits.rs:330 calls ALL_DIG_OPCODES "the canonical" list), so
the fix is a reuse, not an invention.

Proven by mutation, not by inspection: setting UNALLOCATED_DIG_OPCODE to 200 - an assigned DIG
opcode that both earlier revisions accepted - now fails with opcode 200 is no longer unallocated; restoring 223 returns all 5 tests to green.

MichaelTaylor3d and others added 3 commits August 11, 2026 21:38
…ost-#63 main

Co-Authored-By: Claude <noreply@anthropic.com>
DigLink's inbound loop removes three former one-frame kill switches
(unmatched correlation id, unknown opcode, undecodable frame), but
nothing in this crate proved it, so a future change could silently
restore one.

Drives each anomaly over a real TLS websocket and asserts a subsequent
correlated request is still served, plus a negative control proving that
assertion fails against a genuinely torn-down link.

Refs DIG-Network/dig_ecosystem#2391

Co-Authored-By: Claude <noreply@anthropic.com>
Pins each hostile frame to the decoder so a fixture that quietly stopped
being hostile cannot leave the tolerance tests green while exercising
nothing. Test-only change, so patch.

Co-Authored-By: Claude <noreply@anthropic.com>

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness gate: PASS

Head reviewed: 967f095569bb40384d33602c6282589b78b4ac84.

Scope claim verified

git diff origin/main -- src/ is EMPTY, including src/relay/ (dig-relay vendors that wire byte-identically — unmoved). Diff is +356/-2 across Cargo.toml, Cargo.lock, and two test files. tests/common/wss_full_node.rs is purely additive — the new block is appended after the pre-existing acceptors, every prior byte unchanged. Version 0.23.1 in both Cargo.toml and Cargo.lock; cargo metadata --locked exits 0. Patch is the right bump for a test-only change.

The vacuity question — adjudicated by measurement, not by report

The survival assertion is structural, not hoped-for. connect_to blocks on request_infallible(RequestPeers::new()) and only returns Ok after a correlated RespondPeers arrives over the same link (src/service/gossip_handle.rs:840-844), and add_to_new_table is called even for an empty peer list precisely so the CON-001 hook records the exchange (:846-853). Since the server emits the hostile frame before reading RequestPeers, and the stream is ordered, the client cannot reach Ok without having read and survived the hostile frame. Delivery is structural.

I did not take the claimed mutation on report — I ran a stronger one. The implementer's proposed mutation edits the harness (close the socket after injecting), which mostly re-proves what the negative control already proves. The decisive question is whether these tests catch a production-side reintroduction of the kill switch in dig_peer_protocol::DigLink. So I vendored dig-peer-protocol 0.5.0, patched it in via [patch.crates-io], and reverted the tolerance at each of the three points in read_inbound:

Mutation (in DigLink::read_inbound) Result
from_bytes_owned → None returns Err instead of continue malformed_frame_does_not_drop_the_link RED
unmatched correlation id returns Err instead of falling through unmatched_correlation_id_does_not_drop_the_link RED
opcode 223 returns Err unknown_opcode_does_not_drop_the_link RED
baseline (unmutated) all 5 green

Each case turns red only for its own mutation — no cross-talk, and the negative control plus the fixture guard stayed green throughout. That is materially stronger evidence than the PR claims for itself.

The subtle claim — both halves verified

  • UnknownOpcode really is routine now, not a decode failure: the fixture guard's DigMessage::from_bytes(...).expect(...) passes, so it decodes.
  • MalformedFrame really does reach the from_bytes_owned → None skip path: mutating exactly that branch and nothing else turned exactly that test red. Measured, not reasoned.

Negative control and fixture guard are real

  • torn_down_link_fails_the_survival_assertion genuinely errors: dial failed: LinkError(WebSocket(Protocol(SendAfterClosing))). Not vacuous.
  • Fixture guard: I mutated MalformedFrame to declare a truthful 2-byte length; the guard went RED with its intended message. Real. (One gap in its opcode half — non-gating comment inline.)

Flakiness

Low risk. Five tests complete in 8-10s under default parallelism against a 20s per-dial budget — roughly 10x headroom on loopback TLS. Ran three times (serial + two parallel), zero intermittency. The failure signature when a kill switch exists is a budget timeout, so the bound is doing real work rather than masking.

Readable-code (§2.5)

Above bar. The module doc states what is proven, why the shape was chosen, and what is deliberately not covered (the full-channel case, with the flake reasoning) — that last section is exactly the honesty this contract asks for. HostileFrame as data rather than three harnesses is the right abstraction; dial_outcome returning Result<(), String> so the control can state how it failed is a good call. Hand-encoding the wire bytes is justified in a comment (a correct encoder cannot emit MalformedFrame).

One non-gating comment inline; I am resolving it myself so it does not block merge.

Comment thread tests/sec_2391_frame_tolerance_tests.rs Outdated
Copilot AI and others added 3 commits August 12, 2026 05:15
Co-authored-by: MichaelTaylor3d <5665004+MichaelTaylor3d@users.noreply.github.com>
Co-authored-by: MichaelTaylor3d <5665004+MichaelTaylor3d@users.noreply.github.com>
Co-authored-by: MichaelTaylor3d <5665004+MichaelTaylor3d@users.noreply.github.com>
The guard listed three DIG opcodes by hand, so it covered 3 of the 23
assigned and could not catch its own drift. ALL_DIG_OPCODES is the
canonical set already used for the same purpose in
src/connection/inbound_limits.rs.

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 12, 2026 14:06
@MichaelTaylor3d
MichaelTaylor3d merged commit 1e3be70 into main Aug 12, 2026
11 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the test/2391-frame-tolerance-rederive branch August 12, 2026 14:07
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.

2 participants