Give alerts a second rail that no relay can read - #35
Conversation
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>
Wired4ncer
left a comment
There was a problem hiding this comment.
Reviewed the whole diff. Splitting the verdict, because the two halves of this PR are not in the same state.
The crypto holds up. NIP-44's conversation key, message keys, padding and the extended-length-prefix amendment all match the published vectors, with the fixture's own sha256 asserted so the vectors can't drift; ChaCha20 matches RFC 8439; bech32 follows the BIP-173 reference including the no-pad rejection in _convertbits. Deliberately not using coincurve's ecdh() because it hashes the shared point is exactly right, and documenting why in the module docstring is what makes it stay right. I checked the websocket-client claim in SECURITY.md against its source too — cert_reqs=CERT_REQUIRED and check_hostname=True are the defaults, as stated. The dependency table is honest.
The relay boundary does not. All eight findings below are in channel.py, and seven of them are on the path where relay-supplied data enters the process. Seven are reproduced against this branch, not inferred.
The pattern is one thing rather than eight: _publish_to treats a relay frame's shape as trusted while treating its content as hostile. The content half is done well — _classify_note reducing free text to an enum word is the right instinct, and the tests for it are good. But frame itself is parsed, indexed and coerced with no checks, and three separate malformed replies put an uncaught exception through send(), which the Channel protocol says returns a DeliveryResult.
tests/fake_relay.py is where this got missed: its recv() can only ever return a well-formed ["OK", id, bool, str]. Every failure below lives outside what that fake can express — so the suite is thorough about this module's logic and silent about its input. Worth teaching FakeRelay to return "", a non-JSON string, a wrong-typed OK flag and a CLOSED, at which point most of these become failing tests first.
Two of the eight aren't about relays: validate_dest accepting a non-curve npub (which contradicts base.py's own docstring about failing at enrolment rather than at 3am), and the absent length check on the private key, which lets a truncated key boot the service under a silently different identity.
None of this is a reason to rework the design — the layering is right and the module split reads well. It's the last mile between "the gift wrap is correct" and "the alarm gets out".
`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>
Make the relay double capable of lying
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>
#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>
|
All eight findings addressed in 7434332 and 3ab9253. Fixed (7):
Documented, not patched (1):
On the overlap with #36: that PR landed mid-review with its own Full suite: 432 passed, ruff clean. |
Wired4ncer
left a comment
There was a problem hiding this comment.
Reviewed at head 3ab9253: checked out the branch, ran the suite (432 tests, all pass), and probed the transport paths against the real websocket-client 1.9 rather than only the test doubles.
Overall: the cryptography — the part most worth worrying about — holds up. ChaCha20, HKDF, the NIP-44 padding and bech32 are vector-checked against the published suites, and the extended 6-byte length prefix that looked at first like a spec divergence is the amended 44.md table, with matching hashes. get_conversation_key correctly avoids coincurve's hashed ecdh(). The frame[2] is True check, _classify_note's allowlist, the wss://-only constructor and the 32-byte key-length guard are all the right calls, and the relay-selection residual is documented honestly in both SECURITY.md and architecture.md.
Three findings, all at the relay-frame boundary this PR set out to harden.
1. A non-UTF-8 relay frame escapes send() as an exception
src/coldwatch/channels/nostr/channel.py:250
except json.JSONDecodeError is too narrow. websocket-client's recv() returns raw bytes for a BINARY opcode (_core.py, v1.9), and json.loads on bytes that are not valid UTF-8 raises UnicodeDecodeError — a ValueError, not a JSONDecodeError. send()'s except (OSError, websocket.WebSocketException) does not catch it either, so it propagates out of the channel.
Verified on this branch with a connection double returning b'["OK","abc",true,"\xff\xfe"]':
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 18: invalid start byte
The same happens one layer earlier for a TEXT frame carrying invalid UTF-8, where recv() itself does data.decode("utf-8").
This is the failure mode the new malformed-frame tests were written to close: the alarm is lost with an unhandled exception, and every relay after the bad one goes untried. Catching ValueError at that json.loads covers both paths.
2. A malformed relay URL raises ValueError out of send() at alert time
src/coldwatch/channels/nostr/channel.py:237 (validation at :167)
The constructor validates only the wss:// prefix, but create_connection rejects URLs that are bad beyond the scheme. Verified end-to-end on this branch — all three construct fine, then raise from send():
COLDWATCH_NOSTR_RELAYS |
raised |
|---|---|
wss://relay.example.com:99999/ |
ValueError: Port out of range 0-65535 |
wss://[bad |
ValueError: Invalid IPv6 URL |
wss:// (truncated) |
ValueError: hostname is invalid |
Because it escapes the per-relay try, every relay listed after the bad one is also skipped. A config typo silently disarms the channel and only announces itself by throwing during an alarm — which is the opposite of the "rejected at construction, not discovered mid-publish" posture the wss:// check itself states. Parsing the URL at construction would fail it at boot instead.
3. conn.close() sits outside the deadline
src/coldwatch/channels/nostr/channel.py:240
The new deadline bounds the recv loop, but conn.close() in the finally is outside it, and websocket-client's close() sets a 3-second socket timeout and waits for a CLOSE frame that a silent relay never sends. So the foreground alarm path is bounded by roughly (timeout + 3) * len(relays), not timeout: four reachable-but-silent relays at the default 15 s is ~72 s before send() returns. Lower confidence that this matters in practice than the two above, but it does undercut the bound the loop just gained.
Nothing here touches the crypto or the gift-wrap layers, and 1 and 2 are both small: widen the json.loads except, and validate the relay URL at construction.
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>
Wired4ncer
left a comment
There was a problem hiding this comment.
Approving at 138a136. CI is green on that head, mutation sweep included: 76/76 as expected (3 known equivalent), same three as before this branch.
The crypto is the part that had to be right and it is: every primitive is checked against its own published vectors rather than against itself, and the NIP-59 test decrypts a gift wrap another implementation produced, which is the difference between interop and self-consistency. The extended length prefix that looks like a divergence from 44.md is the amended table, with matching hashes. get_conversation_key avoids coincurve's hashed ecdh() for the documented reason.
Findings 1 and 2 from my review are fixed on the branch in 138a136 (a relay reply that isn't text, and a relay URL that isn't a URL — both were exceptions escaping send), with a mutation each. Note that I wrote that commit, so this approval covers my own change as well as the branch it sits on; the parts I actually reviewed at arm's length are everything up to 3ab9253.
Finding 3 is not addressed here and shouldn't hold this up: conn.close() sits outside the publish deadline, and websocket-client waits up to 3s per connection for a CLOSE frame a silent relay never sends, so the foreground alarm path is bounded by (timeout + 3) * len(relays). Worth its own issue rather than another commit on this branch.
Also still open, and documented rather than fixed — per-recipient kind:10050 relay discovery. The SECURITY.md residual is honest about what ok=True does and doesn't promise, which is the right way to ship it.
@magicka7 — merge when you're ready.
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.