Skip to content

Give alerts a second rail that no relay can read - #35

Merged
magicka7 merged 6 commits into
mainfrom
feat/nostr-channel
Aug 13, 2026
Merged

magicka7 merged 6 commits into
mainfrom
feat/nostr-channel

Conversation

@magicka7

Copy link
Copy Markdown
Collaborator

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.

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>
@magicka7
magicka7 requested a review from Wired4ncer as a code owner August 12, 2026 20:54

@Wired4ncer Wired4ncer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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".

Comment thread src/coldwatch/channels/nostr/channel.py Outdated
Comment thread src/coldwatch/channels/nostr/channel.py
Comment thread src/coldwatch/channels/nostr/channel.py
Comment thread src/coldwatch/channels/nostr/channel.py
Comment thread src/coldwatch/channels/nostr/channel.py Outdated
Comment thread src/coldwatch/channels/nostr/channel.py Outdated
Comment thread src/coldwatch/channels/nostr/channel.py
Comment thread src/coldwatch/channels/nostr/channel.py Outdated
`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 and others added 3 commits August 13, 2026 13:41
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>
@magicka7

Copy link
Copy Markdown
Collaborator Author

All eight findings addressed in 7434332 and 3ab9253.

Fixed (7):

  • _classify_note (L144) now guards isinstance(note, str) before splitting — a non-string message field no longer crashes classification.
  • Private key length (L171): __init__ now rejects anything that isn't exactly 32 bytes with MissingConfig, instead of letting coincurve left-pad a truncated key into a different identity.
  • validate_dest (L207) now constructs coincurve.PublicKey(b"\x02" + pubkey) and rejects off-curve values at enrolment, per base.py's own contract.
  • _publish_to's receive loop (L222) now tracks an overall time.monotonic() deadline, not just a per-recv() timeout.
  • json.loads failures on relay disconnect (L223 — recv() returning "" for a CLOSE opcode, or any non-JSON reply) are now caught and returned as a retriable failure instead of escaping send().
  • bool(frame[2]) (L227) is now frame[2] is True, so the string "false" can't read as delivered.
  • The CLOSED branch (L228) no longer compares against event["id"] — per NIP-01 that's a subscription id, which this channel never has, so the comparison could never fire. Any CLOSED is now treated as relay-level.

Documented, not patched (1):

  • Single service-wide relay list / no per-recipient NIP-17 kind:10050 discovery (L251). This is a feature gap, not a bug — recorded as an accepted residual in SECURITY.md and docs/architecture.md rather than papered over.

On the overlap with #36: that PR landed mid-review with its own ScriptedRelay double and nine xfail(strict=True) cases for the same seven bugs, deliberately built so each marker fails the build once its defect is fixed. Rebased onto it and confirmed all nine flip from XFAIL to XPASS against this fix — independent confirmation from a differently-written test double — then removed the now-satisfied markers and my own duplicate tests in favor of ScriptedRelay's.

Full suite: 432 passed, ruff clean.

@Wired4ncer Wired4ncer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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 Wired4ncer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

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