feat: sprout-pair-relay — ephemeral sidecar for NIP-AB device pairing#467
Merged
tlongwell-block merged 9 commits intomainfrom May 4, 2026
Merged
feat: sprout-pair-relay — ephemeral sidecar for NIP-AB device pairing#467tlongwell-block merged 9 commits intomainfrom
tlongwell-block merged 9 commits intomainfrom
Conversation
Standalone binary that accepts WebSocket connections, matches kind:24134 events against live #p-filtered subscriptions, and forwards matches to subscribers. No persistence, no auth, no history. Designed to run as a loopback sidecar behind a reverse proxy, enabling NIP-AB device pairing handshakes on otherwise private relays. - 676 LOC lib, 19 LOC binary entry point - 51 integration tests (1,214 LOC) - 128 max connections, 120s hard timeout, 4 KiB frame limit - Rate limiting: 20 msg/10s total, 10 EVENT/10s - parking_lot Mutex for non-poisoning subscription state
Document the reverse proxy requirement (slowloris mitigation, path routing, TLS termination) and the intentional security model (no sig verification, no persistence, bounded resources).
Harden the ephemeral pairing relay against exfiltration and covert communications abuse while preserving NIP-AB pairing functionality: 1. Hard cap: 6 events per connection (NIP-AB needs at most 5-6) 2. Require exactly 1 live subscriber for delivery (no dropbox/broadcast) 3. Strict tag shape: exactly [["p", "<hex>"]] (no side-channel tags) 4. Reject extra top-level event fields (no plaintext stuffing) 5. NIP-44 v2 content structure validation (version byte + min size) 6. Freshness window: created_at within ±120s of now 7. Event ID deduplication (bounded LRU, 1024 cap) 8. Schnorr signature + NIP-01 event ID verification Net effect: an attacker gets ~18 KiB total per 120s connection, delivered only to a single live subscriber on an exact ephemeral pubkey match, with cryptographic authentication on every event. New dependencies: secp256k1 0.29 (sig verify), sha2 0.10 (event ID). Both already in workspace lockfile.
…ng, per-#p budget - Replace separate subscriber_count()+fanout() with atomic deliver_single() that holds the lock throughout (fixes TOCTOU race) - Move dedup insertion AFTER signature verification (prevents dedup poisoning) - Add per-#p delivered event budget (MAX_DELIVERED_PER_P=12) on the Relay struct so reconnecting senders can't reset their budget - Return OK false when try_send fails (honest delivery reporting)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
sprout-pair-relay, a standalone binary that serves as a minimal ephemeral relay purpose-built for NIP-AB device pairing handshakes.It accepts WebSocket connections, matches incoming
kind:24134events against live#p-filtered subscriptions, and forwards matches to the subscriber. Nothing else — no persistence, no auth, no history.Why
NIP-AB pairing requires two devices to exchange ephemeral key material in real time. Private relays (like Sprout's) can't serve this role without exposing auth flows to unauthenticated pairing clients. This sidecar solves that: it runs on loopback behind a reverse proxy (routed via
/pair), accepting only the narrow slice of Nostr protocol needed for the handshake.By keeping it as a separate binary with zero shared state, we get:
Anti-Abuse Measures
The relay is hardened against misuse as an exfiltration channel or covert communications pipe. An attacker gets at most 6 events × ~3 KiB × 1 session = ~18 KiB total, delivered only if someone is actively listening on the exact ephemeral pubkey, with no replay.
Protocol Constraints
24134only#pfilter requiredAnti-Abuse Tightenings (8 total)
6-event session cap — At most 6 accepted EVENTs per connection lifetime. NIP-AB needs 5-6 events total. Turns the relay from a "short chat pipe" into a "single handshake envelope."
Live subscriber required (atomic delivery) — EVENTs are only accepted if the target
#phas exactly ONE live subscriber. Zero → rejected. Multiple → rejected. Subscriber check + delivery happens under a single lock (no TOCTOU race). No blind dropbox, no broadcast.Strict tag shape — Events must have exactly one tag:
["p", "<64 hex>"]. No extra tags, no extra elements, no side-channel data.No extra event fields — Only the 7 NIP-01 fields allowed:
id,pubkey,created_at,kind,tags,content,sig.NIP-44 content validation — Content must be valid standard base64, decode to bytes starting with
0x02(NIP-44 v2), minimum 99 decoded bytes. Arbitrary plaintext rejected.Freshness window —
created_atmust be within ±120s of relay time (overflow-safe via i128 arithmetic). Precomputed batch attacks blocked.Event ID deduplication (atomic reservation) — IDs are optimistically reserved before delivery and unreserved on failure. TTL-based expiry (300s) prevents process-lifetime exhaustion. No concurrent publisher can sneak the same event through.
Schnorr signature verification — Full NIP-01 event ID recomputation (SHA-256 of canonical JSON) + Schnorr sig verification. Cryptographic authentication on every event.
Additional Hardening
#pvalue (survives reconnects). TTL-based expiry prevents process-lifetime exhaustion. Budget check is atomic with delivery (single lock).#pis rejected at subscription time (atomic check under lock). Prevents late-joiner poisoning of active sessions.Rate Limiting (burst protection)
Deployment Model
127.0.0.1)/pairand handles TLS + HTTP timeoutsStats
src/lib.rssrc/main.rstests/integration.rsCargo.tomlQuality Gates
cargo clippy -D warningscleancargo fmt --checkclean