Skip to content

feat: sprout-pair-relay — ephemeral sidecar for NIP-AB device pairing#467

Merged
tlongwell-block merged 9 commits intomainfrom
pip/ephemeral-pairing-relay
May 4, 2026
Merged

feat: sprout-pair-relay — ephemeral sidecar for NIP-AB device pairing#467
tlongwell-block merged 9 commits intomainfrom
pip/ephemeral-pairing-relay

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented May 3, 2026

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:24134 events 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:

  • Isolation — compromise of the pairing relay doesn't touch the main relay
  • Simplicity — 1,044 LOC of relay logic, fully auditable in one sitting
  • Safety — defense-in-depth anti-abuse measures (see below)

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

Constraint Value Rationale
Event kind 24134 only Only NIP-AB pairing events accepted
Max frame size 4,096 bytes NIP-AB payloads are small
Connection TTL 120 seconds Matches NIP-AB session lifetime
Max connections 128 Bounded resource usage
Subscriptions 1 per connection, #p filter required One pairing session per connection

Anti-Abuse Tightenings (8 total)

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

  2. Live subscriber required (atomic delivery) — EVENTs are only accepted if the target #p has exactly ONE live subscriber. Zero → rejected. Multiple → rejected. Subscriber check + delivery happens under a single lock (no TOCTOU race). No blind dropbox, no broadcast.

  3. Strict tag shape — Events must have exactly one tag: ["p", "<64 hex>"]. No extra tags, no extra elements, no side-channel data.

  4. No extra event fields — Only the 7 NIP-01 fields allowed: id, pubkey, created_at, kind, tags, content, sig.

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

  6. Freshness windowcreated_at must be within ±120s of relay time (overflow-safe via i128 arithmetic). Precomputed batch attacks blocked.

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

  8. Schnorr signature verification — Full NIP-01 event ID recomputation (SHA-256 of canonical JSON) + Schnorr sig verification. Cryptographic authentication on every event.

Additional Hardening

  • Per-#p delivery budget — Max 12 delivered events per #p value (survives reconnects). TTL-based expiry prevents process-lifetime exhaustion. Budget check is atomic with delivery (single lock).
  • Duplicate #p subscription rejection — A second REQ for an already-subscribed #p is rejected at subscription time (atomic check under lock). Prevents late-joiner poisoning of active sessions.
  • Fail-closed capacity limits — When dedup or delivery maps hit capacity, new events are rejected rather than state being cleared. No attacker can churn valid events to wipe tracking state.

Rate Limiting (burst protection)

  • 20 messages per 10-second window (all frame types)
  • 10 EVENTs per 10-second window

Deployment Model

  • Binds loopback only (127.0.0.1)
  • Runs behind a reverse proxy that routes /pair and handles TLS + HTTP timeouts
  • Zero persistence — events exist only in-flight between matched pub/sub
  • No replay — EOSE is immediate with zero events

Stats

File LOC
src/lib.rs 1,044
src/main.rs 19
tests/integration.rs 1,400 (51 tests)
Cargo.toml 36
Total 2,499

Quality Gates

  • ✅ 51/51 integration tests passing
  • ✅ 17/17 live e2e tests passing (happy path + red team)
  • cargo clippy -D warnings clean
  • cargo fmt --check clean
  • ✅ Codex CLI review: 8/10 (post-tightenings, all actionable findings addressed)
  • ✅ Blue-team security audit with attack scenarios

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)
@tlongwell-block tlongwell-block merged commit 15bd9ba into main May 4, 2026
13 checks passed
@tlongwell-block tlongwell-block deleted the pip/ephemeral-pairing-relay branch May 4, 2026 01: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.

1 participant