diff --git a/Cargo.lock b/Cargo.lock index e0e948218..c0704b83a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3724,11 +3724,16 @@ dependencies = [ "chrono", "hex", "nostr", + "percent-encoding", + "rand 0.10.0", "schemars", "serde", "serde_json", + "subtle", "thiserror 2.0.18", + "url", "uuid", + "zeroize", ] [[package]] @@ -3801,6 +3806,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "sprout-pairing-cli" +version = "0.1.0" +dependencies = [ + "clap", + "futures-util", + "hex", + "nostr", + "serde_json", + "sprout-core", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.29.0", + "url", + "zeroize", +] + [[package]] name = "sprout-persona" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e196c5789..f2635e0de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/sprout-workflow", "crates/sprout-media", "crates/sprout-cli", + "crates/sprout-pairing-cli", "crates/sprout-sdk", "crates/sprout-persona", ] @@ -80,6 +81,8 @@ hmac = "0.13" # Randomness rand = "0.10" +subtle = "2.6" +zeroize = "1.8" # Concurrent data structures dashmap = "6" diff --git a/crates/sprout-core/Cargo.toml b/crates/sprout-core/Cargo.toml index c0267e313..36f3cdea6 100644 --- a/crates/sprout-core/Cargo.toml +++ b/crates/sprout-core/Cargo.toml @@ -20,5 +20,10 @@ uuid = { workspace = true } chrono = { workspace = true } hex = { workspace = true } schemars = { workspace = true, optional = true } +rand = { workspace = true } +subtle = { workspace = true } +zeroize = { workspace = true } +percent-encoding = "2.3" +url = { workspace = true } # NO tokio, NO sqlx, NO redis, NO axum — zero I/O dependencies diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index ef2a777f8..27507c471 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -79,6 +79,8 @@ pub const EPHEMERAL_KIND_MAX: u32 = 29999; // Ephemeral events (20000–29999) — Redis pub/sub only, never stored. /// Ephemeral: user presence update (online/away/offline). pub const KIND_PRESENCE_UPDATE: u32 = 20001; +/// NIP-AB: Device pairing event. Ephemeral — relay may discard after delivery. +pub const KIND_PAIRING: u32 = 24134; /// Ephemeral: typing indicator for a channel. pub const KIND_TYPING_INDICATOR: u32 = 20002; @@ -254,6 +256,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP29_GROUP_ROLES, KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, + KIND_PAIRING, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_MESSAGE_EDIT, diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index 20d08c265..736c04767 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -17,6 +17,8 @@ pub mod filter; pub mod kind; /// Network utilities — SSRF-safe IP classification. pub mod network; +/// NIP-AB device pairing — crypto primitives, message types, and errors. +pub mod pairing; /// Presence status types shared across crates. pub mod presence; /// Schnorr signature and event ID verification. diff --git a/crates/sprout-core/src/pairing/NIP-AB.md b/crates/sprout-core/src/pairing/NIP-AB.md new file mode 100644 index 000000000..6fe263b06 --- /dev/null +++ b/crates/sprout-core/src/pairing/NIP-AB.md @@ -0,0 +1,750 @@ +NIP-AB +====== + +Device Pairing +-------------- + +`draft` `optional` + +## Versions + +This NIP is versioned to allow future algorithm upgrades without breaking existing implementations. + +Currently defined versions: + +| Version | Status | Description | +|---------|--------|-------------| +| `1` | Active | secp256k1 ECDH, HKDF-SHA256, SAS-6digit, NIP-44 v2 encryption | + +The version is communicated in two places: + +1. **QR URI**: `nostrpair://?secret=&relay=&v=1` + - The `v` parameter defaults to `1` if absent (backward compatibility). + - _target_ MUST reject URIs with an unrecognized `v` value and display a human-readable error: "This QR code requires a newer version of [App]. Please update." + +2. **Offer message**: the `offer` JSON MUST include a `version` field: + ```jsonc + { + "type": "offer", + "version": 1, + "session_id": "" + } + ``` + _source_ MUST reject offers with a `version` it does not support. + +Implementations MUST NOT silently ignore an unrecognized version — they MUST surface an error to the user. + +This NIP defines a protocol for securely transferring secrets between two devices over standard Nostr relays using QR-code-initiated, end-to-end encrypted channels with visual confirmation. + +## Motivation + +Users need their Nostr identity on multiple devices. Today the options are: + +- Paste a raw `nsec` — insecure, no authentication, no encryption in transit +- Use [NIP-46](46.md) remote signing — requires the signer device to be online for every operation +- Enter a [NIP-06](06.md) mnemonic — manual, error-prone, not all clients support it + +NIP-46 solves *ongoing delegation*: the key stays on one device and signs remotely. This NIP solves *one-time transfer*: the key moves to the new device, which then operates independently. They are complementary — this NIP can even bootstrap a NIP-46 session as one of its payload types. + +This NIP provides a secure, authenticated channel between two devices that can carry any secret payload — a private key, a [NIP-46](46.md) session bootstrap, or application-specific data — without trusting the relay. + +## Terminology + +- **source**: The device that holds the secret and initiates pairing (e.g., a desktop app). +- **target**: The device that wants to receive the secret (e.g., a mobile phone). +- **pairing relay**: Any [NIP-01](01.md) compliant relay used to route pairing events. The relay learns nothing about the payload. +- **session secret**: A 32-byte random value shared via QR code, used to derive encryption keys. +- **SAS (Short Authentication String)**: A short code displayed on both devices for the user to visually confirm, preventing man-in-the-middle attacks. + +## Overview + +1. _source_ generates an ephemeral keypair and a session secret, encodes them in a QR code. +2. _target_ scans the QR code, generates its own ephemeral keypair. +3. Both devices connect to the pairing relay and exchange ephemeral public keys via `kind:24134` events. +4. Both devices derive a shared secret via ECDH and display a SAS code for the user to confirm. +5. After confirmation, _source_ sends the encrypted payload via a `kind:24134` event. +6. _target_ decrypts and imports the payload. + +All events use ephemeral keypairs that are discarded after the session. The relay sees only opaque ciphertext addressed to throwaway public keys. + +## Limitations + +This NIP provides a secure one-time transfer channel. It does not provide: + +- **No ongoing security**: once the payload is transferred, this NIP's security guarantees end. The transferred key's security depends entirely on the receiving device's storage and the user's operational security. +- **No key revocation**: there is no mechanism to invalidate a completed pairing. If the _target_ device is later compromised, the transferred key is compromised. +- **No multi-device coordination**: this NIP transfers a key to one device at a time. Managing keys across N devices requires N separate pairing sessions. +- **No relay confidentiality**: the pairing relay learns the timing and approximate frequency of pairing events, even though it cannot read the payload. For high-risk users, a private relay is recommended. +- **No post-quantum security**: the ECDH key exchange is vulnerable to a sufficiently powerful quantum computer. The NIP-44 encryption layer inherits the same limitation. +- **Physical presence assumption**: SAS verification requires the user to visually compare codes on two physical screens. An attacker with physical access to both devices simultaneously can bypass this check. +- **QR code window**: the session secret is exposed in the QR code for up to 120 seconds. Screen capture, shoulder surfing, or a compromised camera can expose it. +- **Single-use only**: this protocol is not designed for repeated or automated transfers. Each transfer requires a new QR scan and user confirmation. + +For ongoing remote signing without key transfer, use [NIP-46](46.md) instead. + +## QR Code Format + +The _source_ generates: + +- An ephemeral secp256k1 keypair (`source_ephemeral_privkey`, `source_ephemeral_pubkey`) +- A 32-byte cryptographically random `session_secret` + +The QR code encodes a URI: + +``` +nostrpair://?secret=&relay=&v=1 +``` + +- `source_ephemeral_pubkey_hex`: 64-character lowercase hex-encoded 32-byte x-only public key (as used throughout Nostr per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki)) +- `session_secret_hex`: 64-character lowercase hex-encoded 32 random bytes +- `relay`: percent-encoded WebSocket URL of the pairing relay. MUST appear at least once. MAY appear multiple times (see §Multi-Relay Considerations). +- `v`: protocol version integer (see §Versions). Defaults to `1` if absent. + +The total URI length MUST NOT exceed 2048 characters. Reject any URI that exceeds this limit (prevents DoS via QR scanning). + +Implementations MUST validate the QR URI before processing: +- `source_ephemeral_pubkey_hex` MUST be exactly 64 lowercase hex characters (32 bytes). Reject if not. +- `session_secret_hex` MUST be exactly 64 lowercase hex characters (32 bytes). Reject if not. +- `relay` MUST be a valid WebSocket URL beginning with `wss://` or `ws://`. Reject if not. +- Implementations MUST NOT process a `nostrpair://` URI that fails any of the above checks. + +Both _source_ and _target_ connect to the relay specified in the QR URI. If the relay is unreachable, the session MUST be aborted. There is no relay discovery mechanism; the QR code is the authoritative relay list. + +The QR code MUST NOT contain any private key material. If intercepted, an attacker obtains only an ephemeral public key and a session secret, which are useless without completing the handshake within the session timeout. + +Clients MAY support additional query parameters for forward compatibility. Unknown parameters MUST be ignored. + +## Event Kind + +All pairing messages use a single event kind: + +``` +kind: 24134 +``` + +This kind is in the ephemeral event range. Relays SHOULD treat these events as ephemeral and MAY delete them after delivery or after a short TTL (e.g., 5 minutes). Relays do not need any special handling for this kind — standard NIP-01 event routing is sufficient. + +## Event Structure + +All `kind:24134` events follow this structure: + +```jsonc +{ + "id": "", + "pubkey": "", + "kind": 24134, + "content": "", + "tags": [["p", ""]], + "created_at": , + "sig": "" +} +``` + +The `content` field is always encrypted using **NIP-44 version 2** (the `0x02` algorithm: secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256), as specified in [NIP-44](44.md). The conversation key is derived from the sender's ephemeral private key and the recipient's ephemeral public key. Implementations MUST use NIP-44 v2 and MUST reject events whose NIP-44 version byte is not `0x02`. + +NIP-AB does not negotiate encryption versions. If a future NIP-44 version is required, this NIP will be updated with a new version indicator. Implementations MUST NOT silently fall back to an older NIP-44 version. + +The encrypted plaintext is always a JSON object containing a `type` field that identifies the message: + +```jsonc +{ + "type": "", + // ... type-specific fields +} +``` + +Message types are: `offer`, `sas-confirm`, `payload`, `complete`, `abort`. + +There are no unencrypted type indicators in tags or other visible fields. The relay sees only the `p` tag (an ephemeral pubkey with no link to any real identity) and opaque ciphertext. + +## Event Validation + +Before processing any `kind:24134` event, implementations MUST: + +1. Validate the event `id` and `sig` per [NIP-01](01.md). +2. Validate that `pubkey` is a valid, non-zero secp256k1 curve point per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki). +3. Validate that the event contains a `p` tag whose value matches the local device's ephemeral public key. This guards against misdelivery by a malicious or buggy relay. +4. Validate that `pubkey` matches the expected peer for the current session state: + - _source_ expects events from `target_ephemeral_pubkey` (learned from the first valid `offer`). + - _target_ expects events from `source_ephemeral_pubkey` (learned from the QR code). + - Before the first valid `offer`, _source_ accepts events from any `pubkey` (since `target_ephemeral_pubkey` is not yet known), but MUST lock to that pubkey after accepting. +5. Decrypt `content` per [NIP-44](44.md). The `content` field MUST be a valid NIP-44 v2 payload (base64, 132–87472 characters per NIP-44). Events with `content` outside this range MUST be silently discarded. +6. Parse the decrypted JSON and validate the `type` field against the expected message for the current state. +7. **Out-of-order messages**: A message whose `type` does not match the expected message for the current protocol state is considered out-of-order. Out-of-order messages MUST be silently discarded; the session state MUST NOT advance. Implementations MUST NOT send an `abort` in response to an out-of-order message, as doing so would allow a relay to probe session state. + + The valid `type` for each state is: + + | State | Role | Expected `type` | + |-------|------|-----------------| + | `Waiting` | Source | `offer` | + | `Confirming` | Source | *(awaiting user; no inbound expected)* | + | `Confirming` | Target | `sas-confirm` | + | `AwaitingConfirmation` | Target | *(awaiting user; no inbound expected)* | + | `Transferring` | Target | `payload` | + | `PayloadExchanged` | Source | `complete` | + + `abort` is valid in any non-terminal state from a known peer (see §Abort). All other combinations are out-of-order and MUST be discarded. + +Events that fail any validation step MUST be silently discarded. Implementations MUST NOT reveal validation failure details to the relay or to the sender. + +### Duplicate Event Handling + +Relays MAY deliver the same event more than once (e.g., on reconnect or when multiple relay connections are active). Implementations MUST handle duplicate delivery idempotently. + +An event is a duplicate if its `id` matches an event already successfully processed in the current session. Implementations MUST track the `id` of each successfully processed event and MUST silently discard any event whose `id` has already been processed. + +Implementations SHOULD maintain a per-session set of processed event IDs. This set need not persist beyond the session lifetime (120 seconds maximum). + +A duplicate `offer` event (same `id`) received after the source has already accepted an offer MUST be discarded, not treated as a new session attempt. A duplicate `payload` event received after the target has already imported the payload MUST be discarded; the target MUST NOT re-import or re-send `complete`. + +## Pairing Protocol + +### Step 1: Source Subscribes + +After displaying the QR code, _source_ subscribes to the pairing relay for events tagged to its ephemeral public key: + +```json +["REQ", "", {"kinds": [24134], "#p": [""]}] +``` + +### Step 2: Target Sends Offer + +_target_ scans the QR code, generates its own ephemeral secp256k1 keypair (`target_ephemeral_privkey`, `target_ephemeral_pubkey`), and publishes an `offer` event: + +```jsonc +{ + "kind": 24134, + "pubkey": "", + "content": "", + "tags": [["p", ""]], + "created_at": , + // id, sig per NIP-01 +} +``` + +Encrypted plaintext: + +```jsonc +{ + "type": "offer", + "version": 1, + "session_id": "" +} +``` + +Where `session_id` is derived as: + +``` +session_id = HKDF-SHA256( + IKM = session_secret, // 32 bytes from QR code + salt = "", // empty + info = "nostr-pair-session-id", + L = 32 +) +``` + +The `session_id` proves the _target_ possesses the QR code's `session_secret` without revealing the secret on the wire. + +_source_ MUST verify the `session_id` matches its own derivation. _source_ MUST accept at most one valid `offer` per session. After accepting an offer, _source_ MUST ignore all subsequent `offer` events and MUST record `target_ephemeral_pubkey` as the only valid peer for the remainder of the session. + +### Step 3: SAS Verification + +Both devices now have each other's ephemeral public keys. Both compute: + +``` +ecdh_shared = ECDH(own_ephemeral_privkey, other_ephemeral_pubkey) +``` + +Where `ecdh_shared` is the 32-byte x-coordinate of the shared point (unhashed), as produced by standard secp256k1 scalar multiplication. + +Then: + +``` +sas_input = HKDF-SHA256( + IKM = ecdh_shared, // 32 bytes + salt = session_secret, // 32 bytes from QR code + info = "nostr-pair-sas-v1", + L = 32 +) + +sas_code = be_u32(sas_input[0..4]) mod 1000000 +``` + +Where `be_u32(bytes)` interprets the first 4 bytes of `sas_input` as a big-endian unsigned 32-bit integer. + +Both devices display the `sas_code` as a zero-padded 6-digit decimal string (e.g., `"047291"`). The user MUST visually confirm the codes match on both screens before proceeding. + +**UX requirement**: The confirmation prompt MUST clearly state what is being authorized. Example: *"You are about to transfer your Nostr identity to another device. Does your other device show: **047291**?"* with prominent Confirm and Deny buttons. + +After the user confirms on the _source_ device, _source_ publishes a `sas-confirm` event: + +```jsonc +{ + "kind": 24134, + "pubkey": "", + "content": "", + "tags": [["p", ""]], + // ... +} +``` + +Encrypted plaintext: + +```jsonc +{ + "type": "sas-confirm", + "transcript_hash": "" +} +``` + +Where `transcript_hash` binds the confirmation to the full session transcript: + +``` +transcript = session_id + || source_ephemeral_pubkey // 32 bytes, x-coordinate + || target_ephemeral_pubkey // 32 bytes, x-coordinate + || sas_input // 32 bytes + +transcript_hash = HKDF-SHA256( + IKM = transcript, // 128 bytes + salt = session_secret, + info = "nostr-pair-transcript-v1", + L = 32 +) +``` + +_target_ MUST compute the same `transcript_hash` and verify it matches before proceeding. Implementations MUST use constant-time comparison when checking `transcript_hash` to prevent timing side-channels. A mismatch indicates a MITM attack or protocol error; _target_ MUST send `abort` with reason `"sas_mismatch"` and terminate the session. + +### Step 4: Payload Transfer + +After receiving and verifying the `sas-confirm`, _source_ publishes a `payload` event: + +Encrypted plaintext: + +```jsonc +{ + "type": "payload", + "payload_type": "", + "payload": "" +} +``` + +Defined payload types: + +| `payload_type` | Description | `payload` format | +|----------------|-------------|------------------| +| `nsec` | Private key transfer | [NIP-49](49.md) `ncryptsec1...` string (recommended) or `nsec1...` bech32 | +| `bunker` | NIP-46 signer-initiated session | `bunker://...` URI as defined in [NIP-46](46.md) | +| `connect` | NIP-46 client-initiated session | `nostrconnect://...` URI as defined in [NIP-46](46.md) | +| `custom` | Application-specific data | String (see §Custom Payloads) | + +**Payload size limits**: The total serialized JSON plaintext of a `kind:24134` event's decrypted content MUST NOT exceed 65,535 bytes (the NIP-44 v2 plaintext limit). For `payload` messages, this means the `payload` field plus JSON envelope overhead (typically 50–80 bytes depending on `payload_type` and JSON escaping) must fit within this limit. In practice, `payload` values up to 65,400 bytes are safe. Implementations MUST reject (silently discard) `payload` events where the decrypted plaintext JSON exceeds 65,535 bytes. + +For the defined payload types (`nsec`, `bunker`, `connect`), payloads are expected to be well under 1,024 bytes. Implementations MAY enforce a stricter limit of 4,096 bytes for these types and SHOULD document any custom limit for `custom` payloads. + +_Source_ implementations MUST NOT construct a `payload` event whose plaintext JSON exceeds 65,535 bytes; doing so will cause NIP-44 encryption to fail. + +### Custom Payloads + +The `custom` payload type carries application-defined data. The `payload` field MUST be a string. Applications that need to transfer structured data SHOULD encode it as JSON and then serialize the JSON object to a string (i.e., JSON-in-string, consistent with Nostr convention). + +To prevent cross-application misinterpretation, applications using `custom` payloads SHOULD include an application identifier in the payload. The RECOMMENDED format is: + +```jsonc +{ + "type": "payload", + "payload_type": "custom", + "payload": "{\"app\":\"com.example.myapp\",\"version\":1,\"data\":\"...\"}" +} +``` + +The `app` field SHOULD use reverse-DNS notation to namespace the payload. Implementations that receive a `custom` payload with an unrecognized `app` value SHOULD surface this to the user rather than silently discarding it. + +`custom` payloads are subject to the general 65,535-byte plaintext limit (65,400 bytes is a safe practical bound for the `payload` field). Applications SHOULD document their expected payload size. Applications with payloads larger than 4,096 bytes SHOULD consider whether NIP-AB is the appropriate transport — NIP-AB is designed for short secrets, not bulk data transfer. + +NIP-AB does not provide a mechanism for _target_ to reject a `custom` payload based on its content. If _target_ does not understand the payload, it SHOULD send `complete` with `success: false` and inform the user. + +For `nsec` payloads using [NIP-49](49.md) `ncryptsec` format, clients SHOULD set `KEY_SECURITY_BYTE = 0x02` (client does not track provenance) unless the client can positively assert the key has never been handled insecurely, in which case `0x01` MAY be used. + +### Step 5: Completion + +_target_ decrypts the payload, imports the secret into secure storage, and SHOULD publish a `complete` event: + +```jsonc +{ "type": "complete", "success": true } +``` + +**`complete` is advisory, not required for security.** The payload transfer is complete when _target_ successfully decrypts and stores the payload. `complete` is a best-effort acknowledgment that allows _source_ to display a success confirmation to the user. + +**If _target_ crashes or disconnects after importing but before sending `complete`**: The import has succeeded. _target_ MUST NOT re-request the payload. On next launch, _target_ SHOULD display a success state (the key is present in storage). _source_ will time out waiting for `complete` and MAY display an ambiguous state ("Transfer may have succeeded — check your other device"). + +**`success: false`**: _target_ SHOULD send `complete` with `success: false` if it successfully received and decrypted the payload but failed to import it into secure storage (e.g., keychain write failed). This allows _source_ to inform the user of a partial failure. _source_ MUST NOT retry sending the payload in response to `success: false` — the session is over. The user must initiate a new pairing. + +**Source timeout for `complete`**: _source_ SHOULD wait up to 30 seconds for `complete` after sending `payload`. If `complete` is not received within this window, _source_ SHOULD display an ambiguous confirmation ("Transfer sent — verify on your other device") rather than an error. _source_ MUST NOT re-send `payload`. + +_source_ MUST process at most one `complete` event per session. Subsequent `complete` events MUST be silently discarded. + +Both devices MUST close their subscriptions and discard their ephemeral keypairs after either (a) receiving `complete`, (b) the per-step timeout expires, or (c) the session timeout (120 seconds) expires. Implementations MUST zero the ephemeral private keys and session secret from memory before freeing. + +### Implementation Pseudocode + +The following Python-like pseudocode is normative. Implementations MUST produce identical outputs for identical inputs. + +```python +# --- Key Derivation --- + +def derive_session_id(session_secret: bytes) -> bytes: + # session_secret: 32 bytes from QR code + assert len(session_secret) == 32 + return hkdf_sha256(IKM=session_secret, salt=b"", info=b"nostr-pair-session-id", L=32) + +def derive_sas_input(ecdh_shared: bytes, session_secret: bytes) -> bytes: + # ecdh_shared: 32-byte x-coordinate of secp256k1 shared point (unhashed) + assert len(ecdh_shared) == 32 + assert len(session_secret) == 32 + return hkdf_sha256(IKM=ecdh_shared, salt=session_secret, info=b"nostr-pair-sas-v1", L=32) + +def derive_sas_code(sas_input: bytes) -> str: + # Returns zero-padded 6-digit decimal string + n = int.from_bytes(sas_input[0:4], byteorder='big') + return str(n % 1_000_000).zfill(6) + +def derive_transcript_hash( + session_id: bytes, + source_pubkey: bytes, # 32-byte x-coordinate + target_pubkey: bytes, # 32-byte x-coordinate + sas_input: bytes, + session_secret: bytes +) -> bytes: + assert all(len(x) == 32 for x in [session_id, source_pubkey, target_pubkey, sas_input, session_secret]) + transcript = session_id + source_pubkey + target_pubkey + sas_input # 128 bytes + return hkdf_sha256(IKM=transcript, salt=session_secret, info=b"nostr-pair-transcript-v1", L=32) + +# --- Message Encryption (wraps NIP-44) --- + +def encrypt_message(msg: dict, sender_privkey: bytes, recipient_pubkey: bytes) -> str: + # msg: dict with "type" field and type-specific fields + plaintext = json_encode(msg) # UTF-8 JSON, no trailing whitespace + conversation_key = nip44_get_conversation_key(sender_privkey, recipient_pubkey) + nonce = secure_random_bytes(32) + return nip44_encrypt(plaintext, conversation_key, nonce) + +def decrypt_message(ciphertext: str, recipient_privkey: bytes, sender_pubkey: bytes) -> dict: + conversation_key = nip44_get_conversation_key(recipient_privkey, sender_pubkey) + plaintext = nip44_decrypt(ciphertext, conversation_key) + return json_decode(plaintext) + +# --- Usage example --- +# session_secret = secure_random_bytes(32) +# session_id = derive_session_id(session_secret) +# ecdh_shared = secp256k1_ecdh(own_privkey, peer_pubkey) # x-coordinate, unhashed +# sas_input = derive_sas_input(ecdh_shared, session_secret) +# sas_code = derive_sas_code(sas_input) # display to user, e.g. "047291" +# transcript_hash = derive_transcript_hash(session_id, source_pub, target_pub, sas_input, session_secret) + +# --- Transcript Verification (target side) --- +# After receiving sas-confirm: +# expected = derive_transcript_hash(session_id, source_pub, target_pub, sas_input, session_secret) +# if not constant_time_equal(received_hash, expected): +# send_abort(reason="sas_mismatch") +# raise TranscriptMismatchError +``` + +### Abort + +Either device MAY send an `abort` message at any point during the protocol: + +Encrypted plaintext: + +```jsonc +{ + "type": "abort", + "reason": "" +} +``` + +Defined reason strings: + +| `reason` | Meaning | +|----------|---------| +| `"sas_mismatch"` | User observed mismatched SAS codes | +| `"user_denied"` | User explicitly denied the pairing | +| `"timeout"` | Session timed out | +| `"protocol_error"` | Unexpected message or validation failure | + +Upon receiving an `abort`, the other device MUST terminate the session, discard ephemeral keys, and inform the user. Implementations MAY define additional reason strings; unknown reasons SHOULD be treated as `"protocol_error"`. + +## Protocol Diagram + +``` + Source (Desktop) Relay Target (Phone) + ──────────────── ───── ─────────────── + Generate ephemeral keypair + Generate session_secret + Display QR code + Subscribe: kind:24134 + #p: source_ephemeral_pubkey ──────► + Scan QR code + Generate ephemeral keypair + ◄─────────────────────── Publish offer + {type:"offer", session_id} + ◄────────────────────────────────── + Validate sig, pubkey, session_id + Accept offer, lock to this peer + Compute SAS code ◄─────────────────────────────────────────► Compute SAS code + Display: "047291" Display: "047291" + + [User confirms codes match on both devices] + + Publish sas-confirm ──────────────► + {type:"sas-confirm", ──────────────────────► + transcript_hash} Verify transcript_hash + + Publish payload ──────────────────► + {type:"payload", ──────────────────────► + payload_type:"nsec", Decrypt payload + payload:"ncryptsec1..."} Import to secure storage + ◄─────────────────────── Publish complete + ◄────────────────────────────────── {type:"complete"} + + Discard ephemeral keys Discard ephemeral keys + Zero session_secret Zero session_secret +``` + +## Security Considerations + +### Man-in-the-Middle Attacks + +An attacker who intercepts the QR code (e.g., by photographing the screen or creating a fake QR code) could attempt to race the legitimate _target_ and establish their own session. The SAS verification step prevents this: the attacker's ECDH shared secret will differ from the legitimate pair, producing a different SAS code. The user will observe mismatched codes and abort. + +This is the same defense used by Matrix (emoji verification), Bluetooth Secure Simple Pairing, and ZRTP. Signal's device linking omitted SAS verification and was subsequently exploited by state-level attackers who created fake QR codes to silently link unauthorized devices. + +Clients MUST display an unambiguous confirmation prompt. The prompt SHOULD explicitly state what is being authorized and display the SAS code prominently with a clear option to deny. + +### Relay Compromise + +A compromised relay can: +- **Drop events** (denial of service) — mitigated by session timeout and retry with alternate relays +- **Delay events** — mitigated by session timeout +- **Attempt MITM** — defeated by SAS verification (relay does not possess ephemeral private keys) + +A compromised relay **cannot**: +- Read the payload (NIP-44 encrypted with ECDH keys the relay does not possess) +- Forge events (events are signed by ephemeral keys; signatures are validated before processing) +- Correlate pairing sessions with real user identities (ephemeral keys are unlinked to real identities) + +### QR Code Exposure + +The QR code contains only an ephemeral public key and a session secret. If an attacker captures the QR code and races the legitimate _target_ to send the first `offer`, the _source_ will accept the attacker's offer and compute a SAS using the attacker's ephemeral key. However: + +1. The _source_ displays a SAS code derived from the ECDH shared secret with the attacker. +2. The user's physical phone (the legitimate _target_) either (a) failed to connect (if the attacker's offer was accepted first) and shows an error, or (b) is not displaying any SAS code at all. +3. The user observes that their phone does not show the expected SAS code and denies the pairing on the _source_. + +The defense is **user verification against their physical device**, not cryptographic impossibility. This is the same security model as Bluetooth Secure Simple Pairing and ZRTP: the SAS step converts a network-level MITM into a physical-presence requirement. + +The _source_ MUST reject additional `offer` events after accepting one. If the legitimate _target_'s offer arrives after an attacker's, the _target_ will receive no response and SHOULD time out. + +### Session Timeout + +Implementations MUST enforce a session timeout (recommended: 120 seconds from QR display). After timeout, the _source_ MUST discard the ephemeral keypair and session secret. A new QR code MUST be generated for a new attempt. + +### Key Material on Two Devices + +After an `nsec` transfer, the private key exists on both devices. This is an inherent tradeoff of key transfer versus remote signing ([NIP-46](46.md)). Clients SHOULD store imported keys in platform-secure storage (iOS Keychain, Android Keystore, OS-level credential managers). + +### Replay Protection + +Session secrets are random and single-use. Ephemeral keypairs are generated per session. Two independent mechanisms prevent cross-session replay: + +**1. `p` tag binding**: Every event carries a `p` tag containing the recipient's ephemeral public key. The recipient validates that this tag matches their own ephemeral public key (§Event Validation, step 3). A replayed event from session A has `p` = `source_A_ephemeral_pubkey`; session B's source has a different ephemeral key and will reject it at the `p` tag check, before any decryption is attempted. + +**2. NIP-44 key binding**: Even if the `p` tag check were bypassed, NIP-44 decryption would fail. The conversation key is derived from `ECDH(own_ephemeral_privkey, sender_pubkey)`. A replayed event encrypted for session A's keypair cannot be decrypted by session B's keypair. + +These two mechanisms are independent; either alone is sufficient to prevent cross-session replay. Together they provide defense in depth. + +**Within-session replay**: The state machine provides within-session replay protection. Once a message type has been processed and the state has advanced, a replayed copy of the same message is out-of-order and MUST be discarded (§Event Validation, item 7). The duplicate event ID check (§Duplicate Event Handling) provides an additional layer. + +### Metadata Privacy + +All pairing events use ephemeral pubkeys that are unlinked to the user's real Nostr identity. The relay cannot determine which real user is pairing devices. + +Implementations SHOULD set `created_at` to the current time minus a random value between 0 and 30 seconds. This provides metadata privacy (obscuring the exact time of each protocol step) while remaining within the timestamp acceptance window of all known relay implementations. + +Implementations MUST NOT set `created_at` to a future time. Implementations MUST NOT set `created_at` more than 60 seconds in the past, as some relays enforce a `created_at_lower_limit` (per NIP-11) and may reject events with timestamps too far in the past. + +If a relay rejects an event with an `invalid: event creation date` error (NIP-01 `OK` message), the implementation SHOULD retry with `created_at` set to the current time (no jitter). The privacy benefit of jitter is secondary to successful delivery. + +## Design Rationale + +### Why HKDF for `session_id` instead of a direct hash? + +`session_id = HKDF(session_secret, ...)` rather than `SHA256(session_secret)` provides domain separation. Using HKDF with a labeled `info` string ensures that the `session_id` output is cryptographically independent from any other value derived from `session_secret` (e.g., `sas_input`). This prevents cross-protocol attacks where an attacker tricks one derivation path into producing a value valid for another. + +### Why 6-digit decimal SAS? + +6 decimal digits provide ~20 bits of entropy (10^6 = ~2^20). An attacker who can race the legitimate target has a 1-in-1,000,000 chance of a matching SAS per attempt. The session timeout (120 seconds) and single-offer acceptance limit make brute force infeasible. Decimal was chosen over emoji (Matrix) for cross-client compatibility — emoji sets vary by platform and font, causing display inconsistencies. Decimal was chosen over 4-digit (Bluetooth) because 4 digits (1-in-10,000) is considered insufficient against targeted attacks. + +### Why `session_secret` in the QR code instead of deriving it from the ephemeral keypair? + +The `session_secret` is independent of the ephemeral keypair. This means that even if an attacker somehow learns the ephemeral private key (e.g., via a side-channel), they cannot compute the `session_id` or `sas_input` without also knowing `session_secret`. The QR code is a separate out-of-band channel; requiring knowledge of both the QR code AND the ECDH handshake provides defense-in-depth. + +### Why transcript binding (`transcript_hash`)? + +The `transcript_hash` in `sas-confirm` commits the source to the exact session parameters: the `session_id`, both ephemeral public keys, and the `sas_input`. Without this, a MITM could potentially replay a `sas-confirm` from a different session. The transcript hash ensures that the source's confirmation is bound to this specific session and cannot be replayed. + +### Why NIP-44 for event encryption instead of a custom scheme? + +NIP-44 is the Nostr standard for authenticated encryption. Using it here means NIP-AB inherits NIP-44's security audit, test vectors, and broad implementation support. A custom scheme would require separate review and implementation work in every client. + +### Audit + +An independent security audit of this protocol is planned. Until an audit is completed, implementations in high-security contexts should treat this NIP as `draft` and conduct their own review. + +## Cryptographic Primitives + +### ECDH + +`secp256k1_ecdh(priv, pub)` is scalar multiplication of point `pub` by scalar `priv`, as defined in [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki). The result is the shared point `P`; this function returns the 32-byte x-coordinate of `P` using BIP-340's `bytes(P)` encoding. The result is **not hashed**. + +⚠️ **Implementation warning**: many secp256k1 libraries (including some bindings to libsecp256k1) hash the ECDH output with SHA-256 by default. This NIP requires the **unhashed** x-coordinate. Verify your library's behavior before shipping. + +Private keys MUST be validated as scalars in range `[1, secp256k1_order - 1]`. Public keys MUST be validated as valid, non-zero curve points per BIP-340. + +### HKDF-SHA256 + +[RFC 5869](https://datatracker.ietf.org/doc/html/rfc5869) with SHA-256. + +- **Extract**: `PRK = HMAC-SHA256(salt, IKM)`. When `salt` is specified as `""` (empty string), use a zero-length byte array (not the string literal). +- **Expand**: `OKM = HKDF-Expand(PRK, info, L)` where `info` is the UTF-8 encoding of the specified string and `L` is the output length in bytes. + +### Operators and Notation + +- `||` denotes byte array concatenation with no length prefixes or delimiters. +- `x[i:j]` where `x` is a byte array returns bytes `i` (inclusive) through `j` (exclusive). +- `be_u32(x)` interprets the first 4 bytes of `x` as a big-endian unsigned 32-bit integer. + +### Constants + +| Name | Value | Description | +|------|-------|-------------| +| `SESSION_TIMEOUT` | 120 seconds | Maximum time from QR display to session completion | +| `STEP_TIMEOUT` | 30 seconds | Maximum time to wait for each protocol step | +| `SAS_DIGITS` | 6 | Number of decimal digits in SAS code | +| `SAS_MODULUS` | 1,000,000 | `10^SAS_DIGITS` | +| `SESSION_SECRET_LEN` | 32 bytes | Length of session secret | +| `MAX_URI_LEN` | 2048 characters | Maximum total length of the `nostrpair://` URI | +| `MAX_PAYLOAD_LEN` | 65,400 bytes | Safe practical maximum for the `payload` field (65,535-byte NIP-44 limit minus JSON envelope overhead) | + +## Test Vectors + +``` +session_secret (hex): + a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 + +source_ephemeral_privkey (hex): + 7f4c11a9c9d1e3b5a7f2e4d6c8b0a2f4e6d8c0b2a4f6e8d0c2b4a6f8e0d2c4b5 + +source_ephemeral_pubkey (hex): + 199e64ca60662cb2d6e91d16cb065be51ad74a6ee5f8c5b0fdc53d246611ed9a + +target_ephemeral_privkey (hex): + 3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b + +target_ephemeral_pubkey (hex): + 89a9fa762105d0aee2b19678246fe7b823aabbc4f4bf691a1ce8a70fcd36d6e4 + +session_id = HKDF-SHA256(IKM=session_secret, salt="", info="nostr-pair-session-id", L=32): + fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380 + +ecdh_shared = ECDH(source_priv, target_pub) x-coordinate: + 9b4b6d6990713d89d6d9982e506ee1bbcde6f05c54d9d2978696e8a7274d4408 + +sas_input = HKDF-SHA256(IKM=ecdh_shared, salt=session_secret, info="nostr-pair-sas-v1", L=32): + e8b03a329f3a0ac37fe7fbe929171e14b72812be67e33c5d6e193543c41798d3 + +sas_code = be_u32(sas_input[0..4]) mod 1000000: + 863346 + +transcript = session_id || source_pubkey || target_pubkey || sas_input (128 bytes) + +transcript_hash = HKDF-SHA256(IKM=transcript, salt=session_secret, info="nostr-pair-transcript-v1", L=32): + d662818ff8911fc60a2d025f8b8b4756107104e85888dd202d28db5ca2cf28d3 +``` + +Implementations MUST validate against these vectors. They can be reproduced with `sprout-pair test-vectors`. + +A future external vector file (`nip-ab.vectors.json`) with a sha256 checksum committed in this document is planned. When published, it will include categorized intermediate-value vectors for each derivation step and negative/invalid test cases. The sha256 checksum will be the canonical commitment; implementations MUST verify against the checksum before using the file. + +Implementations MUST also test rejection of invalid inputs. Examples of what to test: + +- `session_secret` with wrong length (< 32 or > 32 bytes) → MUST be rejected +- `session_secret` that is all zeros → MUST be rejected +- `offer` with `session_id` that does not match the derived value → MUST be silently discarded +- `sas-confirm` with a mismatched `transcript_hash` → MUST trigger `abort` with reason `"sas_mismatch"` +- NIP-44 ciphertext with version byte ≠ `0x02` → MUST be silently discarded +- `content` field outside the 132–87472 character range → MUST be silently discarded +- decrypted plaintext JSON exceeding 65,535 bytes → MUST be silently discarded +- Duplicate event `id` within a session → MUST be silently discarded + +## Implementation Notes + +### Choosing a Pairing Relay + +The _source_ encodes the relay URL in the QR code. Implementations MAY: +- Use the user's preferred relay from [NIP-65](65.md) +- Use a hardcoded default relay +- Allow the user to choose + +The protocol is secure regardless of relay trustworthiness. For additional metadata privacy, a relay that supports [NIP-42](42.md) AUTH is preferred but not required. + +### SAS Display + +Implementations MUST display the SAS code as a zero-padded 6-digit decimal number (e.g., `047291`). Implementations MAY additionally display an emoji representation for improved usability, but the 6-digit decimal MUST always be shown as the canonical representation to ensure cross-client compatibility. + +### Secure Storage + +After importing a key, clients MUST store it in platform-secure storage: +- **iOS**: Keychain Services with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` +- **Android**: Android Keystore or EncryptedSharedPreferences +- **Desktop**: OS credential manager or encrypted keyring + +### Error Handling + +If _source_ receives an `offer` with an invalid `session_id`, it MUST silently ignore it and continue waiting for a valid offer (up to the session timeout). + +If either device receives an event with an unexpected `type` for the current state, it MUST silently discard it (see §Event Validation, item 7 — out-of-order messages). Implementations MUST NOT send `abort` in response to an out-of-order message. + +If either device does not receive the expected next message within a reasonable time (recommended: 30 seconds per step), it SHOULD send an `abort` with reason `"timeout"` and terminate the session. + +### Concurrent Sessions + +**Source**: A _source_ implementation MAY run multiple pairing sessions simultaneously. Each session MUST use a distinct ephemeral keypair and session secret, and therefore a distinct QR code. Sessions are fully independent — an event addressed to one session's ephemeral pubkey cannot affect another session. Implementations SHOULD limit the number of concurrent active sessions to a small number (recommended: 3) to prevent resource exhaustion. + +**Target**: A _target_ implementation MAY scan multiple QR codes and run multiple pairing sessions simultaneously. Each session is independent. However, importing the same payload type (e.g., `nsec`) from two concurrent sessions is application-defined behavior; implementations SHOULD prompt the user to confirm each import individually. + +**Session isolation**: Because each session uses independent ephemeral keypairs, there is no cryptographic interaction between concurrent sessions. A compromised or malicious session cannot affect the security of other sessions. + +**UX recommendation**: Implementations SHOULD display each active session distinctly (e.g., by SAS code) so the user can match the correct QR code to the correct device. + +## Multi-Relay Considerations + +The QR URI format supports multiple `relay` parameters for redundancy. Multi-relay support is OPTIONAL — implementations that use a single relay are fully conformant. The guidance below is for implementations that choose to support multiple relays. + +**Recommended relay count**: 1–3 relay URLs. More than 3 increases QR code size and connection overhead without proportional benefit. + +**Source behavior**: _source_ SHOULD subscribe to **all** listed relays simultaneously. This ensures _target_ can reach _source_ regardless of which relay _target_ connects to first. Subscribing to all relays has no privacy cost since all events use ephemeral pubkeys. + +**Target behavior**: _target_ SHOULD attempt to connect to listed relays in parallel and use the first relay that both (a) accepts the WebSocket connection and (b) successfully delivers the subscription (confirmed by receiving an `EOSE` or the first event). If a relay connection fails after the session is underway, _target_ MAY attempt the next relay in the list; however, _target_ MUST NOT construct a new `offer` event. If _target_ needs to reach _source_ via a different relay, _target_ SHOULD re-publish the **same signed `offer` event** (identical bytes, same event ID) to the new relay. This is safe because the event is already signed and addressed to `source_ephemeral_pubkey`; _source_ will deduplicate by event ID if it receives the offer on multiple relays. + +**Cross-relay delivery**: Because _source_ subscribes to all listed relays, events published by _target_ to any listed relay will be received by _source_. The protocol is relay-agnostic: _source_ and _target_ do not need to be connected to the same relay simultaneously. + +**Fallback**: If all listed relays fail, the session MUST be aborted. There is no relay discovery mechanism; the QR code is the authoritative relay list. + +## Relation to Other NIPs + +- [NIP-01](01.md): All pairing events are valid NIP-01 events. +- [NIP-44](44.md): Used for all encryption within pairing events. +- [NIP-46](46.md): This NIP can bootstrap a NIP-46 session via the `bunker` or `connect` payload types. NIP-46 provides ongoing remote signing; this NIP provides one-time secure transfer. They are complementary. +- [NIP-49](49.md): Recommended format for `nsec` payloads. +- [NIP-59](59.md): Gift Wrap uses ephemeral keys for metadata privacy; this NIP uses ephemeral keys for session isolation. Both demonstrate the pattern of throwaway Nostr identities for protocol-level operations. diff --git a/crates/sprout-core/src/pairing/crypto.rs b/crates/sprout-core/src/pairing/crypto.rs new file mode 100644 index 000000000..4a36e74c5 --- /dev/null +++ b/crates/sprout-core/src/pairing/crypto.rs @@ -0,0 +1,426 @@ +//! NIP-AB HKDF-SHA256 key derivation primitives. +//! +//! All functions are pure (no I/O, no side effects) and operate on fixed-size +//! `[u8; 32]` arrays. The underlying HKDF implementation is +//! [`nostr::util::hkdf`], which uses `bitcoin::hashes` internally. +//! +//! # Derivation overview +//! +//! ```text +//! session_secret (32 bytes, random) +//! │ +//! ├─► derive_session_id → session_id (HKDF, salt=[], info="nostr-pair-session-id") +//! │ +//! ├─► derive_sas(ecdh_shared, …) +//! │ ├─ sas_input (HKDF, salt=session_secret, info="nostr-pair-sas-v1") +//! │ └─ sas_code = be_u32(sas_input[0..4]) % 1_000_000 +//! │ +//! └─► derive_transcript_hash(session_id, src_pk, tgt_pk, sas_input, …) +//! └─ transcript_hash (HKDF, salt=session_secret, +//! info="nostr-pair-transcript-v1") +//! ``` + +use nostr::hashes::Hash as _; +use nostr::util::hkdf; + +// ── HKDF info strings ──────────────────────────────────────────────────────── + +const INFO_SESSION_ID: &[u8] = b"nostr-pair-session-id"; +const INFO_SAS: &[u8] = b"nostr-pair-sas-v1"; +const INFO_TRANSCRIPT: &[u8] = b"nostr-pair-transcript-v1"; + +// ── Internal helper ─────────────────────────────────────────────────────────── + +/// Run HKDF-SHA256(IKM=`ikm`, salt=`salt`, info=`info`) and return 32 bytes. +/// +/// Uses `nostr::util::hkdf::{extract, expand}` directly so we don't pull in +/// an extra `hkdf` crate dependency. +fn hkdf32(salt: &[u8], ikm: &[u8], info: &[u8]) -> [u8; 32] { + let prk = hkdf::extract(salt, ikm); + let okm = hkdf::expand(&prk.to_byte_array(), info, 32); + // HKDF-Expand with L=32 and SHA-256 (HashLen=32) always produces exactly + // 32 bytes (one iteration, truncated to L). Copy into a fixed-size array + // without expect/unwrap. + let mut out = [0u8; 32]; + out.copy_from_slice(&okm[..32]); + out +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Derive the session ID from the session secret. +/// +/// ```text +/// session_id = HKDF-SHA256(IKM=session_secret, salt=[], info="nostr-pair-session-id", L=32) +/// ``` +/// +/// The session ID is safe to share publicly (e.g., in the QR code or as a +/// Nostr event tag). It uniquely identifies the pairing session without +/// revealing the secret. +pub fn derive_session_id(session_secret: &[u8; 32]) -> [u8; 32] { + hkdf32(b"", session_secret, INFO_SESSION_ID) +} + +/// Derive the Short Authentication String (SAS) code and the raw SAS input. +/// +/// ```text +/// sas_input = HKDF-SHA256(IKM=ecdh_shared, salt=session_secret, info="nostr-pair-sas-v1", L=32) +/// sas_code = be_u32(sas_input[0..4]) mod 1_000_000 +/// ``` +/// +/// Returns `(sas_code, sas_input)`. The caller needs `sas_input` to compute +/// the transcript hash — see [`derive_transcript_hash`]. +/// +/// `ecdh_shared` is the raw 32-byte x-coordinate from +/// `nostr::util::generate_shared_key(own_secret, other_pubkey)`. +pub fn derive_sas(ecdh_shared: &[u8; 32], session_secret: &[u8; 32]) -> (u32, [u8; 32]) { + let sas_input = hkdf32(session_secret, ecdh_shared, INFO_SAS); + let sas_code = + u32::from_be_bytes([sas_input[0], sas_input[1], sas_input[2], sas_input[3]]) % 1_000_000; + (sas_code, sas_input) +} + +/// Derive the transcript hash that binds all session parameters together. +/// +/// ```text +/// transcript = session_id ‖ source_pubkey ‖ target_pubkey ‖ sas_input (128 bytes) +/// transcript_hash = HKDF-SHA256(IKM=transcript, salt=session_secret, +/// info="nostr-pair-transcript-v1", L=32) +/// ``` +/// +/// Both parties must independently compute this value and compare it before +/// exchanging the actual payload. A mismatch means the session is compromised. +/// +/// `sas_input` is the second return value of [`derive_sas`]. +pub fn derive_transcript_hash( + session_id: &[u8; 32], + source_pubkey: &[u8; 32], + target_pubkey: &[u8; 32], + sas_input: &[u8; 32], + session_secret: &[u8; 32], +) -> [u8; 32] { + // Concatenate into a 128-byte transcript. + let mut transcript = [0u8; 128]; + transcript[0..32].copy_from_slice(session_id); + transcript[32..64].copy_from_slice(source_pubkey); + transcript[64..96].copy_from_slice(target_pubkey); + transcript[96..128].copy_from_slice(sas_input); + + hkdf32(session_secret, &transcript, INFO_TRANSCRIPT) +} + +/// Format a SAS code as a zero-padded 6-digit string. +/// +/// # Examples +/// ``` +/// use sprout_core::pairing::crypto::format_sas; +/// assert_eq!(format_sas(291), "000291"); +/// assert_eq!(format_sas(47291), "047291"); +/// assert_eq!(format_sas(999999), "999999"); +/// assert_eq!(format_sas(0), "000000"); +/// ``` +pub fn format_sas(code: u32) -> String { + format!("{code:06}") +} + +/// Constant-time comparison of two 32-byte arrays. +/// +/// Returns `true` iff all bytes are equal. Uses [`subtle::ConstantTimeEq`] +/// to guarantee the comparison is not optimized into a short-circuit by the +/// compiler, preventing timing side-channels on secret-derived values like +/// transcript hashes and session IDs. +pub fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { + use subtle::ConstantTimeEq; + a.ct_eq(b).into() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Test vector inputs (from NIP-AB spec) ───────────────────────────────── + + /// session_secret = 0xa1b2c3d4… + fn session_secret() -> [u8; 32] { + hex_to_32("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2") + } + + /// source ephemeral private key bytes (used to derive pubkey for transcript test) + fn source_privkey_bytes() -> [u8; 32] { + hex_to_32("7f4c11a9c9d1e3b5a7f2e4d6c8b0a2f4e6d8c0b2a4f6e8d0c2b4a6f8e0d2c4b5") + } + + /// target ephemeral private key bytes + fn target_privkey_bytes() -> [u8; 32] { + hex_to_32("3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b") + } + + fn hex_to_32(s: &str) -> [u8; 32] { + let bytes = hex::decode(s).expect("valid hex"); + bytes.try_into().expect("32 bytes") + } + + fn bytes_to_hex(b: &[u8]) -> String { + hex::encode(b) + } + + // ── session_id derivation ───────────────────────────────────────────────── + + #[test] + fn session_id_is_deterministic() { + let secret = session_secret(); + let id1 = derive_session_id(&secret); + let id2 = derive_session_id(&secret); + assert_eq!(id1, id2, "session_id must be deterministic"); + } + + #[test] + fn session_id_is_32_bytes() { + let id = derive_session_id(&session_secret()); + assert_eq!(id.len(), 32); + } + + #[test] + fn session_id_differs_from_secret() { + let secret = session_secret(); + let id = derive_session_id(&secret); + assert_ne!(id, secret, "session_id must not equal the raw secret"); + } + + #[test] + fn session_id_test_vector() { + let id = derive_session_id(&session_secret()); + assert_eq!( + bytes_to_hex(&id), + "fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380", + "session_id must match NIP-AB spec test vector" + ); + } + + // ── SAS derivation ──────────────────────────────────────────────────────── + + #[test] + fn sas_code_is_six_digits() { + // Use a synthetic ECDH shared secret (just some fixed bytes). + let ecdh = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"); + let (code, _) = derive_sas(&ecdh, &session_secret()); + assert!(code < 1_000_000, "SAS code must be < 1_000_000, got {code}"); + } + + #[test] + fn sas_is_deterministic() { + let ecdh = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"); + let (code1, input1) = derive_sas(&ecdh, &session_secret()); + let (code2, input2) = derive_sas(&ecdh, &session_secret()); + assert_eq!(code1, code2); + assert_eq!(input1, input2); + } + + #[test] + fn sas_changes_with_different_ecdh() { + let ecdh1 = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"); + let ecdh2 = hex_to_32("ff02030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"); + let (code1, _) = derive_sas(&ecdh1, &session_secret()); + let (code2, _) = derive_sas(&ecdh2, &session_secret()); + assert_ne!( + code1, code2, + "different ECDH inputs must produce different SAS codes" + ); + } + + #[test] + fn sas_with_real_ecdh_keys() { + use nostr::{Keys, SecretKey}; + + let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key"); + let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key"); + let src_keys = Keys::new(src_sk); + let tgt_keys = Keys::new(tgt_sk); + + // ECDH: source computes shared key with target's pubkey + let ecdh_from_src = + nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key()); + // ECDH: target computes shared key with source's pubkey (must match) + let ecdh_from_tgt = + nostr::util::generate_shared_key(tgt_keys.secret_key(), &src_keys.public_key()); + + assert_eq!(ecdh_from_src, ecdh_from_tgt, "ECDH must be symmetric"); + + let (code, sas_input) = derive_sas(&ecdh_from_src, &session_secret()); + println!("sas_code = {}", format_sas(code)); + println!("sas_input = {}", bytes_to_hex(&sas_input)); + + assert!(code < 1_000_000); + } + + // ── transcript_hash derivation ──────────────────────────────────────────── + + #[test] + fn transcript_hash_is_deterministic() { + use nostr::{Keys, SecretKey}; + + let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key"); + let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key"); + let src_keys = Keys::new(src_sk); + let tgt_keys = Keys::new(tgt_sk); + + let session_id = derive_session_id(&session_secret()); + let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key()); + let (_, sas_input) = derive_sas(&ecdh, &session_secret()); + + let src_pk: [u8; 32] = src_keys.public_key().to_bytes(); + let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes(); + + let h1 = + derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret()); + let h2 = + derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret()); + assert_eq!(h1, h2); + } + + /// Full test vector suite — all values pinned against the NIP-AB spec. + #[test] + fn all_test_vectors() { + use nostr::{Keys, SecretKey}; + + let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key"); + let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key"); + let src_keys = Keys::new(src_sk); + let tgt_keys = Keys::new(tgt_sk); + + // Pubkeys + assert_eq!( + bytes_to_hex(&src_keys.public_key().to_bytes()), + "199e64ca60662cb2d6e91d16cb065be51ad74a6ee5f8c5b0fdc53d246611ed9a" + ); + assert_eq!( + bytes_to_hex(&tgt_keys.public_key().to_bytes()), + "89a9fa762105d0aee2b19678246fe7b823aabbc4f4bf691a1ce8a70fcd36d6e4" + ); + + // ECDH + let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key()); + assert_eq!( + bytes_to_hex(&ecdh), + "9b4b6d6990713d89d6d9982e506ee1bbcde6f05c54d9d2978696e8a7274d4408" + ); + + // Session ID + let session_id = derive_session_id(&session_secret()); + assert_eq!( + bytes_to_hex(&session_id), + "fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380" + ); + + // SAS + let (sas_code, sas_input) = derive_sas(&ecdh, &session_secret()); + assert_eq!( + bytes_to_hex(&sas_input), + "e8b03a329f3a0ac37fe7fbe929171e14b72812be67e33c5d6e193543c41798d3" + ); + assert_eq!(format_sas(sas_code), "863346"); + + // Transcript hash + let src_pk = src_keys.public_key().to_bytes(); + let tgt_pk = tgt_keys.public_key().to_bytes(); + let transcript_hash = + derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret()); + assert_eq!( + bytes_to_hex(&transcript_hash), + "d662818ff8911fc60a2d025f8b8b4756107104e85888dd202d28db5ca2cf28d3" + ); + } + + #[test] + fn transcript_hash_sensitive_to_pubkey_order() { + use nostr::{Keys, SecretKey}; + + let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key"); + let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key"); + let src_keys = Keys::new(src_sk); + let tgt_keys = Keys::new(tgt_sk); + + let session_id = derive_session_id(&session_secret()); + let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key()); + let (_, sas_input) = derive_sas(&ecdh, &session_secret()); + + let src_pk: [u8; 32] = src_keys.public_key().to_bytes(); + let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes(); + + let h_correct = + derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret()); + // Swap source and target — must produce a different hash. + let h_swapped = + derive_transcript_hash(&session_id, &tgt_pk, &src_pk, &sas_input, &session_secret()); + assert_ne!( + h_correct, h_swapped, + "transcript_hash must be sensitive to pubkey order" + ); + } + + // ── format_sas ──────────────────────────────────────────────────────────── + + #[test] + fn format_sas_zero_padding() { + assert_eq!(format_sas(0), "000000"); + assert_eq!(format_sas(1), "000001"); + assert_eq!(format_sas(291), "000291"); + assert_eq!(format_sas(47291), "047291"); + assert_eq!(format_sas(999999), "999999"); + } + + #[test] + fn format_sas_always_six_chars() { + for code in [0u32, 1, 99, 1000, 99999, 100000, 999999] { + let s = format_sas(code); + assert_eq!(s.len(), 6, "format_sas({code}) = {s:?} (expected 6 chars)"); + assert!(s.chars().all(|c| c.is_ascii_digit()), "all digits: {s}"); + } + } + + // ── Full round-trip consistency ─────────────────────────────────────────── + + #[test] + fn full_derivation_round_trip() { + use nostr::{Keys, SecretKey}; + + // Simulate both sides of the pairing independently deriving the same values. + let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key"); + let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key"); + let src_keys = Keys::new(src_sk); + let tgt_keys = Keys::new(tgt_sk); + let secret = session_secret(); + + // Both sides derive the same session_id. + let session_id = derive_session_id(&secret); + + // Both sides compute ECDH (symmetric). + let ecdh_src = + nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key()); + let ecdh_tgt = + nostr::util::generate_shared_key(tgt_keys.secret_key(), &src_keys.public_key()); + assert_eq!(ecdh_src, ecdh_tgt, "ECDH must be symmetric"); + + // Both sides derive the same SAS. + let (code_src, sas_input_src) = derive_sas(&ecdh_src, &secret); + let (code_tgt, sas_input_tgt) = derive_sas(&ecdh_tgt, &secret); + assert_eq!(code_src, code_tgt, "SAS codes must match"); + assert_eq!(sas_input_src, sas_input_tgt, "sas_input must match"); + + // Both sides derive the same transcript hash (using the agreed pubkey ordering). + let src_pk: [u8; 32] = src_keys.public_key().to_bytes(); + let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes(); + + let th_src = derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input_src, &secret); + let th_tgt = derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input_tgt, &secret); + assert_eq!(th_src, th_tgt, "transcript hashes must match"); + + println!( + "✅ Round-trip OK: sas={} transcript={}", + format_sas(code_src), + bytes_to_hex(&th_src) + ); + } +} diff --git a/crates/sprout-core/src/pairing/mod.rs b/crates/sprout-core/src/pairing/mod.rs new file mode 100644 index 000000000..4b5e03eff --- /dev/null +++ b/crates/sprout-core/src/pairing/mod.rs @@ -0,0 +1,80 @@ +//! NIP-AB device pairing — crypto primitives, message types, and error types. +//! +//! NIP-AB enables two Nostr devices to securely exchange a secret (e.g., an +//! `nsec` or a NIP-46 bunker connection string) over an untrusted relay, using: +//! +//! 1. **HKDF-SHA256** for all key derivation (session ID, SAS code, transcript hash). +//! 2. **ECDH** (via [`nostr::util::generate_shared_key`]) for the shared secret. +//! 3. **NIP-44 v2** for encrypting the message payloads. +//! 4. **Short Authentication String (SAS)** for out-of-band confirmation. +//! +//! # Module layout +//! +//! | Module | Contents | +//! |--------|----------| +//! | [`crypto`] | Pure HKDF derivation functions | +//! | [`types`] | Serde-serializable pairing message types | +//! +//! # Error handling +//! +//! All fallible operations in the pairing flow return [`PairingError`]. + +pub mod crypto; +pub mod qr; +pub mod session; +pub mod types; + +pub use qr::QrPayload; +pub use session::{PairingSession, Role, SessionState}; +pub use types::{AbortReason, PairingMessage, PayloadType}; + +use thiserror::Error; + +/// Errors that can occur during a NIP-AB pairing session. +#[derive(Debug, Error)] +pub enum PairingError { + /// The scanned QR URI was not a valid NIP-AB pairing URI. + #[error("invalid QR URI: {0}")] + InvalidQr(String), + + /// The session ID extracted from a message was not a valid 32-byte hex string. + #[error("invalid session ID")] + InvalidSessionId, + + /// The SAS code shown on both devices did not match — session must be aborted. + #[error("SAS mismatch")] + SasMismatch, + + /// The transcript hash received from the peer did not match the locally computed value. + #[error("transcript hash mismatch")] + TranscriptMismatch, + + /// A message arrived out of sequence or with the wrong type for the current state. + #[error("unexpected message type: expected {expected}, got {got}")] + UnexpectedMessage { + /// The message type that was expected at this point in the protocol. + expected: String, + /// The message type that was actually received. + got: String, + }, + + /// The pairing session exceeded its time limit without completing. + #[error("session expired")] + SessionExpired, + + /// NIP-44 encryption or decryption failed. + #[error("NIP-44 error: {0}")] + Nip44(#[from] nostr::nips::nip44::Error), + + /// JSON serialization or deserialization failed. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// A public key string could not be parsed. + #[error("invalid pubkey: {0}")] + InvalidPubkey(String), + + /// Event signing or construction failed. + #[error("event signing failed: {0}")] + SigningError(String), +} diff --git a/crates/sprout-core/src/pairing/qr.rs b/crates/sprout-core/src/pairing/qr.rs new file mode 100644 index 000000000..0975adf58 --- /dev/null +++ b/crates/sprout-core/src/pairing/qr.rs @@ -0,0 +1,602 @@ +//! NIP-AB QR code URI encoding and decoding. +//! +//! The QR code encodes a `nostrpair://` URI that the scanning device uses to +//! bootstrap a pairing session. The URI carries: +//! +//! - The source device's ephemeral public key (hex, 64 chars) +//! - A 32-byte session secret shared between both devices (hex, 64 chars) +//! - One or more relay URLs where the pairing messages will be exchanged +//! - A protocol version (`v=1`) +//! +//! # URI format +//! +//! ```text +//! nostrpair://?secret=&relay=&v=1 +//! ``` +//! +//! Multiple relays are represented as repeated `relay=` parameters: +//! +//! ```text +//! nostrpair://abc123...?secret=def456...&relay=wss%3A%2F%2Frelay1.example.com&relay=wss%3A%2F%2Frelay2.example.com&v=1 +//! ``` +//! +//! All characters unsafe in a query-parameter value (`:`, `/`, `?`, `#`, +//! `&`, `=`, `%`, and space) are percent-encoded. + +use nostr::PublicKey; +use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; +use zeroize::Zeroize; + +use super::PairingError; + +// ── Data types ──────────────────────────────────────────────────────────────── + +/// Data encoded in the QR code displayed by the source device. +#[derive(Debug, Clone)] +pub struct QrPayload { + /// The source device's ephemeral public key. + pub source_pubkey: PublicKey, + /// 32-byte session secret shared between both devices. + /// + /// This is generated fresh for each pairing session and never reused. + pub session_secret: [u8; 32], + /// One or more relay URLs where pairing messages will be exchanged. + pub relays: Vec, + /// Protocol version. Always `1` for this implementation. + /// + /// Encoded as `v=1` in the URI. Absent in legacy URIs; defaults to `1` + /// on decode for backward compatibility. Values > 1 are rejected. + pub version: u32, +} + +/// Zero the session secret on drop using `zeroize` to prevent dead-store +/// elimination by the compiler (plain `fill(0)` can be optimized away). +impl Drop for QrPayload { + fn drop(&mut self) { + self.session_secret.zeroize(); + } +} + +// ── Encoding ────────────────────────────────────────────────────────────────── + +/// Encode a [`QrPayload`] as a `nostrpair://` URI. +/// +/// Relay URLs are percent-encoded (`:` → `%3A`, `/` → `%2F`) so they can +/// safely appear as query parameter values. +/// +/// # Example +/// +/// ``` +/// use sprout_core::pairing::qr::{QrPayload, encode_qr}; +/// use nostr::Keys; +/// +/// let keys = Keys::generate(); +/// let payload = QrPayload { +/// source_pubkey: keys.public_key(), +/// session_secret: [0u8; 32], +/// relays: vec!["wss://relay.example.com".to_string()], +/// version: 1, +/// }; +/// let uri = encode_qr(&payload); +/// assert!(uri.starts_with("nostrpair://")); +/// ``` +pub fn encode_qr(payload: &QrPayload) -> String { + let pubkey_hex = payload.source_pubkey.to_hex(); + let secret_hex = hex::encode(payload.session_secret); + + let mut uri = format!("nostrpair://{}?secret={}", pubkey_hex, secret_hex); + + for relay in &payload.relays { + uri.push_str("&relay="); + uri.push_str(&url_encode(relay)); + } + + uri.push_str("&v=1"); + + uri +} + +// ── Decoding ────────────────────────────────────────────────────────────────── + +/// Decode a `nostrpair://` URI into a [`QrPayload`]. +/// +/// # Errors +/// +/// Returns [`PairingError::InvalidQr`] if: +/// - The scheme is not `nostrpair` +/// - The public key is not a valid 64-char hex string +/// - The `secret` parameter is missing or not a valid 64-char hex string +/// - No `relay` parameters are present +pub fn decode_qr(uri: &str) -> Result { + // NIP-AB §QR Code Format: URI length MUST NOT exceed 2048 characters. + if uri.len() > 2048 { + return Err(PairingError::InvalidQr(format!( + "URI exceeds 2048-character limit ({} chars)", + uri.len() + ))); + } + + // Split scheme from the rest. + let rest = uri + .strip_prefix("nostrpair://") + .ok_or_else(|| PairingError::InvalidQr("URI must start with nostrpair://".into()))?; + + // Split pubkey from query string. + let (pubkey_hex, query) = match rest.split_once('?') { + Some((pk, q)) => (pk, q), + None => { + return Err(PairingError::InvalidQr( + "missing query string (expected ?secret=…&relay=…)".into(), + )) + } + }; + + // Validate pubkey: must be exactly 64 lowercase hex chars (NIP-AB §QR Code Format). + if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(is_lowercase_hex) { + return Err(PairingError::InvalidQr(format!( + "pubkey must be 64 lowercase hex chars, got {:?}", + pubkey_hex + ))); + } + let source_pubkey = PublicKey::from_hex(pubkey_hex) + .map_err(|e| PairingError::InvalidQr(format!("invalid pubkey: {e}")))?; + + // Parse query parameters. + let mut secret_hex: Option<&str> = None; + let mut relays: Vec = Vec::new(); + let mut version: Option = None; + + for pair in query.split('&') { + if let Some((key, value)) = pair.split_once('=') { + match key { + "secret" => secret_hex = Some(value), + "relay" => relays.push(url_decode(value)), + "v" => version = value.parse::().ok(), + _ => {} // ignore unknown params + } + } + } + + // Default to version 1 if absent (backward compat); reject unsupported versions. + let version = version.unwrap_or(1); + if version != 1 { + return Err(PairingError::InvalidQr(format!( + "unsupported protocol version {version}, expected 1" + ))); + } + + // Validate secret: must be exactly 64 hex chars. + let secret_str = secret_hex + .ok_or_else(|| PairingError::InvalidQr("missing 'secret' query parameter".into()))?; + + if secret_str.len() != 64 || !secret_str.chars().all(is_lowercase_hex) { + return Err(PairingError::InvalidQr(format!( + "secret must be 64 lowercase hex chars, got {:?}", + secret_str + ))); + } + let secret_bytes = hex::decode(secret_str) + .map_err(|e| PairingError::InvalidQr(format!("invalid secret hex: {e}")))?; + let session_secret: [u8; 32] = secret_bytes + .try_into() + .map_err(|_| PairingError::InvalidQr("secret must be exactly 32 bytes".into()))?; + + // NIP-AB §Test Vectors: all-zeros session_secret MUST be rejected. + if session_secret == [0u8; 32] { + return Err(PairingError::InvalidQr( + "session_secret must not be all zeros".into(), + )); + } + + // Must have at least one relay. + if relays.is_empty() { + return Err(PairingError::InvalidQr( + "at least one 'relay' query parameter is required".into(), + )); + } + + // Validate relay URLs — parse fully and require WebSocket scheme + host. + // Prefix-matching alone would accept malformed URLs that crash downstream. + for relay in &relays { + let parsed = url::Url::parse(relay) + .map_err(|e| PairingError::InvalidQr(format!("invalid relay URL {:?}: {e}", relay)))?; + match parsed.scheme() { + "wss" | "ws" => {} + other => { + return Err(PairingError::InvalidQr(format!( + "relay URL must use wss:// or ws:// scheme, got {:?}", + other + ))); + } + } + if parsed.host().is_none() { + return Err(PairingError::InvalidQr(format!( + "relay URL has no host: {:?}", + relay + ))); + } + } + + Ok(QrPayload { + source_pubkey, + session_secret, + relays, + version, + }) +} + +// ── URL encoding helpers ────────────────────────────────────────────────────── + +/// Percent-encode a relay URL for use as a query parameter value. +/// +/// Uses `percent-encoding` crate's `NON_ALPHANUMERIC` set, which encodes +/// everything except ASCII alphanumerics. This is a strict superset of the +/// characters unsafe in query-parameter values (`:`, `/`, `?`, `#`, `&`, +/// `=`, `%`, space) — safe by construction. +fn url_encode(s: &str) -> String { + utf8_percent_encode(s, NON_ALPHANUMERIC).to_string() +} + +/// Percent-decode a query parameter value. +/// +/// Falls back to lossy UTF-8 conversion for non-UTF-8 sequences (which +/// shouldn't appear in valid relay URLs, but we handle it safely). +fn url_decode(s: &str) -> String { + percent_decode_str(s).decode_utf8_lossy().into_owned() +} + +/// NIP-AB §QR Code Format requires lowercase hex only (`0-9`, `a-f`). +fn is_lowercase_hex(c: char) -> bool { + c.is_ascii_digit() || ('a'..='f').contains(&c) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + + fn make_payload(relays: Vec) -> QrPayload { + let keys = Keys::generate(); + QrPayload { + source_pubkey: keys.public_key(), + session_secret: [0xab; 32], + relays, + version: 1, + } + } + + // 1. Round-trip encode/decode + #[test] + fn round_trip_single_relay() { + let original = make_payload(vec!["wss://relay.example.com".to_string()]); + let uri = encode_qr(&original); + let decoded = decode_qr(&uri).expect("decode should succeed"); + + assert_eq!(original.source_pubkey, decoded.source_pubkey); + assert_eq!(original.session_secret, decoded.session_secret); + assert_eq!(original.relays, decoded.relays); + } + + // 7. Handle multiple relays + #[test] + fn round_trip_multiple_relays() { + let original = make_payload(vec![ + "wss://relay1.example.com".to_string(), + "wss://relay2.example.com".to_string(), + "wss://relay3.example.com".to_string(), + ]); + let uri = encode_qr(&original); + let decoded = decode_qr(&uri).expect("decode should succeed"); + + assert_eq!(decoded.relays.len(), 3); + assert_eq!(decoded.relays, original.relays); + } + + // 8. Handle URL-encoded relay URLs + #[test] + fn url_encoding_round_trip() { + let relay = "wss://relay.example.com/path"; + let encoded = url_encode(relay); + // NON_ALPHANUMERIC encodes dots too — stricter than necessary but safe. + assert_eq!(encoded, "wss%3A%2F%2Frelay%2Eexample%2Ecom%2Fpath"); + let decoded = url_decode(&encoded); + assert_eq!(decoded, relay); + } + + #[test] + fn round_trip_relay_with_path() { + let original = make_payload(vec!["wss://relay.example.com/nostr".to_string()]); + let uri = encode_qr(&original); + let decoded = decode_qr(&uri).expect("decode should succeed"); + assert_eq!(decoded.relays[0], "wss://relay.example.com/nostr"); + } + + // 2. Reject missing scheme + #[test] + fn reject_missing_scheme() { + let err = decode_qr("https://relay.example.com").unwrap_err(); + assert!( + matches!(err, PairingError::InvalidQr(_)), + "expected InvalidQr, got {err:?}" + ); + } + + #[test] + fn reject_wrong_scheme() { + let err = decode_qr("nostr://abc").unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + // 3. Reject missing secret + #[test] + fn reject_missing_secret() { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let relay_encoded = url_encode("wss://relay.example.com"); + let uri = format!("nostrpair://{}?relay={}", pubkey, relay_encoded); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + // 4. Reject missing relay + #[test] + fn reject_missing_relay() { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let secret = hex::encode([0xab; 32]); + let uri = format!("nostrpair://{}?secret={}", pubkey, secret); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + // 5. Reject invalid hex in pubkey + #[test] + fn reject_invalid_pubkey_hex() { + let bad_pubkey = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; // 64 chars, not hex + let secret = hex::encode([0xab; 32]); + let relay_encoded = url_encode("wss://relay.example.com"); + let uri = format!( + "nostrpair://{}?secret={}&relay={}", + bad_pubkey, secret, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + // 6. Reject invalid hex in secret + #[test] + fn reject_invalid_secret_hex() { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let bad_secret = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; // 64 chars, not hex + let relay_encoded = url_encode("wss://relay.example.com"); + let uri = format!( + "nostrpair://{}?secret={}&relay={}", + pubkey, bad_secret, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + #[test] + fn reject_short_pubkey() { + let secret = hex::encode([0xab; 32]); + let relay_encoded = url_encode("wss://relay.example.com"); + let uri = format!( + "nostrpair://abc123?secret={}&relay={}", + secret, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + #[test] + fn reject_short_secret() { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let relay_encoded = url_encode("wss://relay.example.com"); + let uri = format!( + "nostrpair://{}?secret=abc123&relay={}", + pubkey, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + #[test] + fn reject_missing_query_string() { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let uri = format!("nostrpair://{}", pubkey); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + #[test] + fn reject_non_websocket_relay_scheme() { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let secret = hex::encode([0xab; 32]); + // http:// is not a valid relay scheme + let relay_encoded = url_encode("https://evil.example.com"); + let uri = format!( + "nostrpair://{}?secret={}&relay={}", + pubkey, secret, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + #[test] + fn accept_ws_and_wss_relay_schemes() { + let payload_wss = make_payload(vec!["wss://relay.example.com".to_string()]); + let uri_wss = encode_qr(&payload_wss); + assert!(decode_qr(&uri_wss).is_ok(), "wss:// should be accepted"); + + let payload_ws = make_payload(vec!["ws://relay.example.com".to_string()]); + let uri_ws = encode_qr(&payload_ws); + assert!(decode_qr(&uri_ws).is_ok(), "ws:// should be accepted"); + } + + #[test] + fn reject_relay_with_no_scheme() { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let secret = hex::encode([0xab; 32]); + let relay_encoded = url_encode("relay.example.com"); + let uri = format!( + "nostrpair://{}?secret={}&relay={}", + pubkey, secret, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!(matches!(err, PairingError::InvalidQr(_))); + } + + #[test] + fn uri_contains_scheme_and_pubkey() { + let payload = make_payload(vec!["wss://relay.example.com".to_string()]); + let uri = encode_qr(&payload); + assert!(uri.starts_with("nostrpair://")); + assert!(uri.contains(&payload.source_pubkey.to_hex())); + assert!(uri.contains("secret=")); + assert!(uri.contains("relay=")); + } + + #[test] + fn url_decode_case_insensitive() { + // %3a and %2f (lowercase) should also decode + assert_eq!( + url_decode("wss%3a%2f%2frelay.example.com"), + "wss://relay.example.com" + ); + } + + #[test] + fn round_trip_relay_with_query_params() { + // Relay URL with query parameters containing &, =, and ? + let original = make_payload(vec![ + "wss://relay.example.com/path?token=abc&flag=1".to_string() + ]); + let uri = encode_qr(&original); + let decoded = decode_qr(&uri).expect("decode should succeed"); + assert_eq!( + decoded.relays[0], + "wss://relay.example.com/path?token=abc&flag=1" + ); + } + + #[test] + fn round_trip_relay_with_percent_and_hash() { + let original = make_payload(vec!["wss://relay.example.com/path#frag%20ment".to_string()]); + let uri = encode_qr(&original); + let decoded = decode_qr(&uri).expect("decode should succeed"); + assert_eq!( + decoded.relays[0], + "wss://relay.example.com/path#frag%20ment" + ); + } + + #[test] + fn url_encode_reserved_chars() { + let encoded = url_encode("wss://relay.com/path?a=1&b=2#frag"); + assert!(!encoded.contains('&'), "& must be encoded"); + assert!(!encoded.contains('='), "= must be encoded"); + assert!(!encoded.contains('?'), "? must be encoded"); + assert!(!encoded.contains('#'), "# must be encoded"); + let decoded = url_decode(&encoded); + assert_eq!(decoded, "wss://relay.com/path?a=1&b=2#frag"); + } + + // Version field tests + + #[test] + fn round_trip_with_version() { + let original = make_payload(vec!["wss://relay.example.com".to_string()]); + let uri = encode_qr(&original); + assert!(uri.contains("&v=1"), "URI must contain &v=1: {uri}"); + let decoded = decode_qr(&uri).expect("decode should succeed"); + assert_eq!(decoded.version, 1); + assert_eq!(original.source_pubkey, decoded.source_pubkey); + assert_eq!(original.session_secret, decoded.session_secret); + assert_eq!(original.relays, decoded.relays); + } + + #[test] + fn reject_unsupported_version() { + let payload = make_payload(vec!["wss://relay.example.com".to_string()]); + // Build a URI with v=2 manually. + let uri = encode_qr(&payload).replace("&v=1", "&v=2"); + let err = decode_qr(&uri).unwrap_err(); + assert!( + matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("unsupported protocol version 2")), + "expected unsupported version error, got {err:?}" + ); + } + + #[test] + fn default_version_when_absent() { + // Strip the &v=1 from a well-formed URI to simulate a legacy QR code. + let payload = make_payload(vec!["wss://relay.example.com".to_string()]); + let uri = encode_qr(&payload).replace("&v=1", ""); + let decoded = decode_qr(&uri).expect("legacy URI without v= should decode as version 1"); + assert_eq!(decoded.version, 1, "missing v= should default to version 1"); + } + + // ── All-zeros session_secret rejection ──────────────────────────────── + + #[test] + fn reject_all_zeros_session_secret() { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let zero_secret = "00".repeat(32); // 64 hex chars, all zeros + let relay_encoded = url_encode("wss://relay.example.com"); + let uri = format!( + "nostrpair://{}?secret={}&relay={}&v=1", + pubkey, zero_secret, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!( + matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("all zeros")), + "expected all-zeros rejection, got {err:?}" + ); + } + + // ── Lowercase hex enforcement ───────────────────────────────────────── + + #[test] + fn reject_uppercase_hex_in_pubkey() { + let keys = Keys::generate(); + // Force uppercase in the pubkey hex + let pubkey_upper = keys.public_key().to_hex().to_uppercase(); + let secret = hex::encode([0xab; 32]); + let relay_encoded = url_encode("wss://relay.example.com"); + let uri = format!( + "nostrpair://{}?secret={}&relay={}&v=1", + pubkey_upper, secret, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!( + matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("lowercase")), + "expected lowercase rejection for pubkey, got {err:?}" + ); + } + + #[test] + fn reject_uppercase_hex_in_secret() { + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let secret_upper = hex::encode([0xab; 32]).to_uppercase(); + let relay_encoded = url_encode("wss://relay.example.com"); + let uri = format!( + "nostrpair://{}?secret={}&relay={}&v=1", + pubkey, secret_upper, relay_encoded + ); + let err = decode_qr(&uri).unwrap_err(); + assert!( + matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("lowercase")), + "expected lowercase rejection for secret, got {err:?}" + ); + } +} diff --git a/crates/sprout-core/src/pairing/session.rs b/crates/sprout-core/src/pairing/session.rs new file mode 100644 index 000000000..8a58825b7 --- /dev/null +++ b/crates/sprout-core/src/pairing/session.rs @@ -0,0 +1,1344 @@ +//! NIP-AB pairing session state machine. +//! +//! A [`PairingSession`] tracks the protocol state for one side of a device +//! pairing exchange. It is pure computation — no I/O, no async. The caller +//! is responsible for relay communication and user interaction. +//! +//! # Protocol flow +//! +//! ```text +//! Source Target +//! ────── ────── +//! new_source(relay) (scan QR) +//! → (session, qr_payload) new_target(&qr) +//! → (session, offer_event) +//! handle_offer(&event) +//! → sas_code (display it) (display sas_code from session) +//! +//! [user confirms SAS match] +//! +//! confirm_sas() +//! → sas_confirm_event handle_sas_confirm(&event) +//! → sas_code (verify it) +//! send_payload(type, data) +//! → payload_event handle_payload(&event) +//! → (type, data) +//! send_complete() +//! handle_complete(&event) → complete_event +//! ``` + +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +use nostr::nips::nip44; +use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag}; +use zeroize::{Zeroize, Zeroizing}; + +use super::crypto::{ct_eq, derive_sas, derive_session_id, derive_transcript_hash, format_sas}; +use super::qr::{self, QrPayload}; +use super::types::{AbortReason, PairingMessage, PayloadType}; +use super::PairingError; + +/// Default session timeout: 120 seconds from creation. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120); + +/// NIP-AB event kind (from the kind registry). +const PAIRING_KIND: u16 = crate::kind::KIND_PAIRING as u16; + +// ── Public types ────────────────────────────────────────────────────────────── + +/// Which role this device plays in the pairing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// The device that holds the secret and initiates pairing. + Source, + /// The device that scans the QR code and receives the secret. + Target, +} + +/// Protocol state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + /// Session created, QR displayed (source) or offer sent (target). + Waiting, + /// SAS code displayed, awaiting user confirmation (source side). + Confirming, + /// Target received `sas-confirm`, awaiting explicit user approval. + /// The target must call [`PairingSession::confirm_target_sas`] to proceed. + AwaitingConfirmation, + /// SAS confirmed, payload in transit. + Transferring, + /// Payload has been sent (source) or received (target); awaiting completion. + PayloadExchanged, + /// Protocol completed successfully. + Completed, + /// Session aborted by either side. + Aborted, +} + +/// A NIP-AB device pairing session. +/// +/// Tracks protocol state for one side of the exchange. All methods that +/// produce [`Event`]s return them for the caller to publish; all methods +/// that consume events take a reference. No I/O happens inside. +pub struct PairingSession { + role: Role, + state: SessionState, + /// Ephemeral keypair for this session (discarded after). + keys: Keys, + /// 32-byte session secret from the QR code. + session_secret: [u8; 32], + /// Relay URLs for this session. + relay_urls: Vec, + /// Peer's ephemeral public key. + /// Source learns this from the offer; target learns it from the QR code. + peer_pubkey: Option, + /// Derived session ID (HKDF of session_secret). + session_id: [u8; 32], + /// SAS code (set after ECDH + HKDF). + sas_code: Option, + /// Raw SAS input bytes (needed for transcript hash). + sas_input: Option<[u8; 32]>, + /// Event IDs already processed in this session (NIP-AB §Duplicate Event Handling). + /// Duplicates are silently discarded to handle relay re-delivery. + processed_ids: HashSet<[u8; 32]>, + /// When the session was created. + created_at: Instant, + /// Maximum session lifetime. + timeout: Duration, +} + +// ── Source-side constructors and methods ─────────────────────────────────────── + +impl PairingSession { + /// Create a new source session. Returns the session and a QR payload + /// to display to the user. + pub fn new_source(relay_url: String) -> (Self, QrPayload) { + let keys = Keys::generate(); + let mut session_secret = [0u8; 32]; + rand::fill(&mut session_secret); + + let session_id = derive_session_id(&session_secret); + + let qr = QrPayload { + version: 1, + source_pubkey: keys.public_key(), + session_secret, + relays: vec![relay_url.clone()], + }; + + let session = Self { + role: Role::Source, + state: SessionState::Waiting, + keys, + session_secret, + relay_urls: vec![relay_url], + peer_pubkey: None, + session_id, + sas_code: None, + sas_input: None, + processed_ids: HashSet::new(), + created_at: Instant::now(), + timeout: DEFAULT_TIMEOUT, + }; + + (session, qr) + } + + /// (Source) Process an incoming offer event from the target. + /// + /// Validates the session ID, computes ECDH + SAS, and returns the + /// formatted SAS code to display. After this call the session is in + /// [`SessionState::Confirming`]. + pub fn handle_offer(&mut self, event: &Event) -> Result { + self.check_expired()?; + self.expect_state(SessionState::Waiting)?; + self.expect_role(Role::Source)?; + self.validate_event_basics(event)?; + + let msg = self.decrypt_message(event)?; + let (session_id_hex, version) = match &msg { + PairingMessage::Offer { + session_id, + version, + } => (session_id.clone(), *version), + other => return Err(unexpected("offer", other)), + }; + + // Reject unsupported protocol versions (NIP-AB §Versions). + if version != 1 { + return Err(PairingError::UnexpectedMessage { + expected: "version 1".into(), + got: format!("version {version}"), + }); + } + + // Verify session_id matches our derivation (constant-time). + let received_id = hex::decode(&session_id_hex) + .ok() + .and_then(|b| <[u8; 32]>::try_from(b).ok()); + match received_id { + Some(ref id) if ct_eq(id, &self.session_id) => {} + _ => return Err(PairingError::InvalidSessionId), + } + + // Lock to this peer. + let peer = event.pubkey; + self.peer_pubkey = Some(peer); + + // Compute ECDH and SAS. Zero the ECDH shared secret after derivation. + let mut ecdh = nostr::util::generate_shared_key(self.keys.secret_key(), &peer); + let (code, sas_input) = derive_sas(&ecdh, &self.session_secret); + ecdh.zeroize(); + self.sas_code = Some(code); + self.sas_input = Some(sas_input); + self.state = SessionState::Confirming; + self.record_event(event); + + Ok(format_sas(code)) + } + + /// (Source) User confirmed the SAS codes match. Build the `sas-confirm` + /// event to publish. + pub fn confirm_sas(&mut self) -> Result { + self.check_expired()?; + self.expect_state(SessionState::Confirming)?; + self.expect_role(Role::Source)?; + + let sas_input = self.sas_input.ok_or(PairingError::SasMismatch)?; + let peer = self + .peer_pubkey + .ok_or(PairingError::InvalidPubkey("no peer".into()))?; + + let transcript_hash = derive_transcript_hash( + &self.session_id, + &self.keys.public_key().to_bytes(), + &peer.to_bytes(), + &sas_input, + &self.session_secret, + ); + + let msg = PairingMessage::SasConfirm { + transcript_hash: hex::encode(transcript_hash), + }; + let event = self.build_event(&msg)?; + self.state = SessionState::Transferring; + Ok(event) + } + + /// (Source) Build the payload event carrying the secret. + pub fn send_payload( + &mut self, + payload_type: PayloadType, + payload: Zeroizing, + ) -> Result { + self.check_expired()?; + self.expect_state(SessionState::Transferring)?; + self.expect_role(Role::Source)?; + + let mut msg = PairingMessage::Payload { + payload_type, + payload: (*payload).clone(), + }; + // Defer `?` so the transient clone is zeroized on both success and error. + let result = self.build_event(&msg); + if let PairingMessage::Payload { + ref mut payload, .. + } = msg + { + payload.zeroize(); + } + let event = result?; + self.state = SessionState::PayloadExchanged; + Ok(event) + } + + /// (Source) Process the `complete` event from the target. + pub fn handle_complete(&mut self, event: &Event) -> Result<(), PairingError> { + self.check_expired()?; + self.expect_state(SessionState::PayloadExchanged)?; + self.expect_role(Role::Source)?; + self.validate_event_from_peer(event)?; + + let msg = self.decrypt_message(event)?; + match msg { + PairingMessage::Complete { success: true } => { + self.state = SessionState::Completed; + self.record_event(event); + Ok(()) + } + PairingMessage::Complete { success: false } => { + self.state = SessionState::Aborted; + // Not recorded: the message was received but not "successfully + // processed" per NIP-AB §Duplicate Event Handling. The session + // is terminal (Aborted) so no future handler can accept events. + Err(PairingError::UnexpectedMessage { + expected: "complete(success=true)".into(), + got: "complete(success=false)".into(), + }) + } + other => Err(unexpected("complete", &other)), + } + } +} + +// ── Target-side constructors and methods ────────────────────────────────────── + +impl PairingSession { + /// Create a new target session from a scanned QR payload. + /// + /// Returns the session and the `offer` event to publish. + pub fn new_target(qr: &QrPayload) -> Result<(Self, Event), PairingError> { + let keys = Keys::generate(); + let session_id = derive_session_id(&qr.session_secret); + + // Compute ECDH and SAS immediately (target knows source pubkey from QR). + // Zero the ECDH shared secret after derivation. + let mut ecdh = nostr::util::generate_shared_key(keys.secret_key(), &qr.source_pubkey); + let (code, sas_input) = derive_sas(&ecdh, &qr.session_secret); + ecdh.zeroize(); + + let mut session = Self { + role: Role::Target, + state: SessionState::Waiting, + keys, + session_secret: qr.session_secret, + relay_urls: qr.relays.clone(), + peer_pubkey: Some(qr.source_pubkey), + session_id, + sas_code: Some(code), + sas_input: Some(sas_input), + processed_ids: HashSet::new(), + created_at: Instant::now(), + timeout: DEFAULT_TIMEOUT, + }; + + // Build and return the offer event. + let msg = PairingMessage::Offer { + session_id: hex::encode(session_id), + version: 1, + }; + let event = session.build_event(&msg)?; + session.state = SessionState::Confirming; + + Ok((session, event)) + } + + /// (Target) Process the `sas-confirm` event from the source. + /// + /// Verifies the transcript hash and returns the SAS code for the user + /// to visually confirm. The session moves to [`SessionState::AwaitingConfirmation`] + /// — the caller **must** call [`confirm_target_sas`] after the user approves + /// before any payload can be received. + pub fn handle_sas_confirm(&mut self, event: &Event) -> Result { + self.check_expired()?; + self.expect_state(SessionState::Confirming)?; + self.expect_role(Role::Target)?; + self.validate_event_from_peer(event)?; + + let msg = self.decrypt_message(event)?; + let received_hash = match &msg { + PairingMessage::SasConfirm { transcript_hash } => transcript_hash.clone(), + other => return Err(unexpected("sas-confirm", other)), + }; + + // Compute our own transcript hash and compare. + let sas_input = self.sas_input.ok_or(PairingError::SasMismatch)?; + let peer = self + .peer_pubkey + .ok_or(PairingError::InvalidPubkey("no peer".into()))?; + + // Source pubkey is the peer (we're target). + let expected_hash = derive_transcript_hash( + &self.session_id, + &peer.to_bytes(), + &self.keys.public_key().to_bytes(), + &sas_input, + &self.session_secret, + ); + + // Constant-time comparison to prevent timing side-channels. + let received_bytes = hex::decode(&received_hash) + .ok() + .and_then(|b| <[u8; 32]>::try_from(b).ok()); + let matches = received_bytes + .as_ref() + .is_some_and(|rb| ct_eq(rb, &expected_hash)); + if !matches { + self.state = SessionState::Aborted; + return Err(PairingError::TranscriptMismatch); + } + + self.state = SessionState::AwaitingConfirmation; + self.record_event(event); + let code = self.sas_code.ok_or(PairingError::SasMismatch)?; + Ok(format_sas(code)) + } + + /// (Target) User confirmed the SAS codes match. Transitions to + /// [`SessionState::Transferring`] so payloads can be received. + pub fn confirm_target_sas(&mut self) -> Result<(), PairingError> { + self.check_expired()?; + self.expect_state(SessionState::AwaitingConfirmation)?; + self.expect_role(Role::Target)?; + self.state = SessionState::Transferring; + Ok(()) + } + + /// (Target) Process the payload event from the source. + /// + /// Only one payload is accepted per session — after this call the state + /// advances to [`SessionState::PayloadExchanged`]. + pub fn handle_payload( + &mut self, + event: &Event, + ) -> Result<(PayloadType, Zeroizing), PairingError> { + self.check_expired()?; + self.expect_state(SessionState::Transferring)?; + self.expect_role(Role::Target)?; + self.validate_event_from_peer(event)?; + + let msg = self.decrypt_message(event)?; + match msg { + PairingMessage::Payload { + payload_type, + payload, + } => { + self.state = SessionState::PayloadExchanged; + self.record_event(event); + Ok((payload_type, Zeroizing::new(payload))) + } + other => Err(unexpected("payload", &other)), + } + } + + /// (Target) Build the `complete` event to publish. + pub fn send_complete(&mut self) -> Result { + self.check_expired()?; + self.expect_state(SessionState::PayloadExchanged)?; + self.expect_role(Role::Target)?; + + let msg = PairingMessage::Complete { success: true }; + let event = self.build_event(&msg)?; + self.state = SessionState::Completed; + Ok(event) + } +} + +// ── Shared methods ──────────────────────────────────────────────────────────── + +impl PairingSession { + /// Build an abort event. Returns `None` if no peer is known yet + /// (nothing to encrypt to), but still transitions to [`SessionState::Aborted`]. + /// + /// Rejects calls from terminal states ([`SessionState::Completed`] / + /// [`SessionState::Aborted`]) — a finished session cannot be regressed. + pub fn abort(&mut self, reason: AbortReason) -> Result, PairingError> { + if matches!(self.state, SessionState::Completed | SessionState::Aborted) { + return Err(PairingError::UnexpectedMessage { + expected: "non-terminal state".into(), + got: format!("state {:?}", self.state), + }); + } + if self.peer_pubkey.is_none() { + self.state = SessionState::Aborted; + return Ok(None); + } + let msg = PairingMessage::Abort { reason }; + let event = self.build_event(&msg)?; + self.state = SessionState::Aborted; + Ok(Some(event)) + } + + /// Process an abort event from the peer. + pub fn handle_abort(&mut self, event: &Event) -> Result { + // Terminal states are final — ignore late aborts. + if matches!(self.state, SessionState::Completed | SessionState::Aborted) { + return Err(PairingError::UnexpectedMessage { + expected: "non-terminal state".into(), + got: format!("state {:?}", self.state), + }); + } + // Require a known peer — an anonymous abort before the offer is + // accepted could let any relay observer kill the session. + if self.peer_pubkey.is_none() { + return Err(PairingError::InvalidPubkey( + "cannot accept abort before peer is known".into(), + )); + } + self.validate_event_from_peer(event)?; + let msg = self.decrypt_message(event)?; + match msg { + PairingMessage::Abort { reason } => { + self.state = SessionState::Aborted; + self.record_event(event); + Ok(reason) + } + other => Err(unexpected("abort", &other)), + } + } + + /// Check if the session has expired. + pub fn is_expired(&self) -> bool { + self.created_at.elapsed() > self.timeout + } + + /// Current protocol state. + pub fn state(&self) -> SessionState { + self.state + } + + /// This device's role. + pub fn role(&self) -> Role { + self.role + } + + /// This session's ephemeral public key. + pub fn pubkey(&self) -> PublicKey { + self.keys.public_key() + } + + /// Relay URLs for this session. + pub fn relay_urls(&self) -> &[String] { + &self.relay_urls + } + + /// The SAS code, if computed. + pub fn sas_code(&self) -> Option { + self.sas_code.map(format_sas) + } + + /// Sign an arbitrary event builder with this session's ephemeral keys. + /// + /// Useful for relay-level operations like NIP-42 authentication, where + /// the relay requires events to be signed by the same key that + /// authenticated the connection. + pub fn sign_event(&self, builder: EventBuilder) -> Result { + builder + .sign_with_keys(&self.keys) + .map_err(|e| PairingError::SigningError(e.to_string())) + } + + /// The QR URI for this session (source only). + pub fn qr_uri(&self) -> Option { + if self.role != Role::Source { + return None; + } + Some(qr::encode_qr(&QrPayload { + version: 1, + source_pubkey: self.keys.public_key(), + session_secret: self.session_secret, + relays: self.relay_urls.clone(), + })) + } +} + +// ── Test-only accessors ─────────────────────────────────────────────────────── + +#[cfg(test)] +impl PairingSession { + /// Returns `true` if the given event ID has been recorded as processed. + /// + /// Test-only: allows assertions about the dedup set without exposing + /// `processed_ids` through the public API. + fn has_processed(&self, event: &Event) -> bool { + self.processed_ids.contains(&event.id.to_bytes()) + } + + /// Override the session timeout for testing. + fn set_timeout(&mut self, timeout: Duration) { + self.timeout = timeout; + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +impl PairingSession { + /// Encrypt a message and wrap it in a signed kind:24134 event. + /// + /// # Secret handling + /// + /// The serialized JSON plaintext is explicitly zeroized after encryption. + /// The caller's `Zeroizing` zeros on drop. The transient clone + /// inside `PairingMessage::Payload` is zeroized by `send_payload` after + /// this method returns. + /// + /// Residual transient copies that cannot be zeroized: + /// 1. `serde_json::to_string` may create intermediate buffers during serialization + /// 2. `nip44::encrypt` reads the plaintext but does not zero its internal copy + /// + /// These are inherent to Rust's heap allocator and third-party crate internals. + fn build_event(&self, message: &PairingMessage) -> Result { + let peer = self + .peer_pubkey + .ok_or_else(|| PairingError::InvalidPubkey("no peer pubkey set".into()))?; + + let mut plaintext = serde_json::to_string(message)?; + let encrypted = nip44::encrypt( + self.keys.secret_key(), + &peer, + &plaintext, + nip44::Version::V2, + )?; + plaintext.zeroize(); // Zero serialized JSON before drop + + // NIP-AB §: Implementations SHOULD set created_at to the current time + // minus a random value between 0 and 30 seconds for metadata privacy. + let now = nostr::Timestamp::now().as_u64(); + let jitter = rand::random::() % 31; // 0-30s jitter per NIP-AB §Metadata Privacy + let ts = nostr::Timestamp::from(now.saturating_sub(jitter)); + + EventBuilder::new( + Kind::Custom(PAIRING_KIND), + &encrypted, + [Tag::public_key(peer)], + ) + .custom_created_at(ts) + .sign_with_keys(&self.keys) + .map_err(|e| PairingError::SigningError(e.to_string())) + } + + /// Decrypt and parse a NIP-44 encrypted pairing message from an event. + /// + /// NIP-AB §Event Validation: `content` MUST be a valid NIP-44 v2 payload + /// (base64, 132–87472 characters). Reject before attempting decryption. + fn decrypt_message(&self, event: &Event) -> Result { + // NIP-AB §Event Validation step 5: reject content outside NIP-44 size range. + let content_len = event.content.len(); + if !(132..=87472).contains(&content_len) { + return Err(PairingError::UnexpectedMessage { + expected: "NIP-44 content (132–87472 chars)".into(), + got: format!("{content_len} chars"), + }); + } + + let mut decrypted = nip44::decrypt( + self.keys.secret_key(), + &event.pubkey, + event.content.as_str(), + )?; + + // NIP-AB §Payload: decrypted plaintext MUST NOT exceed 65,535 bytes. + if decrypted.len() > 65_535 { + decrypted.zeroize(); + return Err(PairingError::UnexpectedMessage { + expected: "plaintext ≤ 65535 bytes".into(), + got: format!("{} bytes", decrypted.len()), + }); + } + + // Defer `?` so decrypted plaintext is zeroized on both success and parse failure. + let result = serde_json::from_str(&decrypted); + decrypted.zeroize(); + Ok(result?) + } + + /// Validate basic event properties: kind, p-tag, and duplicate ID. + /// + /// NIP-AB §Duplicate Event Handling: silently discard events whose `id` + /// has already been processed in this session. The set is bounded by the + /// session lifetime (120 s max, ~6 events in a normal flow). + /// + /// This method only *checks* for duplicates — it does not record the ID. + /// Call [`record_event`] after the message is fully accepted. + fn validate_event_basics(&self, event: &Event) -> Result<(), PairingError> { + // NIP-01 §: Validate the event id and sig. + event + .verify() + .map_err(|e| PairingError::InvalidPubkey(format!("event verification failed: {e}")))?; + + // Duplicate event ID check (NIP-AB §Duplicate Event Handling). + if self.processed_ids.contains(&event.id.to_bytes()) { + return Err(PairingError::UnexpectedMessage { + expected: "new event".into(), + got: "duplicate event id".into(), + }); + } + + if event.kind != Kind::Custom(PAIRING_KIND) { + return Err(PairingError::UnexpectedMessage { + expected: format!("kind {PAIRING_KIND}"), + got: format!("kind {}", event.kind.as_u16()), + }); + } + + // Check p-tag points to us. + let our_pk = self.keys.public_key(); + let has_p_tag = event.tags.iter().any(|t| { + t.as_slice().first().map(|s| s.as_str()) == Some("p") + && t.as_slice() + .get(1) + .map(|s| s.as_str() == our_pk.to_hex().as_str()) + .unwrap_or(false) + }); + if !has_p_tag { + return Err(PairingError::InvalidPubkey( + "event p-tag does not match our ephemeral pubkey".into(), + )); + } + + Ok(()) + } + + /// Record an event ID as successfully processed. + /// + /// Called by each handler only after the message has been fully validated, + /// decrypted, type-checked, and accepted. This ensures that speculative + /// probes (e.g., `handle_abort` used to detect aborts) do not poison the + /// duplicate set for subsequent handlers. + fn record_event(&mut self, event: &Event) { + self.processed_ids.insert(event.id.to_bytes()); + } + + /// Validate that the event is from the expected peer. + fn validate_event_from_peer(&self, event: &Event) -> Result<(), PairingError> { + self.validate_event_basics(event)?; + + if let Some(expected) = self.peer_pubkey { + if event.pubkey != expected { + return Err(PairingError::InvalidPubkey(format!( + "event from {} but expected {}", + event.pubkey.to_hex(), + expected.to_hex() + ))); + } + } + + Ok(()) + } + + /// Check that the session hasn't expired. + fn check_expired(&self) -> Result<(), PairingError> { + if self.is_expired() { + return Err(PairingError::SessionExpired); + } + Ok(()) + } + + /// Check that we're in the expected state. + fn expect_state(&self, expected: SessionState) -> Result<(), PairingError> { + if self.state != expected { + return Err(PairingError::UnexpectedMessage { + expected: format!("state {:?}", expected), + got: format!("state {:?}", self.state), + }); + } + Ok(()) + } + + /// Check that we're playing the expected role. + fn expect_role(&self, expected: Role) -> Result<(), PairingError> { + if self.role != expected { + return Err(PairingError::UnexpectedMessage { + expected: format!("role {:?}", expected), + got: format!("role {:?}", self.role), + }); + } + Ok(()) + } +} + +/// Zero sensitive fields on drop using `zeroize` to prevent dead-store +/// elimination by the compiler. Ephemeral private keys are separately +/// zeroed by `nostr::SecretKey::Drop` (which uses `write_volatile`). +impl Drop for PairingSession { + fn drop(&mut self) { + self.session_secret.zeroize(); + self.session_id.zeroize(); + if let Some(ref mut input) = self.sas_input { + input.zeroize(); + } + } +} + +/// Helper to build an UnexpectedMessage error from a PairingMessage variant. +fn unexpected(expected: &str, got: &PairingMessage) -> PairingError { + let got_name = match got { + PairingMessage::Offer { .. } => "offer", + PairingMessage::SasConfirm { .. } => "sas-confirm", + PairingMessage::Payload { .. } => "payload", + PairingMessage::Complete { .. } => "complete", + PairingMessage::Abort { .. } => "abort", + }; + PairingError::UnexpectedMessage { + expected: expected.into(), + got: got_name.into(), + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Full happy-path: source creates → target joins → SAS match → payload → complete. + #[test] + fn happy_path_full_protocol() { + // Source creates session. + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + assert_eq!(source.state(), SessionState::Waiting); + assert_eq!(source.role(), Role::Source); + + // Target scans QR and creates session + offer event. + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target creation"); + assert_eq!(target.state(), SessionState::Confirming); + assert_eq!(target.role(), Role::Target); + + // Source processes offer. + let source_sas = source.handle_offer(&offer_event).expect("handle offer"); + assert_eq!(source.state(), SessionState::Confirming); + + // Target already has SAS from construction. + let target_sas = target.sas_code().expect("target should have SAS"); + + // SAS codes must match (proves no MITM). + assert_eq!(source_sas, target_sas, "SAS codes must match"); + assert_eq!(source_sas.len(), 6, "SAS must be 6 digits"); + + // Source confirms SAS → sends sas-confirm event. + let sas_confirm_event = source.confirm_sas().expect("confirm SAS"); + assert_eq!(source.state(), SessionState::Transferring); + + // Target verifies sas-confirm — enters AwaitingConfirmation. + let target_sas_verify = target + .handle_sas_confirm(&sas_confirm_event) + .expect("handle sas-confirm"); + assert_eq!(target_sas_verify, target_sas); + assert_eq!(target.state(), SessionState::AwaitingConfirmation); + + // Target user confirms the SAS. + target.confirm_target_sas().expect("target confirms SAS"); + assert_eq!(target.state(), SessionState::Transferring); + + // Source sends payload. + let payload_event = source + .send_payload(PayloadType::Nsec, Zeroizing::new("nsec1test".into())) + .expect("send payload"); + assert_eq!(source.state(), SessionState::PayloadExchanged); + + // Target receives payload. + let (pt, data) = target + .handle_payload(&payload_event) + .expect("handle payload"); + assert_eq!(pt, PayloadType::Nsec); + assert_eq!(*data, "nsec1test"); + assert_eq!(target.state(), SessionState::PayloadExchanged); + + // Target sends complete. + let complete_event = target.send_complete().expect("send complete"); + assert_eq!(target.state(), SessionState::Completed); + + // Source handles complete. + source + .handle_complete(&complete_event) + .expect("handle complete"); + assert_eq!(source.state(), SessionState::Completed); + } + + /// State machine rejects out-of-order operations. + #[test] + fn reject_out_of_order_operations() { + let (mut source, _qr) = PairingSession::new_source("wss://relay.test".into()); + + // Can't confirm SAS before receiving offer. + assert!(source.confirm_sas().is_err()); + + // Can't send payload before confirming SAS. + assert!(source + .send_payload(PayloadType::Nsec, Zeroizing::new("nsec1x".into())) + .is_err()); + } + + /// Abort from either side. + #[test] + fn abort_flow() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target"); + + // Source must first learn the peer pubkey (from the offer) to send an abort. + let _sas = source.handle_offer(&offer_event).expect("handle offer"); + + // Source aborts. + let abort_event = source + .abort(AbortReason::UserDenied) + .expect("source abort") + .expect("should have event since peer is known"); + assert_eq!(source.state(), SessionState::Aborted); + + // Target handles abort. + let reason = target.handle_abort(&abort_event).expect("handle abort"); + assert_eq!(reason, AbortReason::UserDenied); + assert_eq!(target.state(), SessionState::Aborted); + } + + /// Abort before peer is known returns None (no event to send). + #[test] + fn abort_without_peer_returns_none() { + let (mut source, _qr) = PairingSession::new_source("wss://relay.test".into()); + let result = source.abort(AbortReason::Timeout).expect("abort"); + assert!(result.is_none(), "no event when peer is unknown"); + assert_eq!(source.state(), SessionState::Aborted); + } + + /// Local abort() cannot regress a Completed session. + #[test] + fn local_abort_after_completed_is_rejected() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).expect("target"); + let _ = source.handle_offer(&offer).expect("offer"); + let sas_confirm = source.confirm_sas().expect("confirm"); + let _ = target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + let payload = source + .send_payload(PayloadType::Nsec, Zeroizing::new("x".into())) + .expect("payload"); + let _ = target.handle_payload(&payload).expect("handle payload"); + let complete = target.send_complete().expect("complete"); + source.handle_complete(&complete).expect("handle complete"); + + assert_eq!(source.state(), SessionState::Completed); + // Local abort must be rejected. + let result = source.abort(AbortReason::UserDenied); + assert!(result.is_err(), "abort after Completed must fail"); + assert_eq!( + source.state(), + SessionState::Completed, + "state must not regress" + ); + } + + /// handle_abort() before peer is known is rejected (prevents relay-observer DoS). + #[test] + fn reject_handle_abort_before_peer_known() { + let (mut source, _qr) = PairingSession::new_source("wss://relay.test".into()); + // Build a fake abort event from an unknown sender. + let rogue = Keys::generate(); + let msg = PairingMessage::Abort { + reason: AbortReason::Timeout, + }; + let plaintext = serde_json::to_string(&msg).unwrap(); + let encrypted = nip44::encrypt( + rogue.secret_key(), + &source.pubkey(), + &plaintext, + nip44::Version::V2, + ) + .unwrap(); + let fake_abort = EventBuilder::new( + Kind::Custom(crate::kind::KIND_PAIRING as u16), + &encrypted, + [Tag::public_key(source.pubkey())], + ) + .sign_with_keys(&rogue) + .unwrap(); + + // Source has no peer yet — must reject. + let result = source.handle_abort(&fake_abort); + assert!(result.is_err(), "abort before peer known must be rejected"); + assert_eq!( + source.state(), + SessionState::Waiting, + "state must not change" + ); + } + + /// Late abort after session is completed is rejected. + #[test] + fn reject_abort_after_completed() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target"); + + // Run the full happy path to completion. + let _ = source.handle_offer(&offer_event).expect("offer"); + let sas_confirm = source.confirm_sas().expect("confirm"); + let _ = target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + let payload = source + .send_payload(PayloadType::Nsec, Zeroizing::new("nsec1test".into())) + .expect("payload"); + let _ = target.handle_payload(&payload).expect("handle payload"); + let complete = target.send_complete().expect("complete"); + source.handle_complete(&complete).expect("handle complete"); + + assert_eq!(source.state(), SessionState::Completed); + assert_eq!(target.state(), SessionState::Completed); + + // Build a fake abort event from the target to the source. + let abort_event = { + let keys = Keys::generate(); + let msg = PairingMessage::Abort { + reason: AbortReason::Timeout, + }; + let plaintext = serde_json::to_string(&msg).unwrap(); + let encrypted = nip44::encrypt( + keys.secret_key(), + &source.pubkey(), + &plaintext, + nip44::Version::V2, + ) + .unwrap(); + EventBuilder::new( + Kind::Custom(crate::kind::KIND_PAIRING as u16), + &encrypted, + [Tag::public_key(source.pubkey())], + ) + .sign_with_keys(&keys) + .unwrap() + }; + + // Source should reject the late abort. + let result = source.handle_abort(&abort_event); + assert!( + result.is_err(), + "late abort after Completed must be rejected" + ); + // State must remain Completed. + assert_eq!(source.state(), SessionState::Completed); + } + + /// Invalid session_id in offer is rejected. + #[test] + fn reject_invalid_session_id() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + + // Create a target with a DIFFERENT session secret (simulates attacker). + let mut fake_qr = qr.clone(); + fake_qr.session_secret = [0xff; 32]; + let (_, fake_offer) = PairingSession::new_target(&fake_qr).expect("fake target"); + + // Source should reject the offer (session_id won't match). + let result = source.handle_offer(&fake_offer); + assert!( + matches!(result, Err(PairingError::InvalidSessionId)), + "expected InvalidSessionId, got {result:?}" + ); + } + + /// Event from wrong pubkey is rejected. + #[test] + fn reject_event_from_wrong_pubkey() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target"); + + // Source accepts the legitimate offer. + let _ = source.handle_offer(&offer_event).expect("handle offer"); + let sas_confirm = source.confirm_sas().expect("confirm"); + + // Create a rogue session that tries to send a fake sas-confirm. + let rogue_keys = Keys::generate(); + let fake_msg = PairingMessage::SasConfirm { + transcript_hash: "00".repeat(32), + }; + let plaintext = serde_json::to_string(&fake_msg).unwrap(); + let encrypted = nip44::encrypt( + rogue_keys.secret_key(), + &target.pubkey(), + &plaintext, + nip44::Version::V2, + ) + .unwrap(); + let fake_event = EventBuilder::new( + Kind::Custom(PAIRING_KIND), + &encrypted, + [Tag::public_key(target.pubkey())], + ) + .sign_with_keys(&rogue_keys) + .unwrap(); + + // Target should reject (wrong author). + let result = target.handle_sas_confirm(&fake_event); + assert!( + matches!(result, Err(PairingError::InvalidPubkey(_))), + "expected InvalidPubkey, got {result:?}" + ); + + // But the legitimate sas-confirm should work. + let _ = target + .handle_sas_confirm(&sas_confirm) + .expect("legit sas-confirm"); + } + + /// QR URI round-trip through session constructors. + #[test] + fn qr_uri_round_trip() { + let (source, qr) = PairingSession::new_source("wss://relay.test".into()); + let uri = source.qr_uri().expect("source should have QR URI"); + let decoded = qr::decode_qr(&uri).expect("decode QR URI"); + assert_eq!(decoded.source_pubkey, qr.source_pubkey); + assert_eq!(decoded.session_secret, qr.session_secret); + assert_eq!(decoded.relays, qr.relays); + } + + /// Target cannot receive payload without explicit SAS confirmation. + #[test] + fn target_must_confirm_sas_before_payload() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target"); + + let _ = source.handle_offer(&offer_event).expect("offer"); + let sas_confirm_event = source.confirm_sas().expect("confirm"); + + // Target receives sas-confirm → AwaitingConfirmation. + let _ = target + .handle_sas_confirm(&sas_confirm_event) + .expect("sas-confirm"); + assert_eq!(target.state(), SessionState::AwaitingConfirmation); + + // Source sends payload. + let payload_event = source + .send_payload(PayloadType::Nsec, Zeroizing::new("nsec1test".into())) + .expect("payload"); + + // Target tries to handle payload WITHOUT confirming SAS first → error. + let result = target.handle_payload(&payload_event); + assert!( + result.is_err(), + "should reject payload before SAS confirmation" + ); + + // Now confirm, then payload works. + target.confirm_target_sas().expect("confirm"); + let (pt, _) = target + .handle_payload(&payload_event) + .expect("payload after confirm"); + assert_eq!(pt, PayloadType::Nsec); + } + + /// Only one payload per session — duplicate sends/receives are rejected. + #[test] + fn reject_duplicate_payload() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target"); + + let _ = source.handle_offer(&offer_event).expect("offer"); + let sas_confirm = source.confirm_sas().expect("confirm"); + let _ = target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + + // First payload succeeds. + let payload1 = source + .send_payload(PayloadType::Nsec, Zeroizing::new("nsec1first".into())) + .expect("first payload"); + + // Second payload from source is rejected (state already advanced). + let result = source.send_payload(PayloadType::Nsec, Zeroizing::new("nsec1second".into())); + assert!(result.is_err(), "duplicate send_payload should fail"); + + // Target receives first payload. + let _ = target.handle_payload(&payload1).expect("receive first"); + + // Target trying to receive again is rejected. + let result = target.handle_payload(&payload1); + assert!(result.is_err(), "duplicate handle_payload should fail"); + } + + /// Secrets are zeroed on drop. + #[test] + fn secrets_zeroed_on_drop() { + let (session, _qr) = PairingSession::new_source("wss://relay.test".into()); + // We can't directly inspect after drop, but we verify the Drop impl + // compiles and runs without panic. + drop(session); + } + + /// Expired sessions reject all operations with `SessionExpired`. + #[test] + fn expired_session_rejects_operations() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + source.set_timeout(Duration::from_millis(1)); + std::thread::sleep(Duration::from_millis(5)); + + assert!(source.is_expired()); + + // Every handler that calls check_expired should fail. + let (_, offer_event) = PairingSession::new_target(&qr).expect("target"); + let result = source.handle_offer(&offer_event); + assert!( + matches!(result, Err(PairingError::SessionExpired)), + "expected SessionExpired, got {result:?}" + ); + } + + /// Duplicate event IDs are silently discarded (NIP-AB §Duplicate Event Handling). + #[test] + fn duplicate_event_id_is_rejected() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target"); + + // First offer succeeds. + let _ = source.handle_offer(&offer_event).expect("first offer"); + + // Run through the rest of the protocol so we can test duplicate + // complete events on the source side. + let sas_confirm = source.confirm_sas().expect("confirm"); + let _ = target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + let payload = source + .send_payload(PayloadType::Nsec, Zeroizing::new("nsec1test".into())) + .expect("payload"); + let _ = target.handle_payload(&payload).expect("handle payload"); + let complete_event = target.send_complete().expect("complete"); + + // First complete succeeds. + source + .handle_complete(&complete_event) + .expect("first complete"); + + // Second delivery of the same complete event (same event ID) — must + // be rejected because the state has already advanced to Completed. + let result = source.handle_complete(&complete_event); + assert!(result.is_err(), "duplicate event ID must be rejected"); + } + + /// Speculative `handle_abort` on a non-abort event must NOT poison the + /// duplicate set — the real handler must still accept the event. + /// + /// This mirrors the CLI's `check_for_abort()` pattern: every inbound + /// event is first probed via `handle_abort()`, which fails for non-abort + /// messages. The subsequent real handler must still see the event as new. + #[test] + fn speculative_abort_does_not_poison_dedup() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target"); + + // Source accepts the offer (learns peer). + let _ = source.handle_offer(&offer_event).expect("offer"); + let sas_confirm = source.confirm_sas().expect("confirm"); + + // Target: speculative abort probe on the sas-confirm event. + // This must fail (it's not an abort) WITHOUT recording the event ID. + let probe = target.handle_abort(&sas_confirm); + assert!(probe.is_err(), "sas-confirm is not an abort"); + + // Target: real handler must still accept the same event. + let sas = target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm must succeed after speculative abort probe"); + assert_eq!(sas.len(), 6); + } + + /// A wrong-type message that passes validation but fails at type-dispatch + /// must NOT be recorded, so the event ID remains available for future use. + /// + /// Scenario: target is in `Transferring` (waiting for payload). Source + /// accidentally sends a `complete` message instead. The target's + /// `handle_payload` rejects it (wrong type), but the event ID must not + /// be poisoned — the session should still accept the real payload. + #[test] + fn wrong_type_message_not_recorded() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target"); + + // Drive to Transferring on both sides. + let _ = source.handle_offer(&offer_event).expect("offer"); + let sas_confirm = source.confirm_sas().expect("confirm"); + let _ = target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + + // Source sends the real payload (we'll use it later). + let payload_event = source + .send_payload(PayloadType::Nsec, Zeroizing::new("nsec1test".into())) + .expect("payload"); + + // Build a wrong-type event: a `complete` message from source to target. + // This passes kind/p-tag/peer validation but fails at type-dispatch + // inside handle_payload (expects "payload", gets "complete"). + let wrong_type_msg = PairingMessage::Complete { success: true }; + let wrong_plaintext = serde_json::to_string(&wrong_type_msg).unwrap(); + let wrong_encrypted = nip44::encrypt( + source.keys.secret_key(), + &target.pubkey(), + &wrong_plaintext, + nip44::Version::V2, + ) + .unwrap(); + let wrong_event = EventBuilder::new( + Kind::Custom(PAIRING_KIND), + &wrong_encrypted, + [Tag::public_key(target.pubkey())], + ) + .sign_with_keys(&source.keys) + .unwrap(); + + // Target tries to handle as payload — fails (wrong type). + let result = target.handle_payload(&wrong_event); + assert!(result.is_err(), "wrong-type message must be rejected"); + assert_eq!( + target.state(), + SessionState::Transferring, + "state must not advance on wrong-type" + ); + + // The real payload must still be accepted (its ID was never recorded). + let (pt, data) = target + .handle_payload(&payload_event) + .expect("real payload must succeed after wrong-type rejection"); + assert_eq!(pt, PayloadType::Nsec); + assert_eq!(*data, "nsec1test"); + } + + /// `complete(success: false)` transitions to Aborted and does NOT + /// record the event ID (the message was not "successfully processed" + /// per NIP-AB §Duplicate Event Handling). + #[test] + fn complete_failure_aborts_without_recording() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer_event) = PairingSession::new_target(&qr).expect("target"); + + // Drive to PayloadExchanged on source side. + let _ = source.handle_offer(&offer_event).expect("offer"); + let sas_confirm = source.confirm_sas().expect("confirm"); + let _ = target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + let payload = source + .send_payload(PayloadType::Nsec, Zeroizing::new("nsec1test".into())) + .expect("payload"); + let _ = target.handle_payload(&payload).expect("handle payload"); + + // Build a complete(success: false) event from target to source. + let fail_msg = PairingMessage::Complete { success: false }; + let fail_plaintext = serde_json::to_string(&fail_msg).unwrap(); + let fail_encrypted = nip44::encrypt( + target.keys.secret_key(), + &source.pubkey(), + &fail_plaintext, + nip44::Version::V2, + ) + .unwrap(); + let fail_event = EventBuilder::new( + Kind::Custom(PAIRING_KIND), + &fail_encrypted, + [Tag::public_key(source.pubkey())], + ) + .sign_with_keys(&target.keys) + .unwrap(); + + // Source handles complete(false) — should error and abort. + let result = source.handle_complete(&fail_event); + assert!(result.is_err(), "complete(false) must return error"); + assert_eq!( + source.state(), + SessionState::Aborted, + "state must be Aborted after complete(false)" + ); + + // The failed event must NOT be in the processed set. + assert!( + !source.has_processed(&fail_event), + "complete(false) must not record the event ID" + ); + } +} diff --git a/crates/sprout-core/src/pairing/types.rs b/crates/sprout-core/src/pairing/types.rs new file mode 100644 index 000000000..0dcc0baf5 --- /dev/null +++ b/crates/sprout-core/src/pairing/types.rs @@ -0,0 +1,242 @@ +//! NIP-AB pairing message types. +//! +//! All message types are serialized as JSON with a `"type"` discriminant field +//! (kebab-case). These are the plaintext payloads that get NIP-44 encrypted +//! before being placed in a [`crate::kind::KIND_PAIRING`] event. + +use serde::{Deserialize, Serialize}; + +fn default_version() -> u32 { + 1 +} + +/// The set of messages exchanged during a NIP-AB device-pairing session. +/// +/// Serialized with `"type"` as the tag field (kebab-case). Example: +/// ```json +/// {"type":"offer","session_id":"a1b2c3..."} +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum PairingMessage { + /// Target → Source. Announces the session and proves possession of the QR secret. + Offer { + /// Hex-encoded 32-byte session ID derived via HKDF from the session secret. + session_id: String, + /// Protocol version. Always `1` for this implementation. + /// + /// Defaults to `1` when absent (backward compat with pre-versioned implementations). + #[serde(default = "default_version")] + version: u32, + }, + + /// Either party → other. Confirms the Short Authentication String matches. + SasConfirm { + /// Hex-encoded 32-byte transcript hash, binding all session parameters. + transcript_hash: String, + }, + + /// Initiator → Responder (or vice-versa). Delivers the actual secret payload. + Payload { + /// Discriminates the payload format so the receiver knows how to handle it. + payload_type: PayloadType, + /// The payload content (format depends on `payload_type`). + payload: String, + }, + + /// Sent by either party to signal successful session completion. + Complete { + /// `true` if the session completed successfully, `false` on partial failure. + success: bool, + }, + + /// Sent by either party to abort the session early. + Abort { + /// Machine-readable reason for the abort. + reason: AbortReason, + }, +} + +/// Discriminates the content of a [`PairingMessage::Payload`] message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PayloadType { + /// Raw `nsec` bech32 secret key. + Nsec, + /// NIP-46 bunker connection string. + Bunker, + /// NIP-46 `nostrconnect://` URI. + Connect, + /// Application-defined payload; interpretation is out-of-band. + Custom, +} + +/// Machine-readable reason a pairing session was aborted. +/// +/// The spec allows implementations to define additional reason strings. +/// Unknown reasons are deserialized as [`Unknown`](AbortReason::Unknown) +/// and SHOULD be treated as `protocol_error` per NIP-AB §Abort. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AbortReason { + /// The Short Authentication Strings shown to both users did not match. + SasMismatch, + /// The user explicitly denied the pairing request. + UserDenied, + /// The session exceeded its time limit without completing. + Timeout, + /// An unexpected or malformed message was received. + ProtocolError, + /// An unrecognized abort reason from a future or extended implementation. + /// Produced only by deserialization of unknown reason strings. + /// Callers MUST NOT use this variant for outbound aborts — use a + /// spec-defined reason instead. Treat as `ProtocolError` per NIP-AB §Abort. + #[serde(other)] + Unknown, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn offer_round_trip() { + let msg = PairingMessage::Offer { + session_id: "deadbeef".repeat(8), + version: 1, + }; + let json = serde_json::to_string(&msg).expect("serialize"); + assert!( + json.contains(r#""type":"offer""#), + "tag field present: {json}" + ); + assert!( + json.contains(r#""version":1"#), + "version field present: {json}" + ); + let back: PairingMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(msg, back); + } + + #[test] + fn offer_version_defaults_to_1_when_absent() { + // Simulate a legacy offer message without the version field. + let json = r#"{"type":"offer","session_id":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}"#; + let msg: PairingMessage = serde_json::from_str(json).expect("deserialize"); + assert_eq!( + msg, + PairingMessage::Offer { + session_id: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + .to_string(), + version: 1, + } + ); + } + + #[test] + fn sas_confirm_round_trip() { + let msg = PairingMessage::SasConfirm { + transcript_hash: "cafebabe".repeat(8), + }; + let json = serde_json::to_string(&msg).expect("serialize"); + assert!( + json.contains(r#""type":"sas-confirm""#), + "kebab-case tag: {json}" + ); + let back: PairingMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(msg, back); + } + + #[test] + fn payload_round_trip() { + let msg = PairingMessage::Payload { + payload_type: PayloadType::Nsec, + payload: "nsec1abc".to_string(), + }; + let json = serde_json::to_string(&msg).expect("serialize"); + assert!(json.contains(r#""type":"payload""#)); + assert!(json.contains(r#""payload_type":"nsec""#)); + let back: PairingMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(msg, back); + } + + #[test] + fn abort_sas_mismatch_round_trip() { + let msg = PairingMessage::Abort { + reason: AbortReason::SasMismatch, + }; + let json = serde_json::to_string(&msg).expect("serialize"); + assert!( + json.contains(r#""reason":"sas_mismatch""#), + "snake_case: {json}" + ); + let back: PairingMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(msg, back); + } + + #[test] + fn complete_round_trip() { + for success in [true, false] { + let msg = PairingMessage::Complete { success }; + let json = serde_json::to_string(&msg).expect("serialize"); + let back: PairingMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(msg, back); + } + } + + #[test] + fn all_abort_reasons_round_trip() { + let reasons = [ + AbortReason::SasMismatch, + AbortReason::UserDenied, + AbortReason::Timeout, + AbortReason::ProtocolError, + ]; + for reason in reasons { + let msg = PairingMessage::Abort { reason }; + let json = serde_json::to_string(&msg).expect("serialize"); + let back: PairingMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(msg, back); + } + } + + #[test] + fn unknown_abort_reason_deserializes_to_unknown() { + // NIP-AB §Abort: "unknown reasons SHOULD be treated as protocol_error" + let json = r#"{"type":"abort","reason":"solar_flare"}"#; + let msg: PairingMessage = serde_json::from_str(json).expect("deserialize"); + assert_eq!( + msg, + PairingMessage::Abort { + reason: AbortReason::Unknown + } + ); + } + + #[test] + fn unknown_abort_reason_is_not_protocol_error_variant() { + // Unknown is a distinct variant — callers should never construct it + // for outbound use, but if they do it serializes distinctly from + // ProtocolError so we can catch the mistake. + assert_ne!(AbortReason::Unknown, AbortReason::ProtocolError); + } + + #[test] + fn all_payload_types_round_trip() { + let types = [ + PayloadType::Nsec, + PayloadType::Bunker, + PayloadType::Connect, + PayloadType::Custom, + ]; + for payload_type in types { + let msg = PairingMessage::Payload { + payload_type, + payload: "data".to_string(), + }; + let json = serde_json::to_string(&msg).expect("serialize"); + let back: PairingMessage = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(msg, back); + } + } +} diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index d4c0b7c18..46b2cb5e1 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -178,6 +178,12 @@ pub async fn create_channel_with_id( ))); } + if channel_id.is_nil() { + return Err(DbError::InvalidData( + "channel_id must not be nil (reserved for global fan-out)".into(), + )); + } + let mut tx = pool.begin().await?; let rows_affected = sqlx::query( diff --git a/crates/sprout-pairing-cli/Cargo.toml b/crates/sprout-pairing-cli/Cargo.toml new file mode 100644 index 000000000..77a1daf12 --- /dev/null +++ b/crates/sprout-pairing-cli/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "sprout-pairing-cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "CLI tool for NIP-AB device pairing interop testing" + +[[bin]] +name = "sprout-pair" +path = "src/main.rs" + +[dependencies] +sprout-core = { workspace = true } +nostr = { workspace = true } +tokio = { workspace = true } +tokio-tungstenite = { workspace = true } +futures-util = { workspace = true } +serde_json = { workspace = true } +url = { workspace = true } +hex = { workspace = true } +clap = { version = "4", features = ["derive", "env"] } +thiserror = { workspace = true } +zeroize = { workspace = true } diff --git a/crates/sprout-pairing-cli/README.md b/crates/sprout-pairing-cli/README.md new file mode 100644 index 000000000..0ff147dbb --- /dev/null +++ b/crates/sprout-pairing-cli/README.md @@ -0,0 +1,128 @@ +# sprout-pair + +CLI tool for testing the [NIP-AB device pairing protocol](../sprout-core/src/pairing/NIP-AB.md) end-to-end. Exercises the full protocol over a live Nostr relay — designed for interop testing and NIP submission, not production use. + +## Quick Start + +```bash +cargo build --release -p sprout-pairing-cli + +# Terminal 1 — source (holds the secret) +./target/release/sprout-pair source --relay wss://relay.damus.io + +# Terminal 2 — target (receives the secret) +./target/release/sprout-pair target --show-secret +# paste the QR URI from terminal 1 when prompted +``` + +Both sides display a 6-digit SAS code. Confirm they match on each side, and the key transfers. + +## Subcommands + +### `source` + +Acts as the device holding the secret. Generates an ephemeral keypair and session secret, displays a `nostrpair://` QR URI, waits for a target to connect, performs SAS verification, and sends the payload. + +``` +sprout-pair source --relay [--nsec ] +``` + +- `--relay` — WebSocket relay URL (default: `wss://relay.damus.io`) +- `--nsec` — bech32 nsec to transfer. If omitted, generates a throwaway test key. + +### `target` + +Acts as the receiving device. Reads a `nostrpair://` URI from stdin, connects to the relay encoded in the URI, sends an offer, verifies SAS, and receives the payload. + +``` +sprout-pair target [--relay ] [--show-secret] +``` + +- `--relay` — Override the relay URL from the QR code +- `--show-secret` — Print the received secret to stdout (off by default for safety) + +### `test-vectors` + +Prints all derived cryptographic values from the NIP-AB spec's fixed test keys. Useful for verifying implementations against the spec. + +``` +sprout-pair test-vectors +``` + +## Testing Against a Local Sprout Relay + +The CLI supports NIP-42 authentication, so it works with Sprout relays out of the box. + +### Prerequisites + +- Docker running (for Postgres, Redis, etc.) +- Sprout relay built: `cargo build --release -p sprout-relay` + +### Start the relay + +```bash +just setup # Docker services + schema +cargo build --release --workspace +screen -dmS relay bash -c "./target/release/sprout-relay 2>&1 | tee /tmp/sprout-relay.log" +sleep 3 && curl -s http://localhost:3000/health # → "ok" +``` + +### Run the E2E test + +An automated test script using `expect` is provided: + +```bash +.scratch/e2e-pair-local.sh +``` + +This spawns source and target as PTY-driven subprocesses, feeds the QR URI between them, waits for both SAS codes to appear, delays to ensure relay subscriptions are registered, then confirms SAS on both sides. Prints `PASS` or `FAIL` with the SAS codes. + +**Requirements:** `expect` (macOS: built-in at `/usr/bin/expect`) + +**Environment variables:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `RELAY_URL` | `ws://localhost:3000` | Relay to test against | +| `TEST_TIMEOUT` | `45` | Per-step timeout in seconds | +| `SOURCE_CONFIRM_DELAY_MS` | `3000` | Delay after SAS display before confirming (lets relay register subscriptions) | + +### Manual two-terminal test + +```bash +# Terminal 1 +./target/release/sprout-pair source --relay ws://localhost:3000 + +# Terminal 2 +./target/release/sprout-pair target --show-secret +# paste the nostrpair:// URI, confirm SAS on both sides +``` + +## Protocol Overview + +``` +Source Relay Target +────── ───── ────── +Generate ephemeral keys +Display QR (pubkey+secret+relay) +Subscribe kind:24134 Scan QR + Generate ephemeral keys + Subscribe kind:24134 + Wait for EOSE + ◄─────────────────────── Send offer +Verify session_id +Compute SAS ◄──────────────────────────────────────────► Compute SAS +Display: "047291" Display: "047291" + +[User confirms codes match] + +Send sas-confirm ──────────────►─────────────────────► + Verify transcript_hash + [User confirms] +Send payload ──────────────────►─────────────────────► + Decrypt + import + ◄─────────────────────── Send complete +Done Done +``` + +All events are NIP-44 encrypted, signed with ephemeral keys, and addressed via `p` tags. The relay sees only opaque ciphertext between throwaway pubkeys. diff --git a/crates/sprout-pairing-cli/src/main.rs b/crates/sprout-pairing-cli/src/main.rs new file mode 100644 index 000000000..5f98d0070 --- /dev/null +++ b/crates/sprout-pairing-cli/src/main.rs @@ -0,0 +1,647 @@ +//! `sprout-pair` — NIP-AB device pairing interop testing CLI. +//! +//! # Usage +//! +//! ```text +//! sprout-pair source --relay wss://relay.example.com [--nsec nsec1...] +//! sprout-pair target [--relay wss://relay.example.com] +//! sprout-pair test-vectors +//! ``` +//! +//! The `source` subcommand acts as the secret-holding device; `target` acts +//! as the receiving device. Together they exercise the full NIP-AB protocol +//! over a live Nostr relay. + +use std::io::{self, BufRead, Write}; +use std::time::Duration; + +use clap::{Parser, Subcommand}; +use futures_util::{SinkExt, StreamExt}; +use nostr::{Event, EventBuilder, Keys, SecretKey, ToBech32}; +use sprout_core::kind::KIND_PAIRING; +use sprout_core::pairing::session::PairingSession; +use sprout_core::pairing::{ + crypto::{derive_sas, derive_session_id, derive_transcript_hash, format_sas}, + qr::{decode_qr, encode_qr}, + types::PayloadType, + PairingError, +}; +use tokio::time::timeout; +use tokio_tungstenite::{connect_async, tungstenite::Message}; +use zeroize::Zeroizing; + +// ── CLI definition ──────────────────────────────────────────────────────────── + +#[derive(Parser)] +#[command( + name = "sprout-pair", + about = "NIP-AB device pairing interop testing tool", + long_about = "Test the NIP-AB device pairing protocol end-to-end.\n\ + Run 'source' on one terminal and 'target' on another." +)] +struct Cli { + #[command(subcommand)] + command: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Act as the source device (holds the secret, displays QR code). + Source { + /// Relay WebSocket URL to use for pairing. + #[arg(long, default_value = "wss://relay.damus.io")] + relay: String, + + /// nsec (bech32) of the key to transfer. If omitted, generates a test key. + #[arg(long)] + nsec: Option, + }, + + /// Act as the target device (scans QR code, receives the secret). + Target { + /// Override relay URL (default: read from QR URI). + #[arg(long)] + relay: Option, + + /// Print received secrets to stdout. Off by default. + #[arg(long, default_value_t = false)] + show_secret: bool, + }, + + /// Print NIP-AB test vectors derived from the spec's fixed keys. + TestVectors, +} + +// ── Error type ──────────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +enum CliError { + #[error("pairing error: {0}")] + Pairing(#[from] PairingError), + + #[error("WebSocket error: {0}")] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("I/O error: {0}")] + Io(#[from] io::Error), + + #[error("invalid nsec: {0}")] + InvalidNsec(String), + + #[error("timeout waiting for peer")] + Timeout, + + #[error("{0}")] + Other(String), +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + if let Err(e) = run(cli.command).await { + eprintln!("error: {e}"); + std::process::exit(1); + } +} + +async fn run(cmd: Cmd) -> Result<(), CliError> { + match cmd { + Cmd::Source { relay, nsec } => cmd_source(relay, nsec).await, + Cmd::Target { relay, show_secret } => cmd_target(relay, show_secret).await, + Cmd::TestVectors => cmd_test_vectors(), + } +} + +// ── source subcommand ───────────────────────────────────────────────────────── + +async fn cmd_source(relay_url: String, nsec: Option) -> Result<(), CliError> { + // Resolve the payload to transfer. + let (payload_str, payload_type) = resolve_payload(nsec)?; + + // Create pairing session. + let (mut session, qr) = PairingSession::new_source(relay_url.clone()); + let qr_uri = encode_qr(&qr); + + println!("QR URI (contains session secret — do not share beyond the target device):"); + println!("{qr_uri}"); + println!("Waiting for target to scan QR code..."); + + // Connect to relay and handle NIP-42 auth if required. + // Auth uses the session's ephemeral keys so the relay accepts our events. + let (ws, _) = connect_async(&relay_url).await?; + let (mut write, mut read) = ws.split(); + handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?; + + // Subscribe for events tagged to our ephemeral pubkey. + let our_pk = session.pubkey().to_hex(); + let sub_msg = serde_json::json!([ + "REQ", + "pair", + { "kinds": [KIND_PAIRING], "#p": [our_pk] } + ]); + write + .send(Message::Text(sub_msg.to_string().into())) + .await?; + + // Wait for EOSE to confirm the subscription is registered on the relay + // before the target can race us with an offer we'd miss. + wait_for_eose(&mut read, "pair", Duration::from_secs(10)).await?; + + // Wait for a valid offer event (silently discard junk per NIP-AB §Event Validation). + let sas = loop { + let event = wait_for_event(&mut read, "pair", Duration::from_secs(120)).await?; + check_for_abort(&mut session, &event)?; + match session.handle_offer(&event) { + Ok(sas) => break sas, + Err(_) => continue, // silently discard per NIP-AB §Event Validation item 7 + } + }; + println!("Offer received from target."); + println!("SAS code: {sas}"); + print!("Does your other device show {sas}? [y/n]: "); + io::stdout().flush()?; + + let confirmed = read_yes_no()?; + if !confirmed { + // Send abort and exit. + if let Some(abort_event) = + session.abort(sprout_core::pairing::types::AbortReason::SasMismatch)? + { + publish_event(&mut write, &abort_event).await?; + } + return Err(CliError::Other("SAS mismatch — session aborted".into())); + } + + // Send sas-confirm. + let sas_confirm_event = session.confirm_sas()?; + publish_event(&mut write, &sas_confirm_event).await?; + println!("Sending identity..."); + + // Send payload. + let payload_event = session.send_payload(payload_type, payload_str)?; + publish_event(&mut write, &payload_event).await?; + + // Wait for a valid complete event (skip junk; exit on peer abort). + // Surface complete(success=false) explicitly instead of swallowing it. + loop { + let event = wait_for_event(&mut read, "pair", Duration::from_secs(60)).await?; + check_for_abort(&mut session, &event)?; + match session.handle_complete(&event) { + Ok(()) => break, + Err(PairingError::UnexpectedMessage { ref got, .. }) + if got.contains("success=false") => + { + return Err(CliError::Other( + "target reported failure importing the key — check the other device".into(), + )); + } + Err(_) => continue, // silently discard per NIP-AB §Event Validation item 7 + } + } + + println!("Transfer complete! ✓"); + Ok(()) +} + +// ── target subcommand ───────────────────────────────────────────────────────── + +async fn cmd_target(relay_override: Option, show_secret: bool) -> Result<(), CliError> { + // Read QR URI from stdin. + print!("Paste the QR URI: "); + io::stdout().flush()?; + let qr_uri = read_line()?; + let qr_uri = qr_uri.trim(); + + // Decode QR. + let mut qr = decode_qr(qr_uri)?; + + // Apply relay override if provided. + if let Some(relay) = relay_override { + qr.relays = vec![relay]; + } + + let relay_url = qr + .relays + .first() + .cloned() + .ok_or_else(|| CliError::Other("QR URI contains no relay URL".into()))?; + + println!("Connecting to {relay_url}..."); + + // Create target session + offer event. + let (mut session, offer_event) = PairingSession::new_target(&qr)?; + + // Connect to relay and handle NIP-42 auth if required. + let (ws, _) = connect_async(&relay_url).await?; + let (mut write, mut read) = ws.split(); + handle_nip42_auth(&mut read, &mut write, &session, &relay_url).await?; + + // Subscribe BEFORE publishing the offer so we don't miss a fast + // sas-confirm from the source (fixes a race condition). + let our_pk = session.pubkey().to_hex(); + let sub_msg = serde_json::json!([ + "REQ", + "pair", + { "kinds": [KIND_PAIRING], "#p": [our_pk] } + ]); + write + .send(Message::Text(sub_msg.to_string().into())) + .await?; + + // Wait for EOSE to confirm the subscription is registered on the relay + // before publishing the offer. Without this, the relay may process our + // EVENT before our REQ, causing us to miss the source's response. + wait_for_eose(&mut read, "pair", Duration::from_secs(10)).await?; + + // Now publish the offer event. + publish_event(&mut write, &offer_event).await?; + + // Target already knows the SAS from the QR scan — display it now so + // the user can compare while the source is also displaying its code. + let sas = session + .sas_code() + .ok_or_else(|| CliError::Other("no SAS code".into()))?; + println!("SAS code: {sas}"); + println!("Verify this matches your source device."); + println!("Offer sent. Waiting for source to confirm SAS..."); + + // Wait for a valid sas-confirm event (skip junk; exit on peer abort). + // TranscriptMismatch is a hard security failure (possible MITM) — + // surface it immediately rather than swallowing it in the generic handler. + loop { + let event = wait_for_event(&mut read, "pair", Duration::from_secs(120)).await?; + check_for_abort(&mut session, &event)?; + match session.handle_sas_confirm(&event) { + Ok(_) => break, + Err(PairingError::TranscriptMismatch) => { + // NIP-AB §Step 3: target MUST send abort with reason + // "sas_mismatch" on transcript hash mismatch. + if let Ok(Some(abort_event)) = + session.abort(sprout_core::pairing::types::AbortReason::SasMismatch) + { + let _ = publish_event(&mut write, &abort_event).await; + } + return Err(CliError::Other( + "SECURITY: transcript hash mismatch — possible MITM attack. Session aborted." + .into(), + )); + } + Err(_) => continue, // silently discard per NIP-AB §Event Validation item 7 + } + } + + // Explicit target-side confirmation: the user must approve. + print!("Does your source device show {sas}? [y/n]: "); + io::stdout().flush()?; + let confirmed = read_yes_no()?; + if !confirmed { + if let Some(abort_event) = + session.abort(sprout_core::pairing::types::AbortReason::SasMismatch)? + { + publish_event(&mut write, &abort_event).await?; + } + return Err(CliError::Other("SAS mismatch — session aborted".into())); + } + session.confirm_target_sas()?; + println!("SAS confirmed. Waiting for payload..."); + + // Wait for a valid payload event (silently discard junk; exit on peer abort). + let (payload_type, payload) = loop { + let event = wait_for_event(&mut read, "pair", Duration::from_secs(60)).await?; + check_for_abort(&mut session, &event)?; + match session.handle_payload(&event) { + Ok(result) => break result, + Err(_) => continue, // silently discard per NIP-AB §Event Validation item 7 + } + }; + + // Display received payload (secrets gated behind --show-secret). + let kind_label = match payload_type { + PayloadType::Nsec => "nsec", + PayloadType::Bunker => "bunker", + PayloadType::Connect => "nostrconnect", + PayloadType::Custom => "custom", + }; + println!("Received {kind_label} payload!"); + if show_secret { + println!("{kind_label}: {}", &*payload); + } else { + println!("(use --show-secret to display the received secret)"); + } + + // Send complete event. + let complete_event = session.send_complete()?; + publish_event(&mut write, &complete_event).await?; + + println!("Transfer complete! ✓"); + Ok(()) +} + +// ── test-vectors subcommand ─────────────────────────────────────────────────── + +fn cmd_test_vectors() -> Result<(), CliError> { + // Fixed test keys from the NIP-AB spec. + let session_secret: [u8; 32] = + hex_to_32("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2")?; + let source_priv: [u8; 32] = + hex_to_32("7f4c11a9c9d1e3b5a7f2e4d6c8b0a2f4e6d8c0b2a4f6e8d0c2b4a6f8e0d2c4b5")?; + let target_priv: [u8; 32] = + hex_to_32("3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b")?; + + // Derive keys. + let src_sk = + SecretKey::from_slice(&source_priv).map_err(|e| CliError::InvalidNsec(e.to_string()))?; + let tgt_sk = + SecretKey::from_slice(&target_priv).map_err(|e| CliError::InvalidNsec(e.to_string()))?; + let src_keys = Keys::new(src_sk); + let tgt_keys = Keys::new(tgt_sk); + + let source_pubkey: [u8; 32] = src_keys.public_key().to_bytes(); + let target_pubkey: [u8; 32] = tgt_keys.public_key().to_bytes(); + + // Derive all values. + let session_id = derive_session_id(&session_secret); + let ecdh_shared = + nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key()); + let (sas_code_u32, sas_input) = derive_sas(&ecdh_shared, &session_secret); + let sas_code = format_sas(sas_code_u32); + let transcript_hash = derive_transcript_hash( + &session_id, + &source_pubkey, + &target_pubkey, + &sas_input, + &session_secret, + ); + + // Print as a table suitable for pasting into the NIP spec. + let col_w = 20usize; + let val_w = 66usize; + let sep = format!("+-{:- Result<(), CliError> { + match session.handle_abort(event) { + Ok(reason) => Err(CliError::Other(format!( + "peer aborted the session: {reason:?}" + ))), + Err(_) => Ok(()), // not an abort — caller should try its own handler + } +} + +// ── NIP-42 auth helper ──────────────────────────────────────────────────────── + +/// Handle NIP-42 authentication if the relay requires it. +/// +/// Uses the pairing session's ephemeral keys to authenticate, ensuring the +/// relay accepts events signed by those same keys. +async fn handle_nip42_auth( + read: &mut R, + write: &mut W, + session: &PairingSession, + relay_url: &str, +) -> Result<(), CliError> +where + R: StreamExt> + Unpin, + W: SinkExt + Unpin, +{ + // Wait up to 3 seconds for an AUTH challenge. Many relays don't require + // auth at all, so a timeout here is normal (not an error). + let auth_result = timeout(Duration::from_secs(3), async { + loop { + let msg = read + .next() + .await + .ok_or_else(|| CliError::Other("relay closed during auth".into()))??; + + if let Message::Text(text) = msg { + if let Some(challenge) = parse_auth_challenge(text.as_str()) { + return Ok(challenge); + } + } + } + }) + .await; + + let challenge = match auth_result { + Ok(Ok(c)) => c, + Ok(Err(e)) => return Err(e), + Err(_) => return Ok(()), // No AUTH challenge — relay doesn't require it + }; + + // Build and send the NIP-42 auth response using the session's ephemeral keys. + let relay_url_parsed: url::Url = relay_url + .parse() + .map_err(|e| CliError::Other(format!("invalid relay URL: {e}")))?; + let auth_event = session + .sign_event(EventBuilder::auth(challenge, relay_url_parsed)) + .map_err(|e| CliError::Other(format!("failed to sign auth event: {e}")))?; + + let msg = serde_json::json!(["AUTH", auth_event]); + write.send(Message::Text(msg.to_string().into())).await?; + + // Wait for OK response (up to 5 seconds). + let _ = timeout(Duration::from_secs(5), async { + loop { + let msg = read + .next() + .await + .ok_or_else(|| CliError::Other("relay closed during auth".into()))??; + if let Message::Text(text) = msg { + if text.contains("\"OK\"") || text.contains("[\"OK\"") { + return Ok::<(), CliError>(()); + } + } + } + }) + .await; + + Ok(()) +} + +/// Parse an `["AUTH", ""]` relay message. +fn parse_auth_challenge(text: &str) -> Option { + let arr: serde_json::Value = serde_json::from_str(text).ok()?; + let arr = arr.as_array()?; + if arr.len() >= 2 && arr[0].as_str()? == "AUTH" { + return arr[1].as_str().map(|s| s.to_string()); + } + None +} + +// ── WebSocket helpers ───────────────────────────────────────────────────────── + +/// Publish a Nostr event to the relay. +async fn publish_event(write: &mut S, event: &Event) -> Result<(), CliError> +where + S: SinkExt + Unpin, +{ + let msg = serde_json::json!(["EVENT", event]); + write.send(Message::Text(msg.to_string().into())).await?; + Ok(()) +} + +/// Wait for the next [`Event`] from the relay on a given subscription ID. +/// +/// Skips `OK`, `EOSE`, and non-EVENT messages. Returns [`CliError::Timeout`] +/// if no event arrives within `dur`. +async fn wait_for_event(read: &mut S, sub_id: &str, dur: Duration) -> Result +where + S: StreamExt> + Unpin, +{ + timeout(dur, async { + loop { + let msg = read + .next() + .await + .ok_or_else(|| CliError::Other("relay connection closed".into()))??; + + if let Message::Text(text) = msg { + if let Some(event) = parse_relay_event(text.as_str(), sub_id) { + return Ok(event); + } + } + } + }) + .await + .map_err(|_| CliError::Timeout)? +} + +/// Wait for an EOSE message from the relay for the given subscription ID. +/// +/// EOSE (`["EOSE", ""]`) confirms the subscription is registered and +/// all historical events have been delivered. Skips non-EOSE messages. +async fn wait_for_eose(read: &mut S, sub_id: &str, dur: Duration) -> Result<(), CliError> +where + S: StreamExt> + Unpin, +{ + timeout(dur, async { + loop { + let msg = read + .next() + .await + .ok_or_else(|| CliError::Other("relay closed while waiting for EOSE".into()))??; + if let Message::Text(text) = msg { + if let Ok(arr) = serde_json::from_str::(text.as_str()) { + if let Some(arr) = arr.as_array() { + if arr.len() >= 2 + && arr[0].as_str() == Some("EOSE") + && arr[1].as_str() == Some(sub_id) + { + return Ok(()); + } + } + } + } + } + }) + .await + .map_err(|_| CliError::Timeout)? +} + +/// Parse a relay message of the form `["EVENT", "", ]`. +/// +/// Returns `None` for any other message type. +fn parse_relay_event(text: &str, sub_id: &str) -> Option { + let arr: serde_json::Value = serde_json::from_str(text).ok()?; + let arr = arr.as_array()?; + + if arr.len() < 3 { + return None; + } + if arr[0].as_str()? != "EVENT" { + return None; + } + if arr[1].as_str()? != sub_id { + return None; + } + + serde_json::from_value(arr[2].clone()).ok() +} + +// ── Payload helpers ─────────────────────────────────────────────────────────── + +/// Resolve the payload to send. +/// +/// If `nsec` is provided, parse it as bech32 and return the raw nsec string. +/// Otherwise generate a fresh test key and return its nsec. +fn resolve_payload(nsec: Option) -> Result<(Zeroizing, PayloadType), CliError> { + match nsec { + Some(s) => { + // Validate it parses as a secret key. + let _sk = SecretKey::parse(&s).map_err(|e| CliError::InvalidNsec(e.to_string()))?; + Ok((Zeroizing::new(s), PayloadType::Nsec)) + } + None => { + let keys = Keys::generate(); + let nsec_str = keys + .secret_key() + .to_bech32() + .map_err(|e| CliError::InvalidNsec(e.to_string()))?; + println!("(no --nsec provided; using generated test key)"); + Ok((Zeroizing::new(nsec_str), PayloadType::Nsec)) + } + } +} + +// ── I/O helpers ─────────────────────────────────────────────────────────────── + +/// Read a single line from stdin (trims trailing newline). +fn read_line() -> Result { + let stdin = io::stdin(); + let mut line = String::new(); + stdin.lock().read_line(&mut line)?; + Ok(line + .trim_end_matches('\n') + .trim_end_matches('\r') + .to_string()) +} + +/// Prompt for y/n and return true for 'y'/'Y'. +fn read_yes_no() -> Result { + let line = read_line()?; + Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes" | "YES")) +} + +// ── Crypto helpers ──────────────────────────────────────────────────────────── + +/// Decode a 64-char hex string into a `[u8; 32]`. +fn hex_to_32(s: &str) -> Result<[u8; 32], CliError> { + let bytes = hex::decode(s).map_err(|e| CliError::Other(format!("invalid hex '{s}': {e}")))?; + bytes + .try_into() + .map_err(|_| CliError::Other(format!("expected 32 bytes, got wrong length for '{s}'"))) +} diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index 42ffdfb13..d7ddd91a4 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -391,6 +391,42 @@ async fn handle_ephemeral_event( "fan-out: {drop_count} connection(s) cancelled due to full/closed buffers" ); } + } else { + // Channel-less ephemeral events (e.g., NIP-AB pairing kind:24134). + // + // Sentinel pattern: we use `Uuid::nil()` (all-zeros UUID) as a + // "global channel" routing key in Redis pub/sub. This lets other relay + // nodes receive and fan out these events without any real channel_id. + // The nil UUID is ONLY a Redis routing key — it never reaches the DB. + // On the receiving end (main.rs subscriber loop), `is_nil()` is checked + // and converted back to `None` so `fan_out()` uses the global index. + state.mark_local_event(&event.id); + + if let Err(e) = state.pubsub.publish_event(uuid::Uuid::nil(), &event).await { + state.local_event_ids.invalidate(&event.id.to_bytes()); + warn!(conn_id = %conn_id, event_id = %event_id_hex, "Ephemeral global publish failed: {e}"); + } + + // Direct fan-out to local WS subscribers. + // Pass channel_id=None so fan_out() uses the global subscriber index. + let stored_event = StoredEvent::new(event.clone(), None); + let matches = state.sub_registry.fan_out(&stored_event); + let event_json = serde_json::to_string(&event) + .expect("nostr::Event serialization is infallible for well-formed events"); + let mut drop_count = 0u32; + for (target_conn_id, sub_id) in &matches { + let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); + if !state.conn_manager.send_to(*target_conn_id, msg) { + drop_count += 1; + } + } + if drop_count > 0 { + tracing::warn!( + event_id = %event_id_hex, + drop_count, + "fan-out: {drop_count} connection(s) cancelled due to full/closed buffers" + ); + } } conn.send(RelayMessage::ok(event_id_hex, true, "")); diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index 3ced6651b..64c1ac2cf 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -219,10 +219,18 @@ async fn main() -> anyhow::Result<()> { loop { match rx.recv().await { Ok(channel_event) => { - let stored = sprout_core::StoredEvent::new( - channel_event.event, - Some(channel_event.channel_id), - ); + // Nil UUID is the sentinel for channel-less global events + // (see event.rs `else` branch). Convert back to None so + // fan_out() uses the global subscriber index instead of + // looking up subscribers under Some(Uuid::nil()), which + // would find nothing and silently drop every cross-node + // global event. + let channel_id = if channel_event.channel_id.is_nil() { + None + } else { + Some(channel_event.channel_id) + }; + let stored = sprout_core::StoredEvent::new(channel_event.event, channel_id); // Skip events that were already fanned out in-process (local echo). // The cache has TTL-based eviction (60s) so entries are bounded diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 20c7a8aef..6eef45b7a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -5252,10 +5252,14 @@ dependencies = [ "chrono", "hex", "nostr 0.36.0", + "percent-encoding", + "rand 0.10.1", "serde", "serde_json", + "subtle", "thiserror 2.0.18", "uuid", + "zeroize", ] [[package]] diff --git a/schema/schema.sql b/schema/schema.sql index e77185697..a75038399 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -41,7 +41,8 @@ CREATE TABLE channels ( purpose_set_at TIMESTAMPTZ, participant_hash BYTEA, ttl_seconds INT, - ttl_deadline TIMESTAMPTZ + ttl_deadline TIMESTAMPTZ, + CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) ); CREATE INDEX idx_channels_type ON channels (channel_type);