Skip to content

Make the relay double capable of lying - #36

Merged
Wired4ncer merged 1 commit into
feat/nostr-channelfrom
test/malformed-relay-frames
Aug 13, 2026
Merged

Wired4ncer merged 1 commit into
feat/nostr-channelfrom
test/malformed-relay-frames

Conversation

@Wired4ncer

Copy link
Copy Markdown
Owner

Tests only — no change to src/. Base is feat/nostr-channel, so this lands into #35 rather than around it.

What this is

The review on #35 found eight issues, seven of them at the relay boundary. This turns the frame-shaped ones into tests, and fixes the double that let them through in the first place.

FakeRelay can only ever produce ["OK", <id>, <bool>, <str>]. That is precisely the shape NostrChannel handles correctly, so a suite built on it can only test the case that already works — which is why a genuinely thorough set of tests was silent about all of this. ScriptedRelay hands the raw frame to the test instead, with ok_frame() / closed_frame() builders left deliberately untyped so a test can send what a relay that disagrees with NIP-01 would send.

The script is finite on purpose: when it runs out, recv raises the WebSocketTimeoutException a real socket raises once a relay goes quiet. That is what lets a test assert send stopped reading early without any test being able to hang if it doesn't.

The nine cases

All marked xfail(strict=True), so this merges green:

Case Defect
relay closes the connection (×2) json.loads on the "" websocket-client returns for a CLOSE opcode; the second checks relay B still gets tried
frame is not JSON (×3 of 7 params) JSONDecodeError is a ValueError, so it passes through send's except clause
OK flag is the string "false" bool("false") is True — a rejection read as a delivered alarm
OK message is not a string _classify_note calls .split on it
CLOSED frame compared against the event id, but CLOSED carries a subscription id
relay never OKs timeout bounds one recv, not the loop around it

The other four parameters of the not-JSON case are unmarked and pass today[], a non-list, null, 42 are already handled by the existing isinstance(frame, list) guard. Keeping them visible is the point: the guard exists, it is simply downstream of the parse that fails first.

On strict=True

Deliberate. When a defect is fixed the test starts passing, pytest reports the XPASS as a failure, and the marker has to be deleted. A non-strict xfail would let these quietly outlive the bugs they describe, which is the failure mode that makes a suite lie about its own coverage.

Two tests that needed a second pass

Worth recording, because both were wrong in the direction that would have wasted your time:

  • The malformed-OK-flag test also asserted retriable is True. But reading that note at face value gives invalid, which is permanent, and distrusting the whole frame gives retriable — both defensible. It would have failed a correct fix for disagreeing about something the test isn't about. It now asserts only the false-success.
  • The no-deadline test counted frames rather than seconds. An in-process double returns 5,000 frames without the wall clock moving, so the assertion passed by running out of script — against a channel with no deadline at all. ScriptedRelay.delay makes time actually pass, and the test now runs a 0.05s timeout against a relay that takes 0.002s per frame.

Both were caught by running the suite against a patched channel rather than assuming the markers were right.

Verified in both directions

  • Against this branch: 420 passed, 9 xfailed, 0 failed. ruff clean.
  • Against a locally patched channel.py fixing all five frame-handling defects: all 9 flip to XPASS(strict). So none is red for an unrelated reason, and none would stay red once fixed.

That patch is not included here — the fixes are yours to write, and these are meant to be the tests you write them against.

One thing this doesn't cover

tools/mutate.py has 7 mutations for channels/email.py and zero for channels/nostr/. The green mutations check on #35 re-ran a sweep that doesn't touch a line of the new module, so it attested to nothing about it. Same gap as fake_relay.py, one layer up — worth its own issue rather than smuggling it in here.

The two non-frame findings from the review (validate_dest accepting a non-curve npub, and the missing private-key length check) aren't in this PR either. Neither is frame-shaped, and both want a test next to the code that fixes them.

🤖 Generated with Claude Code

`FakeRelay` can only produce `["OK", <id>, <bool>, <str>]` -- the exact shape
`NostrChannel` handles correctly, and therefore the shape that hides every way
it does not. Every gap found reviewing #35 lives outside what that double can
express, which is why a thorough suite was silent about all of them.

`ScriptedRelay` hands the raw frame to the test instead. A relay is
infrastructure we don't run: its *content* is already treated as hostile in
`_classify_note`, and its *shape* had no such treatment.

