Skip to content

feat: NIP-AB device pairing — Phase 1 (core library + CLI)#333

Merged
tlongwell-block merged 11 commits intomainfrom
nip-ab-device-pairing
Apr 16, 2026
Merged

feat: NIP-AB device pairing — Phase 1 (core library + CLI)#333
tlongwell-block merged 11 commits intomainfrom
nip-ab-device-pairing

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented Apr 15, 2026

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:

  • QR code to bootstrap the connection (ephemeral pubkey + session secret + relay URL + protocol version)
  • ECDH + NIP-44 v2 for end-to-end encryption (relay sees only opaque ciphertext)
  • 6-digit SAS code for MITM prevention (user visually confirms codes match)
  • Ephemeral keypairs discarded after the session (no persistent footprint)

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 via subtle::ConstantTimeEq
  • types.rs — Serde-serializable pairing message types (offer with version field, sas-confirm, payload, complete, abort) with forward-compatible AbortReason extensibility via #[serde(other)]
  • qr.rsnostrpair:// URI encode/decode with protocol version (v=1), 2048-char URI limit, relay scheme validation, lowercase hex enforcement, all-zeros session_secret rejection
  • session.rsPairingSession state 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 payload
  • sprout-pair target — paste QR, send offer, verify SAS, receive payload
  • sprout-pair test-vectors — print all derived values from spec's fixed keys
  • NIP-42 authentication support, EOSE-gated subscription on both source and target
  • Zeroizing<String> throughout the secret handling path
  • Silent discard of invalid/out-of-order events per NIP-AB §Event Validation
  • Sends abort(sas_mismatch) on transcript hash mismatch
  • README with usage guide and local testing instructions

Relay (sprout-relay + sprout-db):

  • Global Redis fan-out for channel-less ephemeral events using Uuid::nil() sentinel pattern — enables cross-node delivery of NIP-AB pairing events (kind:24134) without requiring channel membership
  • Nil UUID guard in create_channel_with_id + CHECK constraint in schema to prevent sentinel collision with real channel IDs
  • Subscriber loop nil-check converting sentinel back to None for correct global fan-out routing

Security hardening

  • NIP-01 event signature verification on all incoming events
  • Constant-time comparison via subtle::ConstantTimeEq for transcript hash and session ID
  • Zeroizing<String> for secret payloads — deferred ? pattern ensures zeroization on both success and error paths; serialized plaintext and decrypted plaintext explicitly zeroized after use
  • ECDH shared secret zeroed via zeroize immediately after SAS derivation
  • Session secret and derived material zeroed on Drop via zeroize
  • Terminal state finality (Completed/Aborted cannot regress)
  • Pre-peer abort rejection (relay observers cannot kill sessions)
  • Protocol version enforcement — handle_offer rejects unsupported versions
  • QR URI size limit (2048 chars) to prevent DoS via QR scanning
  • NIP-44 pinned to version 2 (0x02) — rejects other version bytes
  • created_at timestamp randomization (0–30s jitter for metadata privacy)
  • QR relay URL scheme validation (rejects non-WebSocket schemes)
  • Lowercase hex enforcement and all-zeros session_secret rejection in QR decode
  • TranscriptMismatch surfaced as a hard security error with abort sent to peer
  • Per-session duplicate event ID tracking (check/record split prevents speculative probe poisoning)
  • Forward-compatible AbortReason deserialization
  • Nil UUID sentinel defended at both application and schema level

Test coverage

68 unit tests covering:

  • Full happy-path protocol (source → target → SAS → payload → complete)
  • State machine enforcement (out-of-order operations rejected)
  • Abort flows (pre-peer, post-completion, from either side)
  • QR URI round-trips with version, reserved characters, scheme validation
  • Unsupported version rejection (QR and offer)
  • Duplicate payload rejection, target confirmation gating
  • Wrong-pubkey and wrong-session-id rejection
  • Pinned test vectors for all HKDF derivations
  • Duplicate event ID tracking (speculative abort probe does not poison dedup set)
  • Wrong-type message not recorded in dedup set
  • complete(success: false) aborts without recording (verified via has_processed test accessor)
  • All-zeros session_secret rejection
  • Uppercase hex rejection in QR pubkey and secret

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:

Change Rounds Final Score
Global fan-out 3→8→9 ✅ 9/10
Zeroizing<String> 5→7→8→9 ✅ 9/10
NIP-AB spec + code 6→7→9 ✅ 9/10
Spec compliance fixes (dedup, abort, validation) 6→5→8→8→9→10 ✅ 10/10

What's next (Phase 2)

  • Desktop UI integration (refactor MobilePairingCard.tsx to drive the new protocol)
  • Mobile client integration (replace pairing_provider.dart)

How to test

cargo test -p sprout-core --lib pairing    # 68 tests
cargo clippy --workspace --all-targets -- -D warnings

# End-to-end over a local Sprout relay:
.scratch/e2e-pair-local.sh

# Manual two-terminal test:
cargo run -p sprout-pairing-cli -- source --relay ws://localhost:3000
cargo run -p sprout-pairing-cli -- target --show-secret

# Print test vectors:
cargo run -p sprout-pairing-cli -- test-vectors

See crates/sprout-pairing-cli/README.md for full testing guide.

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).
@tlongwell-block tlongwell-block merged commit b8d6a5c into main Apr 16, 2026
10 checks passed
@tlongwell-block tlongwell-block deleted the nip-ab-device-pairing branch April 16, 2026 18:56
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>
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