feat: NIP-AB device pairing — Phase 1 (core library + CLI)#333
Merged
tlongwell-block merged 11 commits intomainfrom Apr 16, 2026
Merged
feat: NIP-AB device pairing — Phase 1 (core library + CLI)#333tlongwell-block merged 11 commits intomainfrom
tlongwell-block merged 11 commits intomainfrom
Conversation
Implement the NIP-AB device pairing protocol in sprout-core as a pure computation module (zero I/O dependencies). The protocol enables secure transfer of Nostr private keys between devices over standard relays using QR-code-initiated, end-to-end encrypted channels with visual SAS confirmation. Modules: - crypto.rs: HKDF-SHA256 derivations (session_id, SAS code, transcript hash) with constant-time comparison and pinned test vectors - types.rs: Serde-serializable pairing message types (offer, sas-confirm, payload, complete, abort) - qr.rs: nostrpair:// URI encode/decode with full percent-encoding - session.rs: PairingSession state machine (7 states, strict role/state enforcement, NIP-01 signature verification, MITM prevention via SAS) Security hardening: - NIP-01 event signature verification on all incoming events - Constant-time comparison for transcript_hash and session_id - ECDH shared secret zeroed immediately after SAS derivation - Session secret and derived material zeroed on Drop - Terminal state finality (Completed/Aborted cannot regress) - Pre-peer abort rejection (relay observers cannot kill sessions) - Single payload per session (PayloadExchanged state prevents duplicates) - Explicit target-side SAS confirmation (AwaitingConfirmation gate) - created_at timestamp randomization (0-60s jitter for metadata privacy) 52 tests, clippy clean.
Standalone CLI tool that exercises the full NIP-AB device pairing protocol over a live Nostr relay. Designed for interop testing and NIP submission — proves the protocol works outside the Sprout apps. Subcommands: - source: display QR URI, wait for target, SAS confirm, send payload - target: paste QR URI, send offer, verify SAS, receive payload - test-vectors: print all derived values from spec's fixed keys Features: - Abort-aware retry loops (invalid/undecryptable events are skipped) - Peer abort detection in all wait states - --show-secret flag gates sensitive output (off by default) - Explicit y/n SAS confirmation on both source and target sides - Configurable relay URL and nsec payload
…n, error taxonomy Address findings from crossfire review (Codex CLI + manual): - Replace hand-rolled ct_eq with subtle::ConstantTimeEq to guarantee constant-time comparison is not optimized away by the compiler - Validate relay URL schemes in decode_qr() — reject non-WebSocket schemes to prevent SSRF / transport downgrade from malicious QR codes - Add SigningError variant to PairingError — build_event() was mapping signing failures to InvalidPubkey (wrong error taxonomy) - Fix Offer doc comment: said 'Initiator → Responder' but the offer flows Target → Source per the NIP-AB spec - Remove dead --show-secret flag from source CLI; add warning that QR URI contains session secret - Fix stale 'minimal URL encoding' doc comment in qr.rs - Fix unused variable warning in tests 3 new tests for relay scheme validation. 55 tests total, clippy clean.
… URL decode, NIP-42 auth, relay ephemeral fan-out Address findings from crossfire review (Opus, Codex CLI, Gemini): Core library (sprout-core): - Replace fill(0) with zeroize::Zeroize for session_secret, session_id, sas_input, and ECDH shared secrets — prevents dead-store elimination by the compiler (nostr::SecretKey::Drop already handles ephemeral key zeroing via write_volatile) - Fix url_decode() to accumulate into Vec<u8> and convert via String::from_utf8, avoiding incorrect byte-to-char Latin-1 casting - Add PairingSession::sign_event() for relay-level operations like NIP-42 auth without exposing the ephemeral private key CLI (sprout-pairing-cli): - Add NIP-42 auth support using the session's ephemeral keys, enabling the CLI to work with Sprout and other auth-requiring relays - Wait for EOSE after subscribing before publishing the offer, ensuring the subscription is registered on the relay before events flow - Add wait_for_eose() helper for subscription readiness confirmation Relay (sprout-relay): - Add fan-out path for channel-less ephemeral events (kind 20000-29999 without h-tags), enabling NIP-AB pairing events to route between subscribers without requiring channel membership Spec: - Fix truncated source_ephemeral_privkey test vector (was 31 bytes) 55 unit tests pass, clippy clean. E2E verified over local Sprout relay.
Covers subcommand usage, local Sprout relay testing with the expect-driven E2E script, environment variables, and a protocol overview diagram.
Move the NIP-AB spec into the crate at src/pairing/NIP-AB.md so it ships with the code. Backfill all test vector placeholders with concrete values computed from the reference implementation. Update README link.
…surfacing, url encoding Three fixes from adversarial crossfire review (Claude Opus + Codex GPT-5.4): 1. AbortReason extensibility: add #[serde(other)] Unknown variant so unrecognized abort reasons from future peers deserialize instead of failing silently. Spec says unknown reasons SHOULD be treated as protocol_error — documented accordingly. 2. TranscriptMismatch surfacing: match PairingError::TranscriptMismatch explicitly in the CLI's sas-confirm loop. Previously caught by the generic 'ignoring invalid event' handler and swallowed until timeout. Now returns a clear SECURITY error immediately. 3. URL encoding: replace 35 lines of hand-rolled percent-encoding with the percent-encoding crate (already in transitive dep tree via url — zero new compiled crates). Uses NON_ALPHANUMERIC encode set which is a strict superset of the old 8-char set.
…spec upgrade Global Redis fan-out for channel-less ephemeral events: - Sentinel Uuid::nil() pattern for cross-node delivery of kind:24134 - Nil guard in create_channel_with_id + CHECK constraint in schema - Subscriber loop nil-check converting sentinel back to None - Echo suppression with mark_local_event + cache invalidation - Drop count observability parity with channel-scoped branch Zeroizing<String> for secret payloads: - Public API boundaries use Zeroizing<String> (send_payload, handle_payload) - resolve_payload() returns Zeroizing<String> from the start - build_event zeroizes serialized plaintext after encryption - decrypt_message zeroizes decrypted plaintext after parsing - Deferred ? pattern ensures zeroization on both success and error paths NIP-AB spec upgrade (474 → 737 lines): - Versions section with v=1 in QR URI and offer message - Limitations section (8-bullet honest threat model) - Cryptographic Primitives expansion (ECDH unhashed warning, constants) - Implementation Pseudocode (normative Python-like derivations) - Design Rationale (HKDF, 6-digit SAS, transcript binding, NIP-44) - Edge cases: out-of-order, duplicates, multi-relay, payload size, complete semantics, custom payloads, concurrent sessions, replay, created_at jitter (0-30s), NIP-44 v2 pinning - Code alignment: version field in qr.rs/types.rs, version rejection in handle_offer, 2048-char URI limit in decode_qr All changes crossfire-reviewed by codex CLI (gpt-5.4) to 9/10.
* origin/main: [codex] Fix authz, scope propagation, and shell-injection bugs (#320) feat(mobile): implement Activity tab with personalized feed (#337) feat(mobile): upgrade mobile_scanner to v7 (Apple Vision) (#336) feat(mobile): app branding — icon, name, launch screen (#335) fix: cancel TTS on remote human speech in multi-participant huddles (#332) feat(mobile): design refresh — navigation, search, reactions (#334) feat(desktop+acp+mcp): deterministic nested thread replies via persisted reply context (#322) feat(mobile): channel management — create, browse, join/leave, DMs, canvas (#331) fix: derive staging ports from worktree to avoid collisions (#329) fix: mentions survive copy/paste from chat into composer (#328) feat(home): add activity and agent feed sections with deep-linking (#330)
…t validation Address spec compliance gaps identified during crossfire review: Duplicate event ID tracking (NIP-AB §Duplicate Event Handling): - Add per-session HashSet<[u8; 32]> for processed event IDs - validate_event_basics() checks for duplicates (reject if seen) - record_event() called only on success paths after full acceptance - Speculative handle_abort() probes do not poison the dedup set Transcript mismatch abort (NIP-AB §Step 3): - CLI target now sends abort(sas_mismatch) before exiting on TranscriptMismatch, so source gets a clean failure signal QR input validation: - Reject all-zeros session_secret (NIP-AB §Test Vectors) - Enforce lowercase hex for pubkey and secret (NIP-AB §QR Code Format) Spec alignment: - Demote multi-relay from normative MUST to optional 'Multi-Relay Considerations' section — single-relay implementations are conformant - CLI silently discards invalid/out-of-order events (no eprintln) - Source now waits for EOSE before expecting offer (closes race) Tests: 68 total (7 new), all passing. New coverage: - speculative_abort_does_not_poison_dedup - wrong_type_message_not_recorded - complete_failure_aborts_without_recording (with has_processed assert) - duplicate_event_id_is_rejected - reject_all_zeros_session_secret - reject_uppercase_hex_in_pubkey / reject_uppercase_hex_in_secret
- Reject NIP-44 content outside 132–87472 char range before decrypt - Reject decrypted plaintext exceeding 65,535 bytes before JSON parse - Parse relay URLs with url::Url instead of prefix-matching (require valid WebSocket scheme + host) - Surface complete(success=false) in CLI instead of swallowing it - Add session expiry test (check_expired path was previously untested) - Add url crate dependency to sprout-core 69 tests pass (68 existing + 1 new expiry test).
wesbillman
added a commit
that referenced
this pull request
Apr 16, 2026
Integrate the NIP-AB pairing protocol from PR #333 into the desktop (source) and mobile (target) apps, replacing the insecure sprout:// QR code that embedded raw credentials directly. Desktop (source side): - New pairing.rs with 3 Tauri commands (start_pairing, confirm_pairing_sas, cancel_pairing) and a background WebSocket actor that drives the protocol - Multi-step PairingDialog: QR display → SAS verification → transfer → done - Token minting refactored to expose mint_token_internal for reuse - Uses nostr-compat (0.36) alias for sprout-core type compatibility Mobile (target side): - NIP-44 v2 encrypt/decrypt implementation using pointycastle - HKDF-SHA256 and secp256k1 ECDH crypto modules - NIP-AB protocol: QR URI parsing, session ID/SAS/transcript derivations - Ephemeral WebSocket with NIP-42 auth for pairing relay - SAS verification UI with large code display and confirm/deny - Backward-compatible: still accepts legacy sprout:// URIs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8 tasks
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.
Summary
Implements the NIP-AB device pairing protocol for securely transferring Nostr private keys between devices. Phase 1 delivers the core Rust library, a standalone CLI interop tool, and relay-side global fan-out for channel-less ephemeral events. Phase 2 (desktop/mobile UI integration) will follow.
What is NIP-AB?
A protocol for moving a Nostr identity from one device to another over standard relays, using:
Think of it as the Nostr equivalent of Signal device linking or Bluetooth Secure Simple Pairing — but over standard Nostr relays with no custom infrastructure.
What's in this PR
Core library (
sprout-core/src/pairing/):NIP-AB.md— Protocol spec (~750 lines) with versioning, limitations, pseudocode, design rationale, edge case coverage, and computed test vectors. Multi-relay support demoted to optional guidance ("Multi-Relay Considerations" section) — single-relay implementations are fully conformant.crypto.rs— HKDF-SHA256 derivations (session ID, SAS code, transcript hash) with constant-time comparison viasubtle::ConstantTimeEqtypes.rs— Serde-serializable pairing message types (offer with version field, sas-confirm, payload, complete, abort) with forward-compatibleAbortReasonextensibility via#[serde(other)]qr.rs—nostrpair://URI encode/decode with protocol version (v=1), 2048-char URI limit, relay scheme validation, lowercase hex enforcement, all-zeros session_secret rejectionsession.rs—PairingSessionstate machine (7 states, strict role/state enforcement,Zeroizing<String>payload API,sign_event()for NIP-42 auth, per-session duplicate event ID tracking)CLI tool (
sprout-pairing-cli/):sprout-pair source— display QR, wait for target, SAS confirm, send payloadsprout-pair target— paste QR, send offer, verify SAS, receive payloadsprout-pair test-vectors— print all derived values from spec's fixed keysZeroizing<String>throughout the secret handling pathabort(sas_mismatch)on transcript hash mismatchRelay (
sprout-relay+sprout-db):Uuid::nil()sentinel pattern — enables cross-node delivery of NIP-AB pairing events (kind:24134) without requiring channel membershipcreate_channel_with_id+CHECKconstraint in schema to prevent sentinel collision with real channel IDsNonefor correct global fan-out routingSecurity hardening
subtle::ConstantTimeEqfor transcript hash and session IDZeroizing<String>for secret payloads — deferred?pattern ensures zeroization on both success and error paths; serialized plaintext and decrypted plaintext explicitly zeroized after usezeroizeimmediately after SAS derivationDropviazeroizehandle_offerrejects unsupported versions0x02) — rejects other version bytescreated_attimestamp randomization (0–30s jitter for metadata privacy)TranscriptMismatchsurfaced as a hard security error with abort sent to peerAbortReasondeserializationTest coverage
68 unit tests covering:
complete(success: false)aborts without recording (verified viahas_processedtest accessor)E2E tested over a local Sprout relay using an
expect-driven test harness.Review process
Crossfire reviewed across multiple rounds by Claude Opus and Codex CLI:
What's next (Phase 2)
MobilePairingCard.tsxto drive the new protocol)pairing_provider.dart)How to test
See
crates/sprout-pairing-cli/README.mdfor full testing guide.