The nine cases that fail are marked `xfail(strict=True)` rather than left red,
so this lands green and each marker deletes itself the moment its defect is
fixed -- a non-strict xfail would outlive the bug it describes.

Two of them needed care to be worth having. The malformed-OK-flag test first
asserted `retriable` too, which a correct fix can defensibly set either way;
it now asserts only the false-success. The no-deadline test measured frames
rather than seconds, so an instant in-process double satisfied it by running
out of script -- `ScriptedRelay.delay` makes the wall clock move, since a
deadline that isn't measured in time isn't the thing being tested.

Verified in both directions: all nine XFAIL against this branch, and all nine
XPASS(strict) against a patched channel, so none is red for an unrelated
reason and none would stay red after the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Wired4ncer
Wired4ncer merged commit a1d9de4 into feat/nostr-channel Aug 13, 2026
4 checks passed
@Wired4ncer
Wired4ncer deleted the test/malformed-relay-frames branch August 13, 2026 01:55
magicka7 added a commit that referenced this pull request Aug 13, 2026
#36 landed ScriptedRelay and nine xfail(strict=True) cases for the same
findings this branch fixes -- by design, each marker was meant to delete
itself the moment its defect was. Verified all nine flip from XFAIL to
XPASS(strict) against the fix, then removed the now-satisfied markers.

Also drops the FakeRelay.recv_returns shim and the duplicate malformed-
frame tests added before the rebase picked up #36 -- ScriptedRelay already
covers the same ground with a finer-grained double.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wired4ncer added a commit that referenced this pull request Aug 13, 2026
Two review findings on #35, both the same shape: an exception escaping `send`.
That is worse than any verdict `send` could return, because the caller expected
a `DeliveryResult` and gets an unhandled alarm instead -- and the loop never
reaches the relays listed after the one that threw. A malformed reply is a
retriable failure; it is not an exit.

`except json.JSONDecodeError` covered a frame that isn't JSON, but not a frame
that isn't *text*. websocket-client returns raw bytes for a BINARY opcode, and
`json.loads` on bytes that aren't valid UTF-8 raises `UnicodeDecodeError` -- a
ValueError, but not that subclass, and `send`'s `(OSError, WebSocketException)`
handler doesn't catch it either. A TEXT frame carrying invalid UTF-8 raises the
same thing one layer earlier, inside `recv` itself, which the widened `except
ValueError` also covers because the call already sits inside that `try`.
`RelayConnection.recv` is now typed `str | bytes` to say so, and `ScriptedRelay`
can script a bytes frame -- a double that can only emit `str` can only test the
case that already works, the same argument #36 made for its shape.

The constructor validated the `wss://` prefix and nothing after it, so
`wss://relay.example:99999`, `wss://[bad` or a truncated `wss://` constructed
cleanly and then raised a plain `ValueError` out of `create_connection` on the
alarm path. Checked with stdlib `urlsplit`, which rejects the same shapes
websocket-client's own parser does without reaching into its private `_url`
module. That is the posture the prefix check already states -- rejected at
construction, not discovered mid-publish -- applied to the rest of the URL.

Two mutations added, one per finding, and a test that a relay on a non-default
port with a path still constructs, so the URL check can't pass by rejecting
everything. Sweep is 76 now; ci.yml's count updated with it.

Refs #3

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
magicka7 added a commit that referenced this pull request Aug 13, 2026
* Give alerts a second rail that no relay can read

Implements NostrChannel against the Channel protocol (#3): NIP-17 gift-wrapped
DMs, sealed with the service's one persistent identity and wrapped under a
fresh, single-use key per message so no relay can link two alerts to the same
sender.

Adds coincurve (secp256k1 ECDH + Schnorr) and websocket-client as the two
dependencies this genuinely needed -- bech32, ChaCha20, HKDF and the NIP-44
padding scheme stay hand-rolled stdlib, each checked against the spec's own
published test vectors: NIP-19's npub examples, RFC 8439's ChaCha20 vectors,
NIP-44's full 126-vector suite, and NIP-59's real worked example -- decrypting
a gift wrap nostr-tools actually produced, to prove interop rather than only
self-consistency.

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

* Teach the relay double to misbehave, and mark what breaks when it does

`FakeRelay` can only produce `["OK", <id>, <bool>, <str>]` -- the exact shape
`NostrChannel` handles correctly, and therefore the shape that hides every way
it does not. Every gap found reviewing #35 lives outside what that double can
express, which is why a thorough suite was silent about all of them.

`ScriptedRelay` hands the raw frame to the test instead. A relay is
infrastructure we don't run: its *content* is already treated as hostile in
`_classify_note`, and its *shape* had no such treatment.

The nine cases that fail are marked `xfail(strict=True)` rather than left red,
so this lands green and each marker deletes itself the moment its defect is
fixed -- a non-strict xfail would outlive the bug it describes.

Two of them needed care to be worth having. The malformed-OK-flag test first
asserted `retriable` too, which a correct fix can defensibly set either way;
it now asserts only the false-success. The no-deadline test measured frames
rather than seconds, so an instant in-process double satisfied it by running
out of script -- `ScriptedRelay.delay` makes the wall clock move, since a
deadline that isn't measured in time isn't the thing being tested.

Verified in both directions: all nine XFAIL against this branch, and all nine
XPASS(strict) against a patched channel, so none is red for an unrelated
reason and none would stay red after the fix.

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

* Trust the relay's shape as little as its content

PR review on #35 found seven bugs on the path where a relay's frame
enters _publish_to unchecked: an uncaught JSONDecodeError on relay
disconnect, a truthy-string OK flag reporting a rejected event as
delivered, a CLOSED branch comparing against the wrong id so it could
never fire, no overall deadline on the receive loop, a non-string OK
message crashing classification, an off-curve npub accepted at
enrolment, and a truncated private key silently booting a different
identity. Fixes each with regression tests built on a FakeRelay that
can now return malformed frames.

The eighth finding -- delivery to one service-wide relay rather than
the recipient's own NIP-17 kind:10050 list -- is a feature gap, not a
bug, so it's recorded as an accepted residual in SECURITY.md and
docs/architecture.md instead of patched.

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

* Delete the xfail markers the fix just satisfied

#36 landed ScriptedRelay and nine xfail(strict=True) cases for the same
findings this branch fixes -- by design, each marker was meant to delete
itself the moment its defect was. Verified all nine flip from XFAIL to
XPASS(strict) against the fix, then removed the now-satisfied markers.

Also drops the FakeRelay.recv_returns shim and the duplicate malformed-
frame tests added before the rebase picked up #36 -- ScriptedRelay already
covers the same ground with a finer-grained double.

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

* Refuse a relay reply that isn't text, and a URL that isn't a URL

Two review findings on #35, both the same shape: an exception escaping `send`.
That is worse than any verdict `send` could return, because the caller expected
a `DeliveryResult` and gets an unhandled alarm instead -- and the loop never
reaches the relays listed after the one that threw. A malformed reply is a
retriable failure; it is not an exit.

`except json.JSONDecodeError` covered a frame that isn't JSON, but not a frame
that isn't *text*. websocket-client returns raw bytes for a BINARY opcode, and
`json.loads` on bytes that aren't valid UTF-8 raises `UnicodeDecodeError` -- a
ValueError, but not that subclass, and `send`'s `(OSError, WebSocketException)`
handler doesn't catch it either. A TEXT frame carrying invalid UTF-8 raises the
same thing one layer earlier, inside `recv` itself, which the widened `except
ValueError` also covers because the call already sits inside that `try`.
`RelayConnection.recv` is now typed `str | bytes` to say so, and `ScriptedRelay`
can script a bytes frame -- a double that can only emit `str` can only test the
case that already works, the same argument #36 made for its shape.

The constructor validated the `wss://` prefix and nothing after it, so
`wss://relay.example:99999`, `wss://[bad` or a truncated `wss://` constructed
cleanly and then raised a plain `ValueError` out of `create_connection` on the
alarm path. Checked with stdlib `urlsplit`, which rejects the same shapes
websocket-client's own parser does without reaching into its private `_url`
module. That is the posture the prefix check already states -- rejected at
construction, not discovered mid-publish -- applied to the rest of the URL.

Two mutations added, one per finding, and a test that a relay on a non-default
port with a path still constructs, so the URL check can't pass by rejecting
everything. Sweep is 76 now; ci.yml's count updated with it.

Refs #3

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Wired4ncer <102553581+Wired4ncer@users.noreply.github.com>
Co-authored-by: Wiredancer <wiredancer@reelnetwork.eu>
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