diff --git a/Cargo.lock b/Cargo.lock index f99759afc..d6cc3d233 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -393,6 +393,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bech32" version = "0.11.1" @@ -465,6 +471,7 @@ checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", "hex-conservative 0.2.3", + "serde", ] [[package]] @@ -653,6 +660,7 @@ dependencies = [ "buzz-agent", "buzz-agent-controller", "buzz-credential-store", + "buzz-pairing", "buzzodz-plugins", "bytes", "chrono", @@ -660,9 +668,11 @@ dependencies = [ "futures-util", "getrandom 0.3.4", "gtk", + "hex", "hmac", "libc", - "nostr", + "nostr 0.44.8", + "nostr 0.45.5", "objc2", "objc2-app-kit", "objc2-foundation", @@ -670,8 +680,10 @@ dependencies = [ "objc2-web-kit", "percent-encoding", "portable-pty", + "qrcode", "reqwest", "rusqlite", + "rustls", "secp256k1 0.31.1", "security-framework", "serde", @@ -687,10 +699,13 @@ dependencies = [ "tauri-winrt-notification", "tempfile", "tokio", + "tokio-tungstenite", + "tokio-util", "url", "user-idle", "uuid", "webkit2gtk", + "webpki-roots 1.0.9", "webview2-com", "windows-core 0.61.2", "windows-sys 0.61.2", @@ -699,6 +714,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "buzz-pairing" +version = "0.0.0" +dependencies = [ + "hex", + "nostr 0.44.8", + "percent-encoding", + "rand 0.10.2", + "serde", + "serde_json", + "subtle", + "thiserror 2.0.20", + "url", + "zeroize", +] + [[package]] name = "buzzodz-plugins" version = "0.1.0" @@ -709,7 +740,7 @@ dependencies = [ "dirs", "dotenvy", "libc", - "nostr", + "nostr 0.45.5", "serde", "serde_json", "sha2 0.10.9", @@ -786,7 +817,7 @@ dependencies = [ "maybe-owned", "rustix", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -1128,6 +1159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1380,7 +1412,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1587,7 +1619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2436,7 +2468,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -2610,6 +2642,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "io-extras" version = "0.19.0" @@ -3117,7 +3161,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3193,6 +3237,30 @@ dependencies = [ "libc", ] +[[package]] +name = "nostr" +version = "0.44.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ff7b77ef428b40aa2834a6acbae38a0e104c98b306208ca4b87a420d579a4b" +dependencies = [ + "base64 0.22.1", + "bech32 0.11.1", + "bip39", + "bitcoin_hashes 0.14.101", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1 0.29.1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + [[package]] name = "nostr" version = "0.45.5" @@ -3224,7 +3292,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3713,6 +3781,27 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4001,6 +4090,12 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-xml" version = "0.42.0" @@ -4064,7 +4159,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4407,7 +4502,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4475,7 +4570,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4508,6 +4603,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4583,6 +4687,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.8", + "secp256k1-sys 0.10.1", + "serde", +] + [[package]] name = "secp256k1" version = "0.30.0" @@ -5021,7 +5148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5691,7 +5818,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5872,8 +5999,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -6150,7 +6281,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6171,6 +6302,8 @@ dependencies = [ "httparse", "log", "rand 0.9.5", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.20", ] @@ -6195,7 +6328,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6606,6 +6739,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -6664,7 +6815,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 11d20974b..fc50185b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] -members = ["crates/plugin-manager", "crates/agent-controller", "crates/credential-store", "src-tauri"] +members = ["crates/pairing", "crates/plugin-manager", "crates/agent-controller", "crates/credential-store", "src-tauri"] default-members = ["crates/plugin-manager"] resolver = "2" diff --git a/crates/pairing/Cargo.toml b/crates/pairing/Cargo.toml new file mode 100644 index 000000000..99362e5d0 --- /dev/null +++ b/crates/pairing/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "buzz-pairing" +version = "0.0.0" +edition = "2021" +license = "Apache-2.0" +description = "Buzz NIP-AB device pairing protocol" + +[dependencies] +nostr = { version = "0.44", features = ["nip44"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +hex = "0.4" +rand = "0.10" +subtle = "2.6" +zeroize = "1.8" +percent-encoding = "2.3" +url = "2" diff --git a/crates/pairing/README.md b/crates/pairing/README.md new file mode 100644 index 000000000..84c53ea04 --- /dev/null +++ b/crates/pairing/README.md @@ -0,0 +1,26 @@ +# Buzz pairing protocol + +Ported from [`block/buzz`](https://github.com/block/buzz/tree/ac0ad7c3004683e5db813492846d787404a2b475/crates/buzz-core/src/pairing), +PR [#8085](https://github.com/block/buzz/pull/8085), under the repository's +Apache-2.0 license. The imported protocol and tests preserve the source's +cryptography, event validation, timeout, replay protection, legacy confirmation, +and encrypted `desktop-code-v1` capability. Local changes extract the module as its +own crate, make kind 24134 local, update crate paths in documentation, and format +with this repository's pinned tools. + +This crate has no network or Keychain access. Desktop transport and account +export are owned by `src-tauri/src/pairing`. Its tests include the exact source +protocol vectors and code-entry proof regressions. Run `bin/cargo test -p +buzz-pairing` for unit tests and doctests. + +The source QR lifetime differs from upstream: `start_source_lifetime()` resets +its two-minute clock after desktop transport setup and immediately before the +visible QR is published. The desktop uses that same deadline for expiry. The +upstream reference starts the clock at session construction and expires the QR +at that original deadline; retaining that behavior here would shorten the +visible-QR contract after slow discovery or subscription. Keep this divergence +when syncing upstream. + +The desktop-code port also fails closed when an oversized rejection cannot be +serialized: exhausting the guess budget clears the code and aborts before the +fallible reply construction. A regression covers that boundary. diff --git a/crates/pairing/src/NIP-AB.md b/crates/pairing/src/NIP-AB.md new file mode 100644 index 000000000..fc8f69245 --- /dev/null +++ b/crates/pairing/src/NIP-AB.md @@ -0,0 +1,824 @@ +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 | `payload` *(buffer until user confirms SAS; do not process until state advances to `Transferring`)* | + | `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. If the user denies the SAS on either device, that device MUST immediately send `abort` with reason `"user_denied"`, discard all session state, and terminate the session. SAS denial is the primary MITM defense — implementations MUST NOT allow the protocol to continue after a denial. + +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 session inconsistency or parameter tampering; _target_ MUST send `abort` with reason `"sas_mismatch"`, discard any payload received in this session, and terminate. Note: because _source_ sends the payload immediately after `sas-confirm` (without waiting for an acknowledgment), the payload may already be in transit or delivered when the mismatch is detected. The transcript hash is a **detection** mechanism, not a prevention gate — MITM prevention relies on the user's visual SAS comparison on the _source_ device *before* the source confirms and sends the payload. + +After verifying the transcript hash, _target_ enters the `AwaitingConfirmation` state. _target_ transitions to `Transferring` when the user confirms the SAS on the target device. _target_ MUST NOT import, process, or act on the secret material within any received `payload` event until **both** the transcript hash has been verified **and** the user has confirmed the SAS on the target device. (Implementations may NIP-44-decrypt the event content to validate the message `type` for state-machine routing. However, implementations MUST NOT deserialize, extract, log, persist, or act on the `payload` field within a `payload`-type message until both conditions are met. If early decryption is used, the decrypted content MUST be treated as opaque for all purposes other than `type` classification, and MUST be zeroized if the session is aborted before dual consent. The safest implementation strategy — and the one closest to the formal proof — is to buffer the raw NIP-44 ciphertext and defer all decryption until after dual consent.) + +### Step 4: Payload Transfer + +After the user confirms the SAS on the _source_ device, _source_ publishes the `sas-confirm` event (Step 3) followed immediately by 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, session secret, and any decrypted payload plaintext from memory before freeing. On the _target_ side, the decrypted payload MUST be zeroed from working memory once it has been committed to platform-secure storage. + +### 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): +# discard_buffered_payload() # payload may have arrived early +# 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"` | SAS codes did not match, or transcript hash verification failed | +| `"user_denied"` | User explicitly denied the pairing | +| `"timeout"` | Session timed out | +| `"protocol_error"` | Local fatal condition (e.g., internal state corruption, unrecoverable implementation error). MUST NOT be sent in response to a peer's out-of-order or validation-failing event — those MUST be silently discarded per §Event Validation. | + +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 SAS on source] + + Publish sas-confirm ──────────────► + {type:"sas-confirm", ──────────────────────► Verify transcript_hash + transcript_hash} + Publish payload ──────────────────► (sent immediately; + {type:"payload", source does not wait + payload_type:"nsec", for target) + payload:"ncryptsec1..."} ──────────────────────► Buffer payload + + [User confirms SAS on target] + + Decrypt payload + 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 MUST 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 MUST 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 for session establishment (offer authentication and SAS derivation). Note: the payload encryption key is derived purely from ECDH and does not depend on `session_secret`, so this defense-in-depth applies to the pairing handshake, not to payload confidentiality directly. + +### 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`. This gives the _target_ a cryptographic consistency check that detects session inconsistency or parameter tampering. (Cross-session replay is already prevented independently by `p`-tag binding and NIP-44 key binding — see §Replay Protection.) The transcript hash is **not** the MITM prevention mechanism — that role belongs to the user's visual SAS comparison on the _source_ device, which gates whether `sas-confirm` and the payload are sent at all. + +### 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. + +## Formal Verification + +A Tamarin model of the protocol lives at [NIP-AB.spthy](NIP-AB.spthy). The model focuses on the security-critical core of the protocol: + +- QR distribution of `session_secret` and `source_ephemeral_pubkey` +- `offer` authentication via possession of the QR secret +- SAS comparison as an explicit user-mediated gate +- `sas-confirm` transcript binding +- encrypted `payload` delivery +- advisory `complete` acknowledgment + +The model treats the relay and network as a full **Dolev-Yao attacker**: the adversary can intercept, reorder, replay, drop, and fabricate messages. It also includes explicit compromise rules for: + +- QR-code exposure (`session_secret` leaks out-of-band) +- source-session compromise +- target-session compromise + +Under those assumptions, the proved lemmas are: + +**Core security invariants:** + +- **`executable_core_flow`** *(executability)*: the happy-path protocol completes — both sides reach `complete` with the same session and payload. +- **`payload_requires_successful_sas_match`** *(SAS gate)*: an honest source can only send `payload` after a successful SAS match. +- **`payload_secrecy_without_endpoint_compromise`** *(payload secrecy)*: the payload remains unknown to the attacker unless one endpoint session is compromised. QR-code exposure alone does not break secrecy, because the SAS gate pins delivery to an honest target-role execution in the model. (This assumes correct SAS verification — the model treats SAS comparison as perfect; the ~20-bit collision bound is a separate computational argument, see §Design Rationale.) +- **`target_completion_agrees_on_source_payload`** *(target agreement)*: under no-compromise assumptions, if the target completes, then the source previously sent that exact payload in the same session. +- **`source_completion_implies_prior_target_completion_without_compromise`** *(source completion soundness)*: under the same no-compromise assumptions, if the source accepts `complete`, the target previously sent `complete` for the same session. (The model abstracts away `success:true/false` semantics — this proves the `complete` event is authentic, not that import succeeded.) + +- **`injective_target_source_agreement`** *(injective agreement, target → source)*: each target completion corresponds to a unique prior source payload send with the same `(sid, pkS, pkT, payload)`, and that send is itself unique. This is a one-directional injective mapping; the reverse (every send leads to a completion) is a liveness property not provable under Dolev-Yao scheduling. + +**MITM resistance:** + +- **`sas_match_implies_genuine_target`**: every SAS match is bound to a `pkT` that an honest target-role instance in the model actually generated (i.e., from `Target_Scan_QR_And_Send_Offer` with a fresh ephemeral). A network adversary that substitutes the offer's ephemeral key with an attacker-chosen value cannot cause the SAS-match rule to fire. This proves resistance to network key-substitution, not physical-device authenticity — the latter relies on the user's physical verification of the SAS code and is outside the symbolic model's scope. +- **`payload_delivery_requires_genuine_target`** *(composition)*: no payload is ever sent under a `pkT` that lacks a prior honest target-role execution. Follows from the SAS gate combined with the genuine-target lemma. + +**Dual consent and payload buffering:** + +- **`target_decrypts_payload_only_after_dual_consent`**: the target never decrypts the payload without **both** transcript verification **and** an explicit user-approval step. The model proves a stronger abstraction than the spec requires: payload plaintext is not made available to protocol logic before both conditions are met. (The spec permits implementations to NIP-44-decrypt the event content early for message-type classification, but the model conservatively defers all decryption — this is strictly stronger. Early type-field decryption on the target is a local operation that does not emit network-observable messages or alter protocol flow; since the Dolev-Yao attacker already possesses the ciphertext, local decryption reveals nothing new to the adversary, and all proved properties (secrecy, agreement, MITM resistance) hold a fortiori for the spec's more permissive buffering model.) +- **`decryption_requires_prior_buffering`**: every decryption is preceded by buffering — the intended two-phase flow (buffer ciphertext, then decrypt after approval) is explicit in the proof surface. +- **`executable_payload_buffered_before_approval`** *(sanity)*: the payload **can** arrive and be buffered before the target user approves, proving the buffering path is reachable and the dual-consent gate is not vacuously enforced by message ordering alone. + +**Reachability and anti-vacuousness:** + +- **`executable_with_qr_leak`**, **`executable_with_source_compromise`**, **`executable_with_target_compromise`**: each compromise rule is reachable from protocol state (i.e., the compromise rules are not dead code), so the no-compromise guards in the secrecy and agreement lemmas are non-trivial. +- **`source_compromise_can_leak_payload`**, **`target_compromise_can_leak_payload`**: there exist traces where endpoint compromise (leakage of session-ephemeral private keys) leads to attacker knowledge of the payload, confirming that the no-compromise guards in the secrecy lemma are load-bearing. + +The Tamarin model intentionally abstracts away details that are orthogonal to the cryptographic proof: + +- exact NIP-01 event IDs / Schnorr signatures — relay anti-forgery relies on these but is not proved symbolically +- exact NIP-44 ciphertext framing, padding, version bytes, and nonce handling — modeled as ideal authenticated encryption (`senc`) over a DH-derived key +- HKDF-SHA256 — collapsed to tagged hashes (`h(< label, inputs >)`) preserving domain separation but not RFC 5869 internals +- ECDH — modeled as symbolic Diffie-Hellman, not exact secp256k1 x-coordinate extraction +- SAS comparison — modeled as perfect (requiring actual key agreement); the ~20-bit collision bound (1/10^6) is a separate computational argument (see §Design Rationale) +- timeout and abort branches +- duplicate-event bookkeeping +- `p`-tag validation and within-session replay protection — these are state-machine / implementation requirements, not Tamarin results +- version negotiation (`version` field in `offer`) +- `complete` success/failure semantics +- payload typing (`nsec` / `bunker` / `connect` / `custom`) + +Those behaviors remain normative in this document and in the Rust implementation; they are simply not the focus of the symbolic proof. + +Run the proof with: + +```bash +tamarin-prover --prove crates/buzz-core/src/pairing/NIP-AB.spthy +``` + +## 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 `buzz-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/pairing/src/NIP-AB.spthy b/crates/pairing/src/NIP-AB.spthy new file mode 100644 index 000000000..65b5d2670 --- /dev/null +++ b/crates/pairing/src/NIP-AB.spthy @@ -0,0 +1,455 @@ +theory NIP_AB +begin + +builtins: diffie-hellman, hashing, symmetric-encryption + +rule Source_Start: + [ Fr(~qr), Fr(~xs) ] + --[ + SourceStarted(h(< 'session-id', ~qr >), 'g'^~xs) + ]-> + [ + SrcWaiting(~qr, ~xs), + !QrVisible(~qr, 'g'^~xs), + !SourceSecrets(~qr, ~xs) + ] + +rule Leak_QR: + [ !QrVisible(qr, pkS) ] + --[ + QrLeaked(h(< 'session-id', qr >), pkS) + ]-> + [ Out(< qr, pkS >) ] + +rule Compromise_Source_Session: + [ !SourceSecrets(qr, xs) ] + --[ + SourceCompromised(h(< 'session-id', qr >), 'g'^xs) + ]-> + [ Out(< qr, xs >) ] + +rule Target_Scan_QR_And_Send_Offer: + [ !QrVisible(qr, pkS), Fr(~xt) ] + --[ + TargetStarted(h(< 'session-id', qr >), pkS, 'g'^~xt) + ]-> + [ + TgtOfferSent(qr, pkS, ~xt, 'g'^~xt), + !TargetSecrets(qr, pkS, ~xt), + Out( + < + 'offer_evt', + 'g'^~xt, + senc( + < 'offer', h(< 'session-id', qr >) >, + h(< 'pair-key', pkS^~xt >) + ) + > + ) + ] + +rule Compromise_Target_Session: + [ !TargetSecrets(qr, pkS, xt) ] + --[ + TargetCompromised(h(< 'session-id', qr >), pkS, 'g'^xt) + ]-> + [ Out(< qr, xt >) ] + +rule Source_Accepts_Offer: + [ SrcWaiting(qr, xs), + In( + < + 'offer_evt', + pkT, + senc( + < 'offer', h(< 'session-id', qr >) >, + h(< 'pair-key', pkT^xs >) + ) + > + ) + ] + --[ + SourceAcceptedOffer(h(< 'session-id', qr >), 'g'^xs, pkT) + ]-> + [ + SrcSasReady(qr, xs, pkT) + ] + +// SAS comparison is modeled as perfect: the rule requires both devices' +// state facts with matching cryptographic material, so it only fires when +// the ECDH shared secret (and therefore the SAS code) genuinely agrees. +// In reality SAS provides ~20 bits of entropy (1/10^6 collision); that +// computational bound is argued separately in §Design Rationale. +rule User_Compares_Matching_SAS: + [ SrcSasReady(qr, xs, pkT), + TgtOfferSent(qr, 'g'^xs, xt, pkT) + ] + --[ + SasMatched( + h(< 'session-id', qr >), + 'g'^xs, + pkT, + h(< 'sas', pkT^xs, qr >) + ) + ]-> + [ + SrcUserConfirmed(qr, xs, pkT), + TgtAwaitingSasConfirm(qr, 'g'^xs, xt) + ] + +// Transcript hash matches spec §Step 3 (see also PR #346 clarifications): +// transcript_hash = HKDF(IKM = session_id || pkS || pkT || sas_input, +// salt = session_secret, info = "nostr-pair-transcript-v1") +// Symbolically we collapse HKDF to h(.) and rely on collision resistance; +// qr (session_secret) is already committed via session_id and sas_input, so +// we do not include it again here. +// +// Per §Step 3, the transcript hash is a detection mechanism for session +// inconsistency, not the MITM prevention gate (that role belongs to the +// user's SAS comparison, modeled by User_Compares_Matching_SAS above). +rule Source_Sends_SAS_Confirm: + [ SrcUserConfirmed(qr, xs, pkT) ] + --[ + SourceSentSasConfirm(h(< 'session-id', qr >), 'g'^xs, pkT) + ]-> + [ + SrcReadyPayload(qr, xs, pkT), + Out( + < + 'sas_confirm_evt', + senc( + < + 'sas-confirm', + h( + < + 'transcript', + h(< 'session-id', qr >), + 'g'^xs, + pkT, + h(< 'sas', pkT^xs, qr >) + > + ) + >, + h(< 'pair-key', pkT^xs >) + ) + > + ) + ] + +rule Target_Receives_SAS_Confirm: + [ TgtAwaitingSasConfirm(qr, pkS, xt), + In( + < + 'sas_confirm_evt', + senc( + < + 'sas-confirm', + h( + < + 'transcript', + h(< 'session-id', qr >), + pkS, + 'g'^xt, + h(< 'sas', pkS^xt, qr >) + > + ) + >, + h(< 'pair-key', pkS^xt >) + ) + > + ) + ] + --[ + TargetVerifiedTranscript(h(< 'session-id', qr >), pkS, 'g'^xt) + ]-> + [ + TgtAwaitingUserApproval(qr, pkS, xt), + TgtCanBuffer(qr, pkS, xt) + ] + +// Target user approval: AwaitingConfirmation -> Transferring (spec §Step 3). +// This fires only after transcript verification (Target_Receives_SAS_Confirm). +rule Target_User_Approves_After_Transcript: + [ TgtAwaitingUserApproval(qr, pkS, xt) ] + --[ + TargetUserApproved(h(< 'session-id', qr >), pkS, 'g'^xt) + ]-> + [ + TgtTransferring(qr, pkS, xt) + ] + +rule Source_Sends_Payload: + [ SrcReadyPayload(qr, xs, pkT), Fr(~payload) ] + --[ + SourceSentPayload(h(< 'session-id', qr >), 'g'^xs, pkT, ~payload), + PayloadMarkedSecret(h(< 'session-id', qr >), ~payload) + ]-> + [ + SrcAwaitingComplete(qr, xs, pkT, ~payload), + Out( + < + 'payload_evt', + senc( + < 'payload', ~payload >, + h(< 'pair-key', pkT^xs >) + ) + > + ) + ] + +// --- Payload buffering (spec §Event Validation, §Step 3-4) --- +// +// Per #346, the source sends payload immediately after sas-confirm without +// waiting for the target. The target may therefore receive the encrypted +// payload while still in AwaitingConfirmation (before user approval). +// The spec requires: buffer the ciphertext, do NOT decrypt or import until +// both transcript_hash is verified AND the user confirms SAS on the target. +// +// We model this as two rules: +// 1. Target_Buffers_Payload — receives ciphertext into a holding fact +// WITHOUT extracting the plaintext. The rule validates that the +// ciphertext is encrypted under the session's DH-derived key +// (h(< 'pair-key', pkS^xt >)), matching the spec's requirement that +// invalid events are silently discarded without advancing state. +// Only fires after transcript verification (linear TgtCanBuffer), +// matching the spec's state table where `payload` is valid only in +// AwaitingConfirmation or Transferring (both post-transcript-verify). +// The linear fact is consumed, so at most one payload can be buffered +// per session — matching the spec's single-payload semantics. +// 2. Target_Decrypts_Payload — pattern-matches senc() to extract plaintext. +// Requires both TgtTransferring (post-approval) and the buffered +// ciphertext. This is the dual-consent gate: decryption only happens +// after transcript verification + user approval. + +rule Target_Buffers_Payload: + [ TgtCanBuffer(qr, pkS, xt), + In(< 'payload_evt', senc(msg, h(< 'pair-key', pkS^xt >)) >) + ] + --[ + TargetBufferedPayload(h(< 'session-id', qr >), pkS, 'g'^xt) + ]-> + [ + TgtPayloadBuffer(qr, pkS, xt, senc(msg, h(< 'pair-key', pkS^xt >))) + ] + +// Target decrypts the payload only after entering Transferring state +// (transcript verified + user approved). This is the dual-consent gate: +// the senc() pattern match here is the symbolic decryption operation. +rule Target_Decrypts_Payload: + [ TgtTransferring(qr, pkS, xt), + TgtPayloadBuffer(qr, pkS, xt, + senc( + < 'payload', payload >, + h(< 'pair-key', pkS^xt >) + ) + ) + ] + --[ + TargetDecryptedPayload(h(< 'session-id', qr >), pkS, 'g'^xt, payload) + ]-> + [ + TgtHasPayload(qr, pkS, xt, payload) + ] + +rule Target_Sends_Complete: + [ TgtHasPayload(qr, pkS, xt, payload) ] + --[ + TargetCompleted(h(< 'session-id', qr >), pkS, 'g'^xt, payload) + ]-> + [ + TgtDone(qr, pkS, xt, payload), + Out( + < + 'complete_evt', + senc('complete', h(< 'pair-key', pkS^xt >)) + > + ) + ] + +rule Source_Receives_Complete: + [ SrcAwaitingComplete(qr, xs, pkT, payload), + In( + < + 'complete_evt', + senc('complete', h(< 'pair-key', pkT^xs >)) + > + ) + ] + --[ + SourceCompleted(h(< 'session-id', qr >), 'g'^xs, pkT, payload) + ]-> + [ + SrcDone(qr, xs, pkT, payload) + ] + +// ============================================================================ +// Core security lemmas (invariants) +// ============================================================================ + +// Happy path: both sides complete with the same session and payload. +lemma executable_core_flow: + exists-trace + "Ex sid pkS pkT payload #i #j. + TargetCompleted(sid, pkS, pkT, payload) @ i + & SourceCompleted(sid, pkS, pkT, payload) @ j" + +// SAS gate: source never sends the payload without a prior SAS match. +lemma payload_requires_successful_sas_match: + "All sid pkS pkT payload #i. + SourceSentPayload(sid, pkS, pkT, payload) @ i + ==> (Ex sas #j. + SasMatched(sid, pkS, pkT, sas) @ j + & #j < #i)" + +// Payload secrecy: without endpoint compromise, the payload is secret. +// Note: this holds even under QR-code leak. The SAS gate prevents a MITM +// from causing the source to send the payload under the attacker's key, +// because the SAS rule only fires when the source's accepted pkT matches +// a genuine fresh target ephemeral (see sas_match_implies_genuine_target). +lemma payload_secrecy_without_endpoint_compromise: + "All sid payload #i. + PayloadMarkedSecret(sid, payload) @ i + & not (Ex pkS #r. SourceCompromised(sid, pkS) @ r) + & not (Ex pkS pkT #r. TargetCompromised(sid, pkS, pkT) @ r) + ==> not (Ex #j. K(payload) @ j)" + +// Target-side agreement: if the target completes, the source genuinely +// sent that exact payload under this session. +lemma target_completion_agrees_on_source_payload: + "All sid pkS pkT payload #i. + TargetCompleted(sid, pkS, pkT, payload) @ i + & not (Ex pkS2 #r. SourceCompromised(sid, pkS2) @ r) + & not (Ex pkS2 pkT2 #r. TargetCompromised(sid, pkS2, pkT2) @ r) + ==> (Ex #j. + SourceSentPayload(sid, pkS, pkT, payload) @ j + & #j < #i)" + +// Source-side completion soundness: if the source sees `complete`, the +// target really completed this session. +lemma source_completion_implies_prior_target_completion_without_compromise: + "All sid pkS pkT payload #i. + SourceCompleted(sid, pkS, pkT, payload) @ i + & not (Ex pkS2 #r. SourceCompromised(sid, pkS2) @ r) + & not (Ex pkS2 pkT2 #r. TargetCompromised(sid, pkS2, pkT2) @ r) + ==> (Ex #j. + TargetCompleted(sid, pkS, pkT, payload) @ j + & #j < #i)" + +// Injective agreement (target → source): each target completion corresponds +// to a unique source payload send, and that send is itself unique. This is +// one-directional; the reverse (every send leads to a completion) is a +// liveness property not provable under Dolev-Yao scheduling. +lemma injective_target_source_agreement: + "All sid pkS pkT payload #i. + TargetCompleted(sid, pkS, pkT, payload) @ i + & not (Ex pkS2 #r. SourceCompromised(sid, pkS2) @ r) + & not (Ex pkS2 pkT2 #r. TargetCompromised(sid, pkS2, pkT2) @ r) + ==> (Ex #j. + SourceSentPayload(sid, pkS, pkT, payload) @ j + & #j < #i + & not (Ex #i2. + TargetCompleted(sid, pkS, pkT, payload) @ i2 + & not (#i2 = #i)) + & not (Ex #j2. + SourceSentPayload(sid, pkS, pkT, payload) @ j2 + & not (#j2 = #j)))" + +// ============================================================================ +// MITM-resistance test cases +// ============================================================================ + +// "MITM does not work": any SAS match pins the source's view of pkT to an +// actual target-generated ephemeral ('g'^~xt from Target_Scan_QR_And_Send_Offer). +// A network adversary who substitutes the offer's pkT with an attacker-chosen +// value can never make this lemma's conclusion hold, because the fresh ~xt +// in TargetStarted is outside attacker knowledge. +lemma sas_match_implies_genuine_target: + "All sid pkS pkT sas #i. + SasMatched(sid, pkS, pkT, sas) @ i + ==> (Ex #j. + TargetStarted(sid, pkS, pkT) @ j + & #j < #i)" + +// Composition: no payload is ever sent under a pkT the real target did not +// produce. Follows from payload_requires_successful_sas_match combined with +// sas_match_implies_genuine_target, and is the explicit no-MITM guarantee. +lemma payload_delivery_requires_genuine_target: + "All sid pkS pkT payload #i. + SourceSentPayload(sid, pkS, pkT, payload) @ i + ==> (Ex #j. + TargetStarted(sid, pkS, pkT) @ j + & #j < #i)" + +// Dual-consent gate (spec §Step 3, PR #346): the target never processes +// (decrypts/imports) a payload without BOTH transcript verification AND +// an explicit user-approval step. The payload may arrive and be buffered +// earlier (see Target_Buffers_Payload), but processing is gated. +lemma target_decrypts_payload_only_after_dual_consent: + "All sid pkS pkT payload #i. + TargetDecryptedPayload(sid, pkS, pkT, payload) @ i + ==> (Ex #j #k. + TargetVerifiedTranscript(sid, pkS, pkT) @ j + & TargetUserApproved(sid, pkS, pkT) @ k + & #j < #i + & #k < #i)" + +// ============================================================================ +// Reachability / sanity test cases +// ============================================================================ +// +// These exists-trace lemmas prove that the compromise model is meaningful +// (each compromise rule is actually reachable within a valid protocol run) +// and that compromise genuinely breaks payload confidentiality. Without +// these, a trivially unreachable compromise rule would make the no-compromise +// secrecy claims vacuous. + +lemma executable_with_qr_leak: + exists-trace + "Ex sid pkS #r. QrLeaked(sid, pkS) @ r" + +lemma executable_with_source_compromise: + exists-trace + "Ex sid pkS #r. SourceCompromised(sid, pkS) @ r" + +lemma executable_with_target_compromise: + exists-trace + "Ex sid pkS pkT #r. TargetCompromised(sid, pkS, pkT) @ r" + +// Buffer-then-decrypt sequencing: every decryption is preceded by buffering. +// Makes the intended two-phase flow explicit in the proof surface. +lemma decryption_requires_prior_buffering: + "All sid pkS pkT payload #i. + TargetDecryptedPayload(sid, pkS, pkT, payload) @ i + ==> (Ex #j. + TargetBufferedPayload(sid, pkS, pkT) @ j + & #j < #i)" + +// Sanity: the payload CAN arrive (be buffered) before the target user +// approves. This proves the buffering path is reachable and the dual-consent +// gate is not vacuously enforced by message ordering alone. +lemma executable_payload_buffered_before_approval: + exists-trace + "Ex sid pkS pkT #i #j. + TargetBufferedPayload(sid, pkS, pkT) @ i + & TargetUserApproved(sid, pkS, pkT) @ j + & #i < #j" + +// Source-side compromise: an attacker who learns xs can decrypt the payload. +// Counter-example to a naive "secrecy always holds" claim; justifies the +// `not SourceCompromised` guard in payload_secrecy_without_endpoint_compromise. +lemma source_compromise_can_leak_payload: + exists-trace + "Ex sid pkS payload #i #j #k. + PayloadMarkedSecret(sid, payload) @ i + & SourceCompromised(sid, pkS) @ j + & K(payload) @ k" + +// Target-side compromise: same story, from the target side. +lemma target_compromise_can_leak_payload: + exists-trace + "Ex sid pkS pkT payload #i #j #k. + PayloadMarkedSecret(sid, payload) @ i + & TargetCompromised(sid, pkS, pkT) @ j + & K(payload) @ k" + +end diff --git a/crates/pairing/src/crypto.rs b/crates/pairing/src/crypto.rs new file mode 100644 index 000000000..ea97f66a5 --- /dev/null +++ b/crates/pairing/src/crypto.rs @@ -0,0 +1,413 @@ +//! 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; + +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"; + +/// 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 +} + +/// 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 buzz_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() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 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) + } + + #[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" + ); + } + + #[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()) + .unwrap(); + // 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()) + .unwrap(); + + 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); + } + + #[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()) + .unwrap(); + 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()) + .unwrap(); + 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()) + .unwrap(); + 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" + ); + } + + #[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}"); + } + } + + #[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()) + .unwrap(); + let ecdh_tgt = + nostr::util::generate_shared_key(tgt_keys.secret_key(), &src_keys.public_key()) + .unwrap(); + 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/pairing/src/lib.rs b/crates/pairing/src/lib.rs new file mode 100644 index 000000000..4b5e03eff --- /dev/null +++ b/crates/pairing/src/lib.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/pairing/src/qr.rs b/crates/pairing/src/qr.rs new file mode 100644 index 000000000..1619dff9e --- /dev/null +++ b/crates/pairing/src/qr.rs @@ -0,0 +1,588 @@ +//! 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 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(); + } +} + +/// 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 buzz_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 +} + +/// 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, + }) +} + +/// 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) +} + +#[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"); + } + + #[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:?}" + ); + } + + #[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/pairing/src/session.rs b/crates/pairing/src/session.rs new file mode 100644 index 000000000..8b777358f --- /dev/null +++ b/crates/pairing/src/session.rs @@ -0,0 +1,1502 @@ +//! 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 +//! ``` + +//! # Code-entry confirmation extension +//! +//! A target advertises `"confirmation":"desktop-code-v1"` in its encrypted +//! offer. The source generates a separate random six-digit code, never included +//! in the QR or challenge. The target submits user input through NIP-44. Only a +//! matching code releases the source proof and payload; five guesses abort the +//! entire session. Legacy offers still require explicit source-side approval. + +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 QR display for sources. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120); + +/// NIP-AB event kind (from the kind registry). +pub const KIND_PAIRING: u16 = 24134; +const PAIRING_KIND: u16 = KIND_PAIRING; + +/// 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, + desktop_code_requested: bool, + desktop_code: Option>, + code_attempts: u8, +} + +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, + desktop_code_requested: false, + desktop_code: None, + code_attempts: 0, + }; + + (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.handle_offer_with_confirmation(event) + .map(|(code, _)| code) + } + + /// Validate an offer and return its SAS and whether the target requests + /// code-entry confirmation. The capability is encrypted so strict relays + /// still receive only the required recipient tag. + pub fn handle_offer_with_confirmation( + &mut self, + event: &Event, + ) -> Result<(String, bool), PairingError> { + 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, code_entry) = match &msg { + PairingMessage::Offer { + session_id, + version, + confirmation, + } => ( + session_id.clone(), + *version, + confirmation.as_deref() == Some("desktop-code-v1"), + ), + 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) + .map_err(|e| PairingError::SigningError(e.to_string()))?; + 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); + + self.desktop_code_requested = code_entry; + Ok((format_sas(code), code_entry)) + } + + /// (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) Process a payload sent back by the target. + /// + /// This is used by recovery flows where the QR-displaying device requests + /// a secret from an already-authorized scanning device. + pub fn handle_return_payload( + &mut self, + event: &Event, + ) -> Result<(PayloadType, Zeroizing), PairingError> { + self.check_expired()?; + self.expect_state(SessionState::Transferring)?; + self.expect_role(Role::Source)?; + 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)), + } + } + + /// (Source) Report whether a returned payload was imported successfully. + pub fn send_source_complete(&mut self, success: bool) -> Result { + self.check_expired()?; + self.expect_state(SessionState::PayloadExchanged)?; + self.expect_role(Role::Source)?; + + let event = self.build_event(&PairingMessage::Complete { success })?; + self.state = if success { + SessionState::Completed + } else { + SessionState::Aborted + }; + 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)), + } + } +} + +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) + .map_err(|e| PairingError::SigningError(e.to_string()))?; + 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, + desktop_code_requested: false, + desktop_code: None, + code_attempts: 0, + }; + + // Build and return the offer event. + let msg = PairingMessage::Offer { + session_id: hex::encode(session_id), + version: 1, + confirmation: None, + }; + 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) + } +} + +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)), + } + } + + /// Start the source lifetime only when its QR is ready to be shown. The + /// transport may spend time connecting and subscribing before this point. + /// Buffered offers have not been processed yet, so the session is still waiting. + pub fn start_source_lifetime(&mut self) { + assert_eq!(self.role, Role::Source); + assert_eq!(self.state, SessionState::Waiting); + self.created_at = Instant::now(); + } + + /// Absolute protocol deadline. Transports must use this same deadline for + /// UI expiry so connection setup never adds time to an expired QR. + pub fn deadline(&self) -> Instant { + self.created_at + self.timeout + } + + /// Check if the session has expired. + pub fn is_expired(&self) -> bool { + Instant::now() >= self.deadline() + } + + /// 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(), + })) + } +} + +#[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; + } +} + +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_secs(); + 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) + .tags([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::DesktopCode {} => "desktop-code", + PairingMessage::CodeSubmit { .. } => "code-submit", + PairingMessage::CodeRejected { .. } => "code-rejected", + PairingMessage::SasConfirm { .. } => "sas-confirm", + PairingMessage::Payload { .. } => "payload", + PairingMessage::Complete { .. } => "complete", + PairingMessage::Abort { .. } => "abort", + }; + PairingError::UnexpectedMessage { + expected: expected.into(), + got: got_name.into(), + } +} + +#[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); + } + + /// Reverse happy-path: the scanning target returns an nsec and the source + /// reports the import result. Duplicate payloads remain single-use. + #[test] + fn reverse_payload_flow_is_single_use() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).expect("target"); + let source_sas = source.handle_offer(&offer).expect("offer"); + let sas_confirm = source.confirm_sas().expect("source confirm"); + assert_eq!( + target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"), + source_sas + ); + target.confirm_target_sas().expect("target confirm"); + + let payload = target + .build_event(&PairingMessage::Payload { + payload_type: PayloadType::Nsec, + payload: "nsec1recovered".into(), + }) + .expect("return payload"); + let (payload_type, secret) = source + .handle_return_payload(&payload) + .expect("handle return payload"); + assert_eq!(payload_type, PayloadType::Nsec); + assert_eq!(*secret, "nsec1recovered"); + assert_eq!(source.state(), SessionState::PayloadExchanged); + assert!(source.handle_return_payload(&payload).is_err()); + + let complete = source.send_source_complete(true).expect("source complete"); + assert_eq!(source.state(), SessionState::Completed); + assert!(matches!( + target.decrypt_message(&complete).expect("decrypt complete"), + PairingMessage::Complete { success: true } + )); + } + + #[test] + fn reverse_payload_import_failure_aborts_both_peers() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut target, offer) = PairingSession::new_target(&qr).expect("target"); + source.handle_offer(&offer).expect("offer"); + let sas_confirm = source.confirm_sas().expect("source confirm"); + target + .handle_sas_confirm(&sas_confirm) + .expect("sas-confirm"); + target.confirm_target_sas().expect("target confirm"); + let payload = target + .build_event(&PairingMessage::Payload { + payload_type: PayloadType::Nsec, + payload: "invalid".into(), + }) + .expect("return payload"); + source + .handle_return_payload(&payload) + .expect("handle return payload"); + + let complete = source + .send_source_complete(false) + .expect("failure complete"); + assert_eq!(source.state(), SessionState::Aborted); + assert!(matches!( + target.decrypt_message(&complete).expect("decrypt complete"), + PairingMessage::Complete { success: false } + )); + } + + /// 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(KIND_PAIRING), &encrypted) + .tags([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(KIND_PAIRING), &encrypted) + .tags([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) + .tags([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) + .tags([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) + .tags([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" + ); + } +} + +#[cfg(test)] +#[path = "session_code_entry_tests.rs"] +mod code_entry_tests; + +#[path = "session_desktop_code.rs"] +mod desktop_code; + +#[cfg(test)] +mod display_lifetime_tests { + use super::*; + + #[test] + fn source_expiry_begins_at_display_even_after_setup_delay() { + let (mut source, _) = PairingSession::new_source("wss://relay.test".into()); + source.created_at -= Duration::from_secs(35); + assert!(source.deadline() <= Instant::now() + Duration::from_secs(85)); + source.start_source_lifetime(); + let remaining = source.deadline().duration_since(Instant::now()); + assert!(remaining <= DEFAULT_TIMEOUT); + assert!(remaining > DEFAULT_TIMEOUT - Duration::from_secs(1)); + assert!(!source.is_expired()); + } +} diff --git a/crates/pairing/src/session_code_entry_tests.rs b/crates/pairing/src/session_code_entry_tests.rs new file mode 100644 index 000000000..17f187ca3 --- /dev/null +++ b/crates/pairing/src/session_code_entry_tests.rs @@ -0,0 +1,237 @@ +use super::*; + +fn setup() -> (PairingSession, PairingSession, String) { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (target, _) = PairingSession::new_target(&qr).expect("target"); + let offer = target + .build_event(&PairingMessage::Offer { + session_id: hex::encode(target.session_id), + version: 1, + confirmation: Some("desktop-code-v1".into()), + }) + .expect("offer"); + assert!( + source + .handle_offer_with_confirmation(&offer) + .expect("offer") + .1 + ); + let (code, challenge) = source.start_desktop_code().expect("challenge"); + assert_eq!( + target.decrypt_message(&challenge).expect("decrypt"), + PairingMessage::DesktopCode {} + ); + assert_eq!(code.len(), 6); + (source, target, code) +} + +fn submission(target: &PairingSession, code: &str, attempt: u8) -> Event { + target + .build_event(&PairingMessage::CodeSubmit { + code: code.into(), + request_id: attempt.to_string(), + }) + .expect("submission") +} + +#[test] +fn only_source_only_code_releases_identity() { + let (mut source, mut target, code) = setup(); + assert!(source + .send_payload(PayloadType::Custom, Zeroizing::new("secret".into())) + .is_err()); + let event = submission(&target, &code, 1); + let (proof, accepted) = source.handle_target_code(&event).expect("verify"); + assert!(accepted); + assert!(source.handle_target_code(&event).is_err()); + target.handle_sas_confirm(&proof).expect("source proof"); + target.confirm_target_sas().expect("user approves import"); + let payload = source + .send_payload(PayloadType::Custom, Zeroizing::new("secret".into())) + .expect("payload"); + assert_eq!( + &*target.handle_payload(&payload).expect("import").1, + "secret" + ); +} + +#[test] +fn qr_derived_transcript_cannot_authorize_release() { + let (mut source, target, _) = setup(); + let hash = derive_transcript_hash( + &target.session_id, + &source.pubkey().to_bytes(), + &target.pubkey().to_bytes(), + &target.sas_input.expect("sas"), + &target.session_secret, + ); + let event = target + .build_event(&PairingMessage::SasConfirm { + transcript_hash: hex::encode(hash), + }) + .expect("proof"); + assert!(source.handle_target_code(&event).is_err()); + assert_eq!(source.state(), SessionState::Confirming); + assert!(source + .send_payload(PayloadType::Custom, Zeroizing::new("secret".into())) + .is_err()); +} + +#[test] +fn five_wrong_guesses_abort_without_reset_or_replay_bypass() { + let (mut source, target, code) = setup(); + let wrong = if code == "000000" { "000001" } else { "000000" }; + for attempt in 1..=5 { + let event = submission(&target, wrong, attempt); + let (reply, accepted) = source.handle_target_code(&event).expect("reject"); + assert!(!accepted); + assert_eq!( + target.decrypt_message(&reply).expect("reply"), + PairingMessage::CodeRejected { + request_id: attempt.to_string(), + remaining_attempts: 5 - attempt + } + ); + assert!( + source.handle_target_code(&event).is_err(), + "duplicate submission" + ); + assert!( + source.start_desktop_code().is_err(), + "cannot regenerate code/budget" + ); + assert!(source + .send_payload(PayloadType::Custom, Zeroizing::new("secret".into())) + .is_err()); + } + assert_eq!(source.state(), SessionState::Aborted); + assert!(source + .handle_target_code(&submission(&target, &code, 6)) + .is_err()); +} + +#[test] +fn wrong_then_correct_code_succeeds() { + let (mut source, target, code) = setup(); + let wrong = if code == "000000" { "000001" } else { "000000" }; + assert!( + !source + .handle_target_code(&submission(&target, wrong, 1)) + .expect("reject") + .1 + ); + assert!( + source + .handle_target_code(&submission(&target, &code, 2)) + .expect("accept") + .1 + ); +} + +#[test] +fn wrong_peer_tampering_and_expiry_cannot_authorize() { + let (mut source, target, code) = setup(); + let (other, _) = PairingSession::new_source("wss://relay.test".into()); + let mut bad = submission(&target, &code, 1); + bad.pubkey = other.pubkey(); + assert!(source.handle_target_code(&bad).is_err()); + let mut tampered = submission(&target, &code, 2); + tampered.content.push('x'); + assert!(source.handle_target_code(&tampered).is_err()); + assert_eq!(source.code_attempts, 0); + source.created_at = Instant::now() - source.timeout - Duration::from_secs(1); + assert!(source + .handle_target_code(&submission(&target, &code, 3)) + .is_err()); +} + +#[test] +fn legacy_capabilities_never_enable_automatic_release() { + for capability in [ + None, + Some("code-entry"), + Some("unknown"), + Some("desktop-code-v1"), + ] { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + let (target, _) = PairingSession::new_target(&qr).expect("target"); + let event = target + .build_event(&PairingMessage::Offer { + session_id: hex::encode(target.session_id), + version: 1, + confirmation: capability.map(str::to_string), + }) + .expect("offer"); + assert_eq!(event.tags.len(), 1); + let (_, enabled) = source + .handle_offer_with_confirmation(&event) + .expect("offer"); + assert_eq!(enabled, capability == Some("desktop-code-v1")); + assert_eq!(source.start_desktop_code().is_ok(), enabled); + } +} + +#[test] +fn readiness_delay_and_late_scan_share_the_original_deadline() { + let (mut source, qr) = PairingSession::new_source("wss://relay.test".into()); + // Simulate the maximum 35-second readiness wait without sleeping. + source.created_at = Instant::now() - Duration::from_secs(35); + let deadline = source.deadline(); + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!(remaining <= Duration::from_secs(85)); + assert!(remaining > Duration::from_secs(84)); + // A scan 84 seconds after readiness still has one second to be accepted. + source.created_at -= Duration::from_secs(84); + let (_, offer) = PairingSession::new_target(&qr).unwrap(); + assert!(source.handle_offer(&offer).is_ok()); + assert!(!source.is_expired()); + // The transport deadline and protocol expiry both end the original window. + source.created_at -= Duration::from_secs(2); + assert!(source.deadline() < Instant::now()); + assert!(source.is_expired()); + assert!(matches!( + source.confirm_sas(), + Err(PairingError::SessionExpired) + )); +} + +#[test] +fn oversized_rejection_cannot_bypass_the_guess_budget() { + let (mut source, target, code) = setup(); + let wrong = if code == "000000" { "000001" } else { "000000" }; + for attempt in 1..5 { + assert!( + !source + .handle_target_code(&submission(&target, wrong, attempt)) + .unwrap() + .1 + ); + } + let empty = PairingMessage::CodeSubmit { + code: wrong.into(), + request_id: String::new(), + }; + let overhead = serde_json::to_string(&empty).unwrap().len(); + // Find the dependency's maximum encodable plaintext (some versions reserve + // padding space below the protocol's 65,535-byte limit). + let event = (65_000..=65_535) + .rev() + .find_map(|size| { + target + .build_event(&PairingMessage::CodeSubmit { + code: wrong.into(), + request_id: "x".repeat(size - overhead), + }) + .ok() + }) + .expect("maximum-size submission"); + assert!(source.handle_target_code(&event).is_err()); + assert_eq!(source.state(), SessionState::Aborted); + assert!(source.desktop_code.is_none()); + assert!(source + .handle_target_code(&submission(&target, &code, 6)) + .is_err()); + assert!(source + .send_payload(PayloadType::Custom, Zeroizing::new("secret".into())) + .is_err()); +} diff --git a/crates/pairing/src/session_desktop_code.rs b/crates/pairing/src/session_desktop_code.rs new file mode 100644 index 000000000..244141ef7 --- /dev/null +++ b/crates/pairing/src/session_desktop_code.rs @@ -0,0 +1,58 @@ +use super::*; +use subtle::ConstantTimeEq; + +impl PairingSession { + /// Start source-only code verification once, after capability negotiation. + /// Returns the code for local display and an encrypted challenge containing + /// no code or verifier. Repeated calls cannot reset the guess budget. + pub fn start_desktop_code(&mut self) -> Result<(String, Event), PairingError> { + self.check_expired()?; + self.expect_role(Role::Source)?; + self.expect_state(SessionState::Confirming)?; + if !self.desktop_code_requested || self.desktop_code.is_some() { + return Err(PairingError::SasMismatch); + } + let code = format!("{:06}", rand::random_range(0..1_000_000u32)); + let challenge = self.build_event(&PairingMessage::DesktopCode {})?; + self.desktop_code = Some(Zeroizing::new(code.clone())); + Ok((code, challenge)) + } + + /// Verify a signed submission from the locked peer. Only a separate random + /// desktop code authorizes release; the QR-derived SAS/transcript cannot. + /// The response is a source proof on success or a rejection with a remaining + /// guess budget. Five wrong guesses permanently abort this QR session. + pub fn handle_target_code(&mut self, event: &Event) -> Result<(Event, bool), PairingError> { + self.check_expired()?; + self.expect_role(Role::Source)?; + self.expect_state(SessionState::Confirming)?; + self.validate_event_from_peer(event)?; + let expected = self + .desktop_code + .as_ref() + .ok_or(PairingError::SasMismatch)?; + let (mut code, request_id) = match self.decrypt_message(event)? { + PairingMessage::CodeSubmit { code, request_id } => (code, request_id), + other => return Err(unexpected("code-submit", &other)), + }; + let correct: bool = code.as_bytes().ct_eq(expected.as_bytes()).into(); + code.zeroize(); + self.code_attempts += 1; + self.record_event(event); + if correct { + self.desktop_code = None; + return self.confirm_sas().map(|proof| (proof, true)); + } + let remaining_attempts = 5u8.saturating_sub(self.code_attempts); + // Exhaustion is terminal even if serializing the rejection fails. + if remaining_attempts == 0 { + self.desktop_code = None; + self.state = SessionState::Aborted; + } + let rejection = self.build_event(&PairingMessage::CodeRejected { + request_id, + remaining_attempts, + })?; + Ok((rejection, false)) + } +} diff --git a/crates/pairing/src/types.rs b/crates/pairing/src/types.rs new file mode 100644 index 000000000..2d09f0383 --- /dev/null +++ b/crates/pairing/src/types.rs @@ -0,0 +1,264 @@ +//! 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 [`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, + /// Optional confirmation UX negotiated inside the encrypted offer. + #[serde(default, skip_serializing_if = "Option::is_none")] + confirmation: Option, + }, + + /// Source advertises a separate, source-only random code. Contains no code. + DesktopCode {}, + /// Target submits the user-entered code over the authenticated encrypted channel. + CodeSubmit { + /// Six ASCII digits displayed only on the source. + code: String, + /// Correlates rejection with the submitted attempt. + request_id: String, + }, + /// Source rejects an attempt without releasing any identity material. + CodeRejected { + /// Request being rejected. + request_id: String, + /// Remaining guesses before this QR session is permanently aborted. + remaining_attempts: u8, + }, + + /// 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, + confirmation: None, + }; + 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, + confirmation: None, + } + ); + } + + #[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/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index d12ee3079..48dc1e2e9 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -129,6 +129,8 @@ pub fn bundled_manifests() -> Vec { }); vec![ builderlab, + serde_json::from_str(include_str!("../../../src/bundled/pairing/manifest.json")) + .expect("valid pairing manifest"), serde_json::from_str(include_str!("../../../src/bundled/todos/manifest.json")) .expect("todos manifest"), serde_json::from_str(include_str!("../../../src/bundled/diffs/manifest.json")) @@ -504,6 +506,7 @@ impl Manager { | "buzz.mentions" | "buzz.emoji" | "buzz.github" + | "buzz.pairing" | "buzz.inbox" | "buzz.projects" | "buzz.agents" diff --git a/docs/images/pairing/code.png b/docs/images/pairing/code.png new file mode 100644 index 000000000..580cfa830 Binary files /dev/null and b/docs/images/pairing/code.png differ diff --git a/docs/images/pairing/qr.png b/docs/images/pairing/qr.png new file mode 100644 index 000000000..c68f12500 Binary files /dev/null and b/docs/images/pairing/qr.png differ diff --git a/docs/pairing.md b/docs/pairing.md new file mode 100644 index 000000000..0089f19b9 --- /dev/null +++ b/docs/pairing.md @@ -0,0 +1,87 @@ +# Pair mobile + +`buzz.pairing` is an enabled-by-default bundled Settings plugin. Settings → +Plugins can disable it independently. The plugin contributes `mobile` through +`ctx.settingsCards.register` in the Account group, using the existing Settings +shell, navigation and contribution lifecycle. It reads the existing +`communityReader` capability without acquiring another relay session. + +The plugin owns its React UI and the short-lived IPC client. The native host +uses the app's existing IdentityHost for account access, NIP-11 pairing-route discovery, +ephemeral NIP-42 authentication, encrypted NIP-AB exchange, QR generation, and +session cancellation. No account private key crosses IPC. Plugins are trusted +in-process code, as elsewhere in this app; this is an ownership boundary, not a +new plugin security sandbox. The frontend CSP is unchanged. + +## Behavior + +1. With an active account, the selected community prefills the address. Without + a live app account, **Use existing Buzz account** reads only the public identity + from the current app identity. The user supplies a community address. +2. Opening Pair mobile automatically verifies the public identity against the + saved native account, opens a dedicated pairing socket, and waits for subscription + readiness before showing the QR. QRs automatically renew while the section + remains open, before the supported sidecar’s 120-second connection cap. Desktop budgets 115 seconds from before + connecting, including setup, so the visible lifetime is shorter than two minutes. + The protocol also expires no later than two minutes after display. Connection + failures show **Try again** instead of retrying indefinitely. +3. A compatible phone advertises encrypted `desktop-code-v1` support. Desktop + generates a separate random six-digit code that is never sent in the QR or + challenge. The phone submits the entered code; desktop verifies it with a + five-attempt budget before sending the encrypted account payload. + In the code-entry flow, the code protects against a captured QR. A QR holder + can instead request legacy comparison, which still requires explicit desktop + approval. Code entry does not protect against a live observer: someone who can + watch both the QR and the code can race the phone and enter the code. Do not + pair while sharing, recording, or otherwise exposing the screen to an untrusted + observer. `desktop-code-v1` extends NIP-AB; the NIP-AB/Tamarin + source-confirmation model in `crates/pairing/src/NIP-AB.md` does not establish + this extension's observation resistance. +4. Older phones retain explicit **Codes match** confirmation and a **Cancel** + action; Cancel sends `user_denied` before disposing the session. Code-entry + phones also have Cancel, but no desktop + confirmation action. +5. **Phone paired** means the phone acknowledged the transfer. Current phone + versions can acknowledge before saving the account, so desktop asks the user to + check that the account is signed in on the phone. The mobile fix is separate + `block/buzz` work. + If a sent transfer times out or loses its connection awaiting acknowledgement, **Check your phone** + preserves the uncertain outcome and requires a deliberate new attempt. + Closing or reloading the window, leaving the Settings section, switching accounts/communities, disabling the + plugin disposes the attempt, with a bounded best-effort `user_denied` notice + only before transfer publication. After publication, closing the socket cannot + retract the account or interrupt the phone's import, so cancellation shows + **Check your phone** (or a result the phone already reported), not + **Pairing was canceled**. Native cancellation remains terminal until an explicit retry + or reopening the pairing section. Retries use a fresh native session. + +Pairing uses the current native app identity through a purpose-bound +`IdentityHost.with_key` operation. The public viewer is checked before producing +the encrypted transfer payload. It never reads or mutates the original Buzz +credential store. Browsers explain that pairing requires desktop. Live development +with `BUZZ_DEV_VIEWER` uses the separate broker identity, so pairing is unavailable +there; start `BUZZ_DEV_VIEWER= bin/just desktop` (also overrides a viewer +pin in `.env.local`) and use the native identity setup for the same account to +test pairing. The mobile implementation remains in `block/buzz`. + +Only secure community origins and secure advertised pairing URLs are accepted. +Redirects are disabled for discovery, setup traffic is bounded, and both early +offers and late authentication challenges are handled. Cancellation prevents +subsequent work; it cannot retract a payload already delivered to the phone. + +## Verification + +- `crates/pairing`: imported protocol tests and vectors from PR #8085, pinned to + `ac0ad7c3004683e5db813492846d787404a2b475`; see its README for provenance. +- Native tests: account mismatch, code-entry and legacy transfers, phone import + failure, stale-session cancellation, secure destination validation, and actual + local WebSocket subscription/authentication exchanges with temporary keys. +- Frontend tests: late start/status cancellation, replacement ordering, cleanup + failure, and legacy-only desktop confirmation. App composition verifies real + plugin enable/disable and registration lifetime. +- Browser journeys: real Settings navigation and plugin UI with fixture IPC in + Chromium and WebKit. These do not prove a live phone handoff or OS Keychain UI. + +Run focused pairing, Settings lifecycle and native identity checks, including +`bin/cargo test -p buzz-pairing` and native pairing tests. +Hosted CI runs `cargo test --workspace`, which includes the pairing crate. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bfbecabdd..bc89b2f0a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,15 @@ url = "2" tauri-build = { version = "2", features = [] } [dependencies] +buzz-pairing = { path = "../crates/pairing" } +nostr-pairing = { package = "nostr", version = "0.44", features = ["nip44"] } +tokio-util = "0.7" +tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } +futures-util = "0.3" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +webpki-roots = "1" +qrcode = { version = "0.14", default-features = false, features = ["svg"] } + tauri = { version = "2", features = ["unstable"] } wry = { version = "=0.55.1", default-features = false, features = ["os-webview"] } buzzodz-plugins = { path = "../crates/plugin-manager" } @@ -28,7 +37,6 @@ hmac = "0.12" base64 = "0.22" reqwest = { version = "0.13", default-features = false, features = ["rustls", "stream"] } bytes = "1" -futures-util = { version = "0.3", default-features = false } url = "2" percent-encoding = "2" tauri-plugin-dialog = "2" @@ -47,6 +55,7 @@ tempfile = "3" rusqlite = { version = "0.38", features = ["bundled"] } [dev-dependencies] +hex = "0.4" tokio = { version = "1", features = ["test-util"] } secp256k1 = { version = "0.31", features = ["std"] } tauri = { version = "2", features = ["test"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 550532983..f18ce7be0 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -33,6 +33,12 @@ fn main() { } tauri_build::try_build( attributes.app_manifest(tauri_build::AppManifest::new().commands(&[ + "pairing_account", + "pairing_start", + "pairing_status", + "pairing_confirm", + "pairing_deny", + "pairing_cancel", "identity_restore", "identity_import", "identity_create", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0f0c184ec..a60216dc3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,6 +3,12 @@ "identifier": "main-window", "description": "Allow the main application commands, title bar controls and external HTTP(S) links.", "permissions": [ + "allow-pairing-account", + "allow-pairing-start", + "allow-pairing-status", + "allow-pairing-confirm", + "allow-pairing-deny", + "allow-pairing-cancel", "allow-identity-restore", "allow-identity-import", "allow-identity-create", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ec0b5fdf..f91800586 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ use oauth_callback::{ }; #[cfg(test)] mod browser_permissions_tests; +mod pairing; use browser::{ browser_action, browser_attach, browser_detach, browser_navigate, browser_set_bounds, browser_status, @@ -398,6 +399,12 @@ async fn update_restart(app: tauri::AppHandle) -> Result<( } fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { tauri::generate_handler![ + pairing::pairing_account, + pairing::pairing_start, + pairing::pairing_status, + pairing::pairing_confirm, + pairing::pairing_deny, + pairing::pairing_cancel, identity_restore, identity_import, identity_create, @@ -562,6 +569,7 @@ pub fn run() { builder .manage(IdentityHost::default()) .manage(archive::ArchiveHost::default()) + .manage(pairing::Pairing::default()) .manage(relay::Uploads::default()) .register_asynchronous_uri_scheme_protocol("buzz-media", relay::media_protocol) .manage(Imports::default()) @@ -597,9 +605,13 @@ pub fn run() { { eprintln!("OAuth callback cleanup failed: {error}"); } + if webview.label() == "main" && matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + webview.state::().cancel_all(); + } browser::page_load(webview, payload); }) .on_window_event(|window, event| { + if window.label() == "main" && matches!(event, tauri::WindowEvent::Destroyed | tauri::WindowEvent::CloseRequested { .. }) { window.state::().cancel_all(); } #[cfg(target_os = "macos")] if window.label() == "main" { if let tauri::WindowEvent::CloseRequested { api, .. } = event { diff --git a/src-tauri/src/pairing/identity.rs b/src-tauri/src/pairing/identity.rs new file mode 100644 index 000000000..b20878da1 --- /dev/null +++ b/src-tauri/src/pairing/identity.rs @@ -0,0 +1,97 @@ +use nostr_pairing::{Keys, ToBech32}; +use zeroize::Zeroizing; + +pub(super) async fn prepare( + host: &crate::identity::IdentityHost, + viewer: String, + origin: String, +) -> Result, String> { + host.with_key(move |secret, current| { + if current != viewer { + return Err("The active account changed. Reopen Pair mobile.".into()); + } + let secret = nostr_pairing::SecretKey::from_slice(secret) + .map_err(|_| "Couldn’t read your Buzz account.")?; + payload(&Keys::new(secret), &viewer, &origin) + }) + .await +} + +pub(super) fn payload( + keys: &Keys, + expected: &str, + relay: &str, +) -> Result, String> { + if keys.public_key().to_hex() != expected { + return Err("The saved Buzz account has changed. Reopen Pair mobile and try again.".into()); + } + #[derive(serde::Serialize)] + #[serde(rename_all = "camelCase")] + struct Payload<'a> { + relay_url: &'a str, + pubkey: &'a str, + nsec: &'a str, + } + let secret = Zeroizing::new( + keys.secret_key() + .to_bech32() + .map_err(|_| "Couldn’t prepare your Buzz account.")?, + ); + serde_json::to_string(&Payload { + relay_url: relay, + pubkey: expected, + nsec: &secret, + }) + .map(Zeroizing::new) + .map_err(|_| "Couldn’t prepare your Buzz account.".into()) +} +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn current_native_identity_is_the_only_payload_source() { + let host = crate::identity::IdentityHost::fixture(); + let viewer = host.viewer().await.unwrap(); + assert!(prepare(&host, "f".repeat(64), "https://relay.test".into()) + .await + .is_err()); + let data = prepare(&host, viewer.clone(), "https://relay.test".into()) + .await + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); + assert_eq!(parsed["pubkey"], viewer); + assert_eq!( + Keys::parse(parsed["nsec"].as_str().unwrap()) + .unwrap() + .public_key() + .to_hex(), + viewer + ); + assert!(prepare( + &crate::identity::IdentityHost::default(), + viewer, + "https://relay.test".into() + ) + .await + .is_err()); + } + #[test] + fn account_pin_is_checked_before_export() { + let keys = Keys::generate(); + assert!(payload( + &keys, + &Keys::generate().public_key().to_hex(), + "https://relay.test" + ) + .is_err()); + let data = payload(&keys, &keys.public_key().to_hex(), "https://relay.test").unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&data).unwrap(); + assert_eq!(parsed["relayUrl"], "https://relay.test"); + assert_eq!( + Keys::parse(parsed["nsec"].as_str().unwrap()) + .unwrap() + .public_key(), + keys.public_key() + ); + } +} diff --git a/src-tauri/src/pairing/mod.rs b/src-tauri/src/pairing/mod.rs new file mode 100644 index 000000000..d4cdc8a7c --- /dev/null +++ b/src-tauri/src/pairing/mod.rs @@ -0,0 +1,557 @@ +mod identity; +mod qr; +mod relay; + +use buzz_pairing::{AbortReason, PairingSession, PayloadType, SessionState}; +use futures_util::FutureExt; +use nostr_pairing::Event; +use serde::Serialize; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroizing; + +#[derive(Clone, Serialize, Debug, PartialEq)] +#[serde(tag = "phase", rename_all = "camelCase")] +pub enum Status { + Connecting, + Qr { + svg: String, + }, + Code { + code: String, + #[serde(rename = "codeEntry")] + code_entry: bool, + }, + Transferring, + Complete, + Expired, + Uncertain, + Error { + message: String, + }, + Cancelled, +} +#[derive(Clone, Copy)] +enum Decision { + Confirm, + Deny, +} +struct Active { + id: String, + cancel: CancellationToken, + confirm: mpsc::Sender, + finished: CancellationToken, + status: Status, + payload_sent: bool, +} +#[derive(Clone, Default)] +pub struct Pairing(Arc>>); +// Cancels the attempt and returns its teardown signal and visible outcome. After +// possible publication, the attempt stays registered as its terminal outcome so +// later status reads cannot report an unsent cancellation. +fn stop(active: &mut Option) -> Option<(CancellationToken, Status)> { + let old = active.as_mut()?; + old.cancel.cancel(); + if !old.payload_sent { + return active.take().map(|old| (old.finished, Status::Cancelled)); + } + if !matches!( + old.status, + Status::Complete | Status::Uncertain | Status::Error { .. } + ) { + old.status = Status::Uncertain; + } + Some((old.finished.clone(), old.status.clone())) +} +impl Pairing { + pub fn cancel_all(&self) { + stop(&mut self.0.lock().unwrap_or_else(|error| error.into_inner())); + } + + fn update(&self, id: &str, status: Status) { + if let Ok(mut active) = self.0.lock() { + if let Some(active) = active + .as_mut() + .filter(|a| a.id == id && !a.cancel.is_cancelled()) + { + active.status = status; + } + } + } + // Serialized with cancellation: false means cancellation won and the payload + // must not be published. + fn mark_payload_sent(&self, id: &str) -> bool { + let Ok(mut active) = self.0.lock() else { + return false; + }; + match active + .as_mut() + .filter(|a| a.id == id && !a.cancel.is_cancelled()) + { + Some(active) => { + active.payload_sent = true; + true + } + None => false, + } + } + fn expire(&self, id: &str) { + self.fail(id, Status::Expired, true); + } + fn fail(&self, id: &str, status: Status, ambiguous: bool) { + if let Ok(mut active) = self.0.lock() { + if let Some(active) = active + .as_mut() + .filter(|a| a.id == id && !a.cancel.is_cancelled()) + { + active.status = if active.payload_sent && ambiguous { + Status::Uncertain + } else { + status + }; + } + } + } + + async fn cancel(&self, id: &str) -> Result { + let stopped = { + let mut active = self + .0 + .lock() + .map_err(|_| "Pairing is unavailable. Restart Buzz.")?; + if active.as_ref().is_some_and(|a| a.id == id) { + stop(&mut active) + } else { + None + } + }; + let Some((finished, status)) = stopped else { + return Ok(Status::Cancelled); + }; + tokio::time::timeout(Duration::from_secs(3), finished.cancelled()) + .await + .map_err(|_| "Couldn’t cancel pairing. Close this window before trying again.")?; + Ok(status) + } +} + +#[tauri::command] +pub async fn pairing_account( + host: tauri::State<'_, crate::identity::IdentityHost>, +) -> Result { + host.with_key(|_, viewer| Ok(viewer.to_owned())).await +} +#[tauri::command] +pub fn pairing_status(pairing: tauri::State<'_, Pairing>, id: String) -> Result { + let active = pairing + .0 + .lock() + .map_err(|_| "Pairing is unavailable. Restart Buzz.")?; + Ok(active + .as_ref() + .filter(|a| a.id == id) + .map(|a| a.status.clone()) + .unwrap_or(Status::Cancelled)) +} +#[tauri::command] +pub async fn pairing_cancel( + pairing: tauri::State<'_, Pairing>, + id: String, +) -> Result { + pairing.cancel(&id).await +} +fn decide(pairing: &Pairing, id: &str, decision: Decision) -> Result<(), String> { + let mut active = pairing + .0 + .lock() + .map_err(|_| "Pairing is unavailable. Restart Buzz.")?; + let active = active + .as_mut() + .filter(|a| { + a.id == id + && matches!( + a.status, + Status::Code { + code_entry: false, + .. + } + ) + }) + .ok_or("This comparison is no longer available.")?; + active + .confirm + .try_send(decision) + .map_err(|_| "A pairing decision is already in progress.")?; + // Keep Code visible to the native owner until the bounded denial finishes. + // The client holds its own "cancelling" display state while polling for the result. + if matches!(decision, Decision::Confirm) { + active.status = Status::Transferring; + } + Ok(()) +} +#[tauri::command] +pub fn pairing_confirm(pairing: tauri::State<'_, Pairing>, id: String) -> Result<(), String> { + decide(&pairing, &id, Decision::Confirm) +} +#[tauri::command] +pub fn pairing_deny(pairing: tauri::State<'_, Pairing>, id: String) -> Result<(), String> { + decide(&pairing, &id, Decision::Deny) +} +#[tauri::command] +pub fn pairing_start( + pairing: tauri::State<'_, Pairing>, + host: tauri::State<'_, crate::identity::IdentityHost>, + id: String, + viewer: String, + community: String, +) -> Result<(), String> { + if id.len() != 36 || !id.bytes().all(|c| c.is_ascii_hexdigit() || c == b'-') { + return Err("Unsupported pairing request.".into()); + } + if viewer.len() != 64 || !viewer.bytes().all(|c| c.is_ascii_hexdigit()) { + return Err("Choose your Buzz account before pairing.".into()); + } + let origin = relay::community(&community)?; + let (tx, rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let finished = CancellationToken::new(); + { + let mut active = pairing + .0 + .lock() + .map_err(|_| "Pairing is unavailable. Restart Buzz.")?; + if let Some(old) = active.take() { + old.cancel.cancel(); + } + *active = Some(Active { + id: id.clone(), + cancel: cancel.clone(), + finished: finished.clone(), + confirm: tx, + status: Status::Connecting, + payload_sent: false, + }); + } + let host = host.inner().clone(); + let pairing = pairing.inner().clone(); + tauri::async_runtime::spawn(async move { + let result = guard(run(&pairing, &host, &id, viewer, origin, rx, &cancel)).await; + match result { + Ok(()) => pairing.update(&id, Status::Complete), + Err(Failure::Expired) => pairing.expire(&id), + Err(failure) => { + let ambiguous = matches!(failure, Failure::Transport(_)); + let (Failure::Transport(message) | Failure::Rejected(message)) = failure else { + unreachable!() + }; + pairing.fail(&id, Status::Error { message }, ambiguous); + } + } + finished.cancel(); + }); + Ok(()) +} + +#[derive(Debug)] +enum Failure { + Expired, + Transport(String), + Rejected(String), +} +impl From for Failure { + fn from(message: String) -> Self { + Self::Transport(message) + } +} +impl From<&str> for Failure { + fn from(message: &str) -> Self { + Self::Transport(message.into()) + } +} + +async fn guard( + future: impl std::future::Future>, +) -> Result<(), Failure> { + std::panic::AssertUnwindSafe(future) + .catch_unwind() + .await + .unwrap_or_else(|_| { + Err("Pairing stopped unexpectedly. Create a new code and try again.".into()) + }) +} + +async fn run( + pairing: &Pairing, + host: &crate::identity::IdentityHost, + id: &str, + viewer: String, + origin: url::Url, + mut confirm: mpsc::Receiver, + cancel: &CancellationToken, +) -> Result<(), Failure> { + let origin_string = origin.as_str().trim_end_matches('/').to_string(); + let payload = tokio::select! { + biased; + _ = cancel.cancelled() => return Ok(()), + result = identity::prepare(host, viewer, origin_string) => result?, + }; + let setup = async { + let relay_url = relay::discover(&origin).await?; + let (session, qr) = PairingSession::new_source(relay_url.to_string()); + let connection_started = tokio::time::Instant::now(); + let mut socket = relay::connect(&relay_url, Duration::from_secs(10)).await?; + let (pending, auth) = relay::subscribe(&mut socket, &session, &relay_url).await?; + let uri = Zeroizing::new(buzz_pairing::qr::encode_qr(&qr)); + let svg = qr::render(&uri)?; + Ok::<_, Failure>(( + Exchange { + session, + payload: Some(payload), + code_entry: false, + }, + socket, + relay_url, + pending, + auth, + svg, + connection_started, + )) + }; + let (mut exchange, mut socket, relay_url, pending, mut auth, svg, connection_started) = tokio::select! { + biased; + _ = cancel.cancelled() => return Ok(()), + result = tokio::time::timeout(Duration::from_secs(35), setup) => + result.map_err(|_| Failure::Transport("Pairing setup timed out. Check your connection and try again.".into()))??, + }; + // Keep protocol expiry aligned with display, but renew before the transport cap. + exchange.session.start_source_lifetime(); + let deadline = exchange_deadline(&exchange.session, connection_started); + pairing.update(id, Status::Qr { svg }); + exchange_until_deadline( + ExchangeContext { + pairing, + id, + relay_url: &relay_url, + pending, + }, + &mut exchange, + &mut socket, + &mut auth, + &mut confirm, + cancel, + deadline, + ) + .await +} + +// The supported sidecar closes connections after 120 seconds. Start before the +// handshake and reserve five seconds so expiry wins over its disconnect. Setup +// consumes this budget; arbitrary transport failures still remain errors. +const CONNECTION_BUDGET: Duration = Duration::from_secs(115); + +fn exchange_deadline( + session: &PairingSession, + connection_started: tokio::time::Instant, +) -> tokio::time::Instant { + tokio::time::Instant::from_std(session.deadline()).min(connection_started + CONNECTION_BUDGET) +} + +struct ExchangeContext<'a> { + pairing: &'a Pairing, + id: &'a str, + relay_url: &'a url::Url, + pending: Vec, +} + +async fn exchange_until_deadline( + context: ExchangeContext<'_>, + exchange: &mut Exchange, + socket: &mut relay::Socket, + auth: &mut relay::Authentication, + confirm: &mut mpsc::Receiver, + cancel: &CancellationToken, + deadline: tokio::time::Instant, +) -> Result<(), Failure> { + tokio::select! { + biased; + _ = cancel.cancelled() => { + // Once transfer construction has consumed the payload, publication may + // already have reached the phone. An abort would interrupt its import. + if exchange.payload.is_some() { + abort(exchange, socket).await; + } + Ok(()) + } + _ = tokio::time::sleep_until(deadline) => Err(Failure::Expired), + result = exchange_loop(context, exchange, socket, auth, confirm) => result, + } +} + +// The same bounded best-effort notification serves explicit rejection and teardown. +async fn abort(exchange: &mut Exchange, socket: &mut relay::Socket) { + if let Ok(Some(event)) = exchange.session.abort(AbortReason::UserDenied) { + let _ = tokio::time::timeout(Duration::from_secs(2), relay::send(socket, &event)).await; + } +} + +async fn exchange_loop( + context: ExchangeContext<'_>, + exchange: &mut Exchange, + socket: &mut relay::Socket, + auth: &mut relay::Authentication, + confirm: &mut mpsc::Receiver, +) -> Result<(), Failure> { + let ExchangeContext { + pairing, + id, + relay_url, + pending, + } = context; + let mut pending = std::collections::VecDeque::from(pending); + loop { + let output = if let Some(event) = pending.pop_front() { + exchange.receive(&event).map_err(Failure::Rejected)? + } else { + loop { + let output = tokio::select! { + Some(decision) = confirm.recv() => match decision { + Decision::Confirm => exchange.confirm().map_err(Failure::Rejected)?, + Decision::Deny => { + if exchange.code_entry || exchange.session.state() != SessionState::Confirming { + return Err(Failure::Rejected("This comparison is no longer available.".into())); + } + abort(exchange, socket).await; + return Err(Failure::Rejected("Pairing was canceled.".into())); + } + }, + message = relay::next(socket) => { + let message = message?; + if auth.handle(socket, &exchange.session, relay_url, &message).await? { continue; } + if let Some(event) = relay::event(&message) { + exchange.receive(&event).map_err(Failure::Rejected)? + } else { continue; } + } + }; + break output; + } + }; + let payload_index = output.events.len().checked_sub(1); + for (index, event) in output.events.into_iter().enumerate() { + if output.status == Some(Status::Transferring) && Some(index) == payload_index { + // Once publication begins, cancellation of the await cannot prove + // that the phone did not receive and import this payload. + if !pairing.mark_payload_sent(id) { + abort(exchange, socket).await; + return Ok(()); + } + } + relay::send(socket, &event).await?; + auth.unacknowledged.push(event); + } + if let Some(status) = output.status { + if status == Status::Complete { + return Ok(()); + } + if let Status::Error { message } = status { + return Err(Failure::Rejected(message)); + } + pairing.update(id, status); + } + } +} + +struct Exchange { + session: PairingSession, + payload: Option>, + code_entry: bool, +} +#[derive(Default)] +struct Output { + events: Vec, + status: Option, +} +impl Exchange { + fn transfer(&mut self, proof: Event) -> Result { + let payload = self + .payload + .take() + .ok_or("This pairing code has already been used.")?; + let event = self + .session + .send_payload(PayloadType::Custom, payload) + .map_err(|_| "Couldn’t prepare the pairing transfer.")?; + Ok(Output { + events: vec![proof, event], + status: Some(Status::Transferring), + }) + } + fn confirm(&mut self) -> Result { + if self.code_entry { + return Err("Enter the code on your phone to continue.".into()); + } + let proof = self + .session + .confirm_sas() + .map_err(|_| "This confirmation has expired. Try pairing again.")?; + self.transfer(proof) + } + fn receive(&mut self, event: &Event) -> Result { + if self.session.handle_abort(event).is_ok() { + return Err("Pairing was cancelled on your phone. Try again when you’re ready.".into()); + } + if let Ok((code, code_entry)) = self.session.handle_offer_with_confirmation(event) { + self.code_entry = code_entry; + let (code, events) = if code_entry { + let (code, challenge) = self + .session + .start_desktop_code() + .map_err(|_| "Couldn’t create the desktop verification code.")?; + (code, vec![challenge]) + } else { + (code, vec![]) + }; + return Ok(Output { + events, + status: Some(Status::Code { code, code_entry }), + }); + } + if self.code_entry && self.session.state() == SessionState::Confirming { + if let Ok((reply, accepted)) = self.session.handle_target_code(event) { + if accepted { + return self.transfer(reply); + } + let status = + (self.session.state() == SessionState::Aborted).then(|| Status::Error { + message: "Too many incorrect codes. Try pairing again.".into(), + }); + return Ok(Output { + events: vec![reply], + status, + }); + } + } + + match self.session.handle_complete(event) { + Ok(()) => { + return Ok(Output { + events: vec![], + status: Some(Status::Complete), + }) + } + Err(_) if self.session.state() == SessionState::Aborted => { + return Err("Your phone couldn’t save the account. Try pairing again.".into()) + } + Err(_) => {} + } + Ok(Output::default()) + } +} +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod relay_tests; diff --git a/src-tauri/src/pairing/qr-reveal.css b/src-tauri/src/pairing/qr-reveal.css new file mode 100644 index 000000000..1fcf20e61 --- /dev/null +++ b/src-tauri/src/pairing/qr-reveal.css @@ -0,0 +1,62 @@ +@keyframes buzz-qr-cell-reveal { + 0% { + opacity: 0; + transform: scale(0); + } + + 27% { + opacity: 0.27; + transform: scale(0); + } + + 30% { + opacity: 0.3; + transform: scale(0.043); + } + + 35% { + opacity: 0.35; + transform: scale(0.167); + } + + 40% { + opacity: 0.4; + transform: scale(0.358); + } + + 45% { + opacity: 0.45; + transform: scale(0.632); + } + + 49% { + opacity: 0.49; + transform: scale(0.918); + } + + 50% { + opacity: 0.5; + transform: scale(1.1); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +.buzz-qr-cell-reveal { + opacity: 0; + transform-box: fill-box; + transform-origin: center; + animation: buzz-qr-cell-reveal 58ms linear var(--buzz-qr-reveal-delay, 0ms) + forwards; +} + +@media (prefers-reduced-motion: reduce) { + .buzz-qr-cell-reveal { + opacity: 1; + transform: none; + animation: none; + } +} diff --git a/src-tauri/src/pairing/qr.rs b/src-tauri/src/pairing/qr.rs new file mode 100644 index 000000000..86a2a5d71 --- /dev/null +++ b/src-tauri/src/pairing/qr.rs @@ -0,0 +1,60 @@ +//! Visual geometry matches the original Buzz StyledQrCode component. +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use qrcode::{Color, EcLevel, QrCode}; +use std::fmt::Write; + +pub(super) fn render(value: &str) -> Result { + // Match the original component’s correction level and bounded logo area. + let code = QrCode::with_error_correction_level(value.as_bytes(), EcLevel::M) + .map_err(|_| "Couldn’t create the pairing code.")?; + let size = code.width(); + let mut center = ((size * size) as f64 * 0.1).sqrt().floor() as usize; + if center % 2 == 0 { + center -= 1; + } + let start = (size - center) / 2; + let end = start + center; + let icon_size = center as f64 * 0.8; + let icon_start = (size as f64 - icon_size) / 2.0; + let extent = size + 8; + let mut svg = format!( + r#""# + ); + svg.push_str(""); + for row in 0..size { + // Original 250ms reveal: row travel plus a 58ms dot animation. + let delay = (row as f64 / size as f64 * (250.0 / 1.3)).round() as u32; + for col in 0..size { + let finder = (row < 7 && (col < 7 || col >= size - 7)) || (row >= size - 7 && col < 7); + let logo = (start..end).contains(&row) && (start..end).contains(&col); + if code[(col, row)] == Color::Dark && !finder && !logo { + write!(svg, r#""#, col, row).unwrap(); + } + } + } + svg.push_str(""); + for (x, y) in [(0, 0), (size - 7, 0), (0, size - 7)] { + write!(svg, r#""#, x + 1, y + 1, x + 2, y + 2).unwrap(); + } + let icon = STANDARD.encode(include_bytes!("../../../public/app-icon.png")); + write!(svg, r##""##).unwrap(); + Ok(svg) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn styled_code_keeps_quiet_zone_and_embeds_the_logo() { + let svg = render("pairing-test").unwrap(); + assert!(svg.contains("viewBox=\"-4 -4 ")); + assert!(svg.contains(">; + +// Other app clients enable a second Rustls provider. Choose ours per connection, +// rather than relying on process-global feature inference or changing other clients. +pub(super) fn tls_connector() -> Result { + let roots = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|_| "Couldn’t initialize the secure pairing connection.")? + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(tokio_tungstenite::Connector::Rustls(std::sync::Arc::new( + config, + ))) +} + +pub(super) fn community(input: &str) -> Result { + let mut url = Url::parse(input).map_err(|_| "Enter a secure community URL.")?; + if input.len() > 2048 + || !matches!(url.scheme(), "https" | "wss") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !matches!(url.path(), "" | "/") + { + return Err( + "Enter a https:// or wss:// community URL without a path or account details.".into(), + ); + } + url.set_scheme("https") + .map_err(|_| "Enter a secure community URL.")?; + Ok(url) +} +fn secure_pairing_url(input: &str) -> Result { + let url = + Url::parse(input).map_err(|_| "The community returned an unsupported pairing address.")?; + if url.scheme() != "wss" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err("The community returned an unsupported pairing address.".into()); + } + Ok(url) +} +fn advertised(origin: &Url, document: &serde_json::Value) -> Result { + if let Some(value) = document.get("pairing_relay_url") { + return secure_pairing_url( + value + .as_str() + .ok_or("The community returned an unsupported pairing address.")?, + ); + } + let mut url = origin.clone(); + url.set_scheme("wss") + .map_err(|_| "Unsupported community address.")?; + if document + .get("supported_nips") + .and_then(|v| v.as_array()) + .is_some_and(|nips| nips.iter().any(|n| n.as_u64() == Some(43))) + { + url.set_path("/pair"); + } + Ok(url) +} +pub(super) async fn discover(origin: &Url) -> Result { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(8)) + .build() + .map_err(|_| "Couldn’t connect to this community.")?; + let response = client + .get(origin.clone()) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|_| "Couldn’t reach this community. Check your connection and try again.")?; + if !response.status().is_success() { + return Err("This community couldn’t provide its pairing details. Try again.".into()); + } + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "Couldn’t read community pairing details.")?; + if bytes.len() + chunk.len() > 65536 { + return Err("Community pairing details are too large.".into()); + } + bytes.extend_from_slice(&chunk); + } + let json = + serde_json::from_slice(&bytes).map_err(|_| "Couldn’t read community pairing details.")?; + advertised(origin, &json) +} +pub(super) async fn connect(relay: &Url, limit: Duration) -> Result { + let config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(256 * 1024)) + .max_frame_size(Some(256 * 1024)); + tokio::time::timeout( + limit, + tokio_tungstenite::connect_async_tls_with_config( + relay.as_str(), + Some(config), + false, + Some(tls_connector()?), + ), + ) + .await + .map_err(|_| "Pairing connection timed out. Check your connection and try again.")? + .map(|(socket, _)| socket) + .map_err(|_| "Couldn’t connect for pairing. Check your connection and try again.".into()) +} + +pub(super) async fn send(socket: &mut Socket, event: &Event) -> Result<(), String> { + socket + .send(Message::Text( + format!("[\"EVENT\",{}]", event.as_json()).into(), + )) + .await + .map_err(|_| "The pairing connection closed. Try again.".into()) +} + +// Authentication remains live after EOSE: some relays challenge the first publication. +#[derive(Default)] +pub(super) struct Authentication { + id: Option, + challenges: usize, + pub unacknowledged: Vec, +} +impl Authentication { + pub async fn handle( + &mut self, + socket: &mut Socket, + session: &PairingSession, + relay: &Url, + message: &serde_json::Value, + ) -> Result { + match message[0].as_str() { + Some("AUTH") => { + self.challenges += 1; + if self.challenges > 3 { + return Err("The relay repeated its account challenge. Try again.".into()); + } + let challenge = message[1] + .as_str() + .ok_or("Couldn’t authenticate pairing.")?; + let event = session + .sign_event(nostr_pairing::EventBuilder::auth( + challenge, + nostr_pairing::RelayUrl::parse(relay.as_str()) + .map_err(|_| "Unsupported pairing address.")?, + )) + .map_err(|_| "Couldn’t authenticate pairing.")?; + self.id = Some(event.id.to_hex()); + socket + .send(Message::Text( + format!("[\"AUTH\",{}]", event.as_json()).into(), + )) + .await + .map_err(|_| "Couldn’t authenticate pairing.")?; + Ok(true) + } + Some("OK") + if self + .id + .as_deref() + .is_some_and(|id| Some(id) == message[1].as_str()) => + { + if message[2].as_bool() != Some(true) { + return Err("This community didn’t accept the pairing connection.".into()); + } + self.id = None; + request(socket, session).await?; + // Replay only signed events awaiting receipts. Peer event IDs deduplicate them. + for event in &self.unacknowledged { + send(socket, event).await?; + } + Ok(true) + } + Some("OK") => { + if let Some(index) = self + .unacknowledged + .iter() + .position(|e| Some(e.id.to_hex().as_str()) == message[1].as_str()) + { + if message[2].as_bool() == Some(true) { + self.unacknowledged.remove(index); + } else if !message[3] + .as_str() + .unwrap_or("") + .starts_with("auth-required:") + { + return Err("The community couldn’t deliver pairing. Try again.".into()); + } + } + Ok(true) + } + Some("CLOSED") if message[1] == "pair" => { + if !message[2] + .as_str() + .unwrap_or("") + .starts_with("auth-required:") + { + return Err("The community closed pairing. Try again.".into()); + } + Ok(true) + } + _ => Ok(false), + } + } +} + +// Subscribe immediately, preserving offers arriving before the readiness marker. +pub(super) async fn subscribe( + socket: &mut Socket, + session: &PairingSession, + relay: &Url, +) -> Result<(Vec, Authentication), String> { + tokio::time::timeout(Duration::from_secs(15), async { + request(socket, session).await?; + let mut pending = Vec::new(); + let mut auth = Authentication::default(); + loop { + let message = next(socket).await?; + if auth.handle(socket, session, relay, &message).await? { + continue; + } + match message[0].as_str() { + Some("EOSE") if message[1] == "pair" => return Ok((pending, auth)), + Some("EVENT") if message[1] == "pair" => { + if pending.len() >= 32 { + return Err("Too much pairing traffic. Try again.".into()); + } + if let Some(event) = event(&message) { + pending.push(event); + } + } + _ => {} + } + } + }) + .await + .map_err(|_| "Pairing took too long to connect. Try again.".to_string())? +} +async fn request(socket: &mut Socket, session: &PairingSession) -> Result<(), String> { + socket.send(Message::Text(serde_json::json!(["REQ","pair",{"kinds":[KIND_PAIRING],"#p":[session.pubkey().to_hex()]}]).to_string().into())) + .await.map_err(|_| "Couldn’t subscribe to pairing.".into()) +} +pub(super) async fn next(socket: &mut Socket) -> Result { + loop { + match socket.next().await { + Some(Ok(Message::Text(text))) => { + if let Ok(value) = serde_json::from_str::(&text) { + if value.is_array() { + return Ok(value); + } + } + } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => { + return Err("The pairing connection closed. Try again.".into()) + } + Some(Ok(_)) => {} + } + } +} +pub(super) fn event(value: &serde_json::Value) -> Option { + if value[0] != "EVENT" || value[1] != "pair" { + return None; + } + serde_json::from_value(value[2].clone()).ok() +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn pairing_tls_is_explicit_even_with_multiple_app_providers() { + assert!(matches!( + tls_connector().unwrap(), + tokio_tungstenite::Connector::Rustls(_) + )); + } + #[test] + fn destinations_stay_secure_and_credentials_are_rejected() { + for value in [ + "http://relay.test", + "https://user:secret@relay.test", + "https://relay.test/api", + "https://relay.test?secret=x", + ] { + assert!(community(value).is_err()); + } + let origin = community("wss://relay.test").unwrap(); + assert_eq!( + advertised(&origin, &serde_json::json!({"supported_nips":[43]})) + .unwrap() + .as_str(), + "wss://relay.test/pair" + ); + assert_eq!( + advertised(&origin, &serde_json::json!({})) + .unwrap() + .as_str(), + "wss://relay.test/" + ); + assert!(advertised( + &origin, + &serde_json::json!({"pairing_relay_url":"ws://relay.test"}) + ) + .is_err()); + assert_eq!( + advertised( + &origin, + &serde_json::json!({"pairing_relay_url":"wss://pair.test/pair"}) + ) + .unwrap() + .as_str(), + "wss://pair.test/pair" + ); + } +} diff --git a/src-tauri/src/pairing/relay_tests.rs b/src-tauri/src/pairing/relay_tests.rs new file mode 100644 index 000000000..410dd749d --- /dev/null +++ b/src-tauri/src/pairing/relay_tests.rs @@ -0,0 +1,126 @@ +use super::relay::*; +use buzz_pairing::PairingSession; +use futures_util::{SinkExt, StreamExt}; +use nostr_pairing::JsonUtil; +use tokio_tungstenite::{tungstenite::Message, WebSocketStream}; +use url::Url; + +async fn sockets() -> (Socket, WebSocketStream) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + tokio_tungstenite::accept_async(listener.accept().await.unwrap().0) + .await + .unwrap() + }); + let (client, _) = tokio_tungstenite::connect_async(format!("ws://{address}")) + .await + .unwrap(); + (client, server.await.unwrap()) +} +async fn send_server( + server: &mut WebSocketStream, + value: serde_json::Value, +) { + server + .send(Message::Text(value.to_string().into())) + .await + .unwrap(); +} +async fn read_server(server: &mut WebSocketStream) -> serde_json::Value { + let message = server.next().await.unwrap().unwrap(); + serde_json::from_str(message.to_text().unwrap()).unwrap() +} +#[tokio::test] +async fn subscription_preserves_an_offer_arriving_before_eose() { + let (mut client, mut server) = sockets().await; + let relay = Url::parse("wss://relay.test/pair").unwrap(); + let (source, qr) = PairingSession::new_source(relay.to_string()); + let (_, offer) = PairingSession::new_target(&qr).unwrap(); + send_server(&mut server, serde_json::json!(["EVENT", "pair", offer])).await; + send_server(&mut server, serde_json::json!(["EOSE", "pair"])).await; + let (pending, _) = subscribe(&mut client, &source, &relay).await.unwrap(); + assert_eq!(pending, vec![offer]); + assert_eq!(read_server(&mut server).await[0], "REQ"); +} +#[tokio::test] +async fn closed_subscription_never_becomes_ready() { + let (mut client, mut server) = sockets().await; + let relay = Url::parse("wss://relay.test/pair").unwrap(); + let (source, _) = PairingSession::new_source(relay.to_string()); + send_server( + &mut server, + serde_json::json!(["CLOSED", "pair", "restricted"]), + ) + .await; + assert!(subscribe(&mut client, &source, &relay).await.is_err()); +} +#[tokio::test] +async fn late_auth_uses_ephemeral_key_and_retries_only_unacknowledged_events() { + let (mut client, mut server) = sockets().await; + let relay = Url::parse("wss://relay.test/pair").unwrap(); + let (source, _) = PairingSession::new_source(relay.to_string()); + send_server(&mut server, serde_json::json!(["EOSE", "pair"])).await; + let (_, mut auth) = subscribe(&mut client, &source, &relay).await.unwrap(); + assert_eq!(read_server(&mut server).await[0], "REQ"); + let event = source + .sign_event(nostr_pairing::EventBuilder::new( + nostr_pairing::Kind::Custom(24134), + "encrypted-fixture", + )) + .unwrap(); + auth.unacknowledged.push(event.clone()); + send_server(&mut server, serde_json::json!(["AUTH", "challenge"])).await; + let message = next(&mut client).await.unwrap(); + assert!(auth + .handle(&mut client, &source, &relay, &message) + .await + .unwrap()); + let authentication = read_server(&mut server).await; + assert_eq!(authentication[0], "AUTH"); + let proof = nostr_pairing::Event::from_json(authentication[1].to_string()).unwrap(); + assert!(proof.verify().is_ok()); + assert_eq!(proof.pubkey, source.pubkey()); + assert_eq!(proof.kind, nostr_pairing::Kind::Authentication); + let rejected = serde_json::json!([ + "OK", + event.id.to_hex(), + false, + "auth-required: authenticate" + ]); + assert!(auth + .handle(&mut client, &source, &relay, &rejected) + .await + .unwrap()); + let accepted = serde_json::json!(["OK", proof.id.to_hex(), true, ""]); + assert!(auth + .handle(&mut client, &source, &relay, &accepted) + .await + .unwrap()); + assert_eq!(read_server(&mut server).await[0], "REQ"); + let replay = read_server(&mut server).await; + assert_eq!(replay[1]["id"], event.id.to_hex()); + auth.handle( + &mut client, + &source, + &relay, + &serde_json::json!(["OK", event.id.to_hex(), true, ""]), + ) + .await + .unwrap(); + assert!(auth.unacknowledged.is_empty()); +} + +#[tokio::test] +async fn stalled_connection_has_a_retryable_transport_timeout() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (_socket, _) = listener.accept().await.unwrap(); + std::future::pending::<()>().await; + }); + let relay = Url::parse(&format!("ws://{address}")).unwrap(); + let result = connect(&relay, std::time::Duration::from_millis(50)).await; + assert!(result.unwrap_err().contains("connection timed out")); + server.abort(); +} diff --git a/src-tauri/src/pairing/tests.rs b/src-tauri/src/pairing/tests.rs new file mode 100644 index 000000000..18ab69fc5 --- /dev/null +++ b/src-tauri/src/pairing/tests.rs @@ -0,0 +1,757 @@ +use super::*; +use buzz_pairing::{crypto, PairingMessage}; +use nostr_pairing::{EventBuilder, JsonUtil, Keys, Kind, Tag}; + +fn message(keys: &Keys, source: &PairingSession, value: PairingMessage) -> Event { + let content = nostr_pairing::nips::nip44::encrypt( + keys.secret_key(), + &source.pubkey(), + serde_json::to_string(&value).unwrap(), + nostr_pairing::nips::nip44::Version::V2, + ) + .unwrap(); + EventBuilder::new(Kind::Custom(buzz_pairing::session::KIND_PAIRING), content) + .tags([Tag::public_key(source.pubkey())]) + .sign_with_keys(keys) + .unwrap() +} +fn entry() -> (Exchange, Keys, buzz_pairing::QrPayload, String) { + let (session, qr) = PairingSession::new_source("wss://relay.test".into()); + let target = Keys::generate(); + let mut exchange = Exchange { + session, + payload: Some(Zeroizing::new("identity-fixture".into())), + code_entry: false, + }; + let offer = message( + &target, + &exchange.session, + PairingMessage::Offer { + session_id: hex::encode(crypto::derive_session_id(&qr.session_secret)), + version: 1, + confirmation: Some("desktop-code-v1".into()), + }, + ); + let output = exchange.receive(&offer).unwrap(); + assert!(matches!( + output.status, + Some(Status::Code { + code_entry: true, + .. + }) + )); + assert_eq!(output.events.len(), 1); + let Some(Status::Code { code, .. }) = output.status else { + panic!("missing code") + }; + (exchange, target, qr, code) +} +fn proof(exchange: &Exchange, target: &Keys, qr: &buzz_pairing::QrPayload) -> Event { + let shared = + nostr_pairing::util::generate_shared_key(target.secret_key(), &qr.source_pubkey).unwrap(); + let (_, sas) = crypto::derive_sas(&shared, &qr.session_secret); + let hash = crypto::derive_transcript_hash( + &crypto::derive_session_id(&qr.session_secret), + &qr.source_pubkey.to_bytes(), + &target.public_key().to_bytes(), + &sas, + &qr.session_secret, + ); + message( + target, + &exchange.session, + PairingMessage::SasConfirm { + transcript_hash: hex::encode(hash), + }, + ) +} +fn submission(exchange: &Exchange, target: &Keys, code: &str, attempt: u8) -> Event { + message( + target, + &exchange.session, + PairingMessage::CodeSubmit { + code: code.into(), + request_id: attempt.to_string(), + }, + ) +} +#[test] +fn transcript_alone_never_exports_and_guess_budget_cannot_be_reset() { + let (mut exchange, target, qr, code) = entry(); + assert!(exchange + .receive(&proof(&exchange, &target, &qr)) + .unwrap() + .events + .is_empty()); + assert!(exchange.payload.is_some()); + let wrong = if code == "000000" { "000001" } else { "000000" }; + for attempt in 1..=5 { + let event = submission(&exchange, &target, wrong, attempt); + let output = exchange.receive(&event).unwrap(); + assert_eq!(output.events.len(), 1); + assert!(exchange.payload.is_some()); + if attempt == 5 { + assert!(matches!(output.status, Some(Status::Error { .. }))); + } + if attempt < 5 { + assert!(exchange.receive(&event).unwrap().events.is_empty()); + } else { + assert!(exchange.receive(&event).is_err()); + } + } + assert!(exchange + .receive(&submission(&exchange, &target, &code, 6)) + .is_err()); + assert!(exchange.payload.is_some()); +} +#[test] +fn timeout_preserves_possible_import_only_after_payload_publication() { + let manager = Pairing::default(); + let (tx, _) = mpsc::channel(1); + *manager.0.lock().unwrap() = Some(Active { + id: "live".into(), + cancel: CancellationToken::new(), + finished: CancellationToken::new(), + confirm: tx, + status: Status::Transferring, + payload_sent: false, + }); + manager.expire("live"); + assert_eq!( + manager.0.lock().unwrap().as_ref().unwrap().status, + Status::Expired + ); + manager.mark_payload_sent("live"); + manager.expire("stale"); + assert_eq!( + manager.0.lock().unwrap().as_ref().unwrap().status, + Status::Expired + ); + manager.expire("live"); + assert_eq!( + manager.0.lock().unwrap().as_ref().unwrap().status, + Status::Uncertain + ); +} +#[test] +fn entry_requires_phone_proof_and_real_completion() { + let (mut exchange, target, qr, code) = entry(); + assert!( + exchange.confirm().is_err(), + "desktop cannot bypass code entry" + ); + let output = exchange + .receive(&submission(&exchange, &target, &code, 1)) + .unwrap(); + assert_eq!(output.status, Some(Status::Transferring)); + assert_eq!(output.events.len(), 2); + let plain = Zeroizing::new( + nostr_pairing::nips::nip44::decrypt( + target.secret_key(), + &qr.source_pubkey, + &output.events[1].content, + ) + .unwrap(), + ); + assert!(plain.contains("identity-fixture")); + assert!(!serde_json::to_string(&output.status) + .unwrap() + .contains("identity-fixture")); + let done = message( + &target, + &exchange.session, + PairingMessage::Complete { success: true }, + ); + assert_eq!( + exchange.receive(&done).unwrap().status, + Some(Status::Complete) + ); +} +#[test] +fn mismatch_and_phone_import_failure_never_claim_success() { + let (mut exchange, target, _, code) = entry(); + let wrong = message( + &target, + &exchange.session, + PairingMessage::CodeSubmit { + code: if code == "000000" { "000001" } else { "000000" }.into(), + request_id: "wrong".into(), + }, + ); + let rejection = exchange.receive(&wrong).unwrap(); + assert_eq!(rejection.events.len(), 1); + assert_ne!(rejection.status, Some(Status::Transferring)); + assert!(exchange.payload.is_some()); + let (mut exchange, target, _qr, code) = entry(); + exchange + .receive(&submission(&exchange, &target, &code, 1)) + .unwrap(); + let rejected = message( + &target, + &exchange.session, + PairingMessage::Complete { success: false }, + ); + assert!(exchange.receive(&rejected).is_err()); +} +#[test] +fn legacy_phone_still_needs_explicit_desktop_confirmation() { + let (session, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut phone, offer) = PairingSession::new_target(&qr).unwrap(); + let mut exchange = Exchange { + session, + payload: Some(Zeroizing::new("fixture".into())), + code_entry: false, + }; + assert!(matches!( + exchange.receive(&offer).unwrap().status, + Some(Status::Code { + code_entry: false, + .. + }) + )); + let output = exchange.confirm().unwrap(); + phone.handle_sas_confirm(&output.events[0]).unwrap(); + phone.confirm_target_sas().unwrap(); + assert_eq!( + &*phone.handle_payload(&output.events[1]).unwrap().1, + "fixture" + ); + assert_eq!( + exchange + .receive(&phone.send_complete().unwrap()) + .unwrap() + .status, + Some(Status::Complete) + ); +} +#[tokio::test] +async fn old_session_cleanup_cannot_cancel_or_overwrite_replacement() { + let manager = Pairing::default(); + let (tx, _) = mpsc::channel(1); + let cancel = CancellationToken::new(); + *manager.0.lock().unwrap() = Some(Active { + id: "new".into(), + cancel: cancel.clone(), + finished: CancellationToken::new(), + confirm: tx, + status: Status::Connecting, + payload_sent: false, + }); + manager.cancel("old").await.unwrap(); + manager.update("old", Status::Complete); + assert!(!cancel.is_cancelled()); + assert_eq!( + manager.0.lock().unwrap().as_ref().unwrap().status, + Status::Connecting + ); + manager + .0 + .lock() + .unwrap() + .as_ref() + .unwrap() + .finished + .cancel(); + manager.cancel("new").await.unwrap(); + assert!(cancel.is_cancelled()); + manager.update("new", Status::Complete); + assert!(manager.0.lock().unwrap().is_none()); +} + +#[tokio::test] +async fn window_reload_or_destruction_cancels_the_live_attempt() { + let manager = Pairing::default(); + let (tx, _) = mpsc::channel(1); + let cancel = CancellationToken::new(); + *manager.0.lock().unwrap() = Some(Active { + id: "live".into(), + cancel: cancel.clone(), + finished: CancellationToken::new(), + confirm: tx, + status: Status::Connecting, + payload_sent: false, + }); + manager.cancel_all(); + assert!(cancel.is_cancelled()); + assert!(manager.0.lock().unwrap().is_none()); +} + +#[test] +fn cancellation_preserves_post_publication_outcomes() { + for (before, after) in [ + (Status::Transferring, Status::Uncertain), + (Status::Complete, Status::Complete), + ( + Status::Error { + message: "Your phone couldn’t save the account. Try pairing again.".into(), + }, + Status::Error { + message: "Your phone couldn’t save the account. Try pairing again.".into(), + }, + ), + ] { + let manager = Pairing::default(); + let (tx, _) = mpsc::channel(1); + *manager.0.lock().unwrap() = Some(Active { + id: "live".into(), + cancel: CancellationToken::new(), + finished: CancellationToken::new(), + confirm: tx, + status: before, + payload_sent: true, + }); + // Window close, reload, and macOS hide all use this path. + manager.cancel_all(); + let active = manager.0.lock().unwrap(); + let active = active.as_ref().unwrap(); + assert!(active.cancel.is_cancelled()); + assert_eq!(active.status, after); + } +} + +#[test] +fn cancellation_before_publication_blocks_the_payload() { + let manager = Pairing::default(); + let (tx, _) = mpsc::channel(1); + *manager.0.lock().unwrap() = Some(Active { + id: "live".into(), + cancel: CancellationToken::new(), + finished: CancellationToken::new(), + confirm: tx, + status: Status::Transferring, + payload_sent: false, + }); + manager.cancel_all(); + assert!(!manager.mark_payload_sent("live")); +} + +#[tokio::test] +async fn unexpected_connection_failure_becomes_a_visible_error() { + let result = guard(async { panic!("simulated connection setup failure") }).await; + assert_eq!( + match result.unwrap_err() { + Failure::Transport(message) => message, + other => panic!("{other:?}"), + }, + "Pairing stopped unexpectedly. Create a new code and try again." + ); +} + +#[test] +fn transport_failure_is_uncertain_after_publication_but_phone_rejection_is_definite() { + let manager = Pairing::default(); + let (tx, _) = mpsc::channel(1); + *manager.0.lock().unwrap() = Some(Active { + id: "live".into(), + cancel: CancellationToken::new(), + finished: CancellationToken::new(), + confirm: tx, + status: Status::Transferring, + payload_sent: false, + }); + let error = Status::Error { + message: "connection lost".into(), + }; + manager.fail("live", error.clone(), true); + assert_eq!(manager.0.lock().unwrap().as_ref().unwrap().status, error); + manager.mark_payload_sent("live"); + manager.fail("stale", error.clone(), true); + assert_eq!(manager.0.lock().unwrap().as_ref().unwrap().status, error); + manager.fail("live", error.clone(), true); + assert_eq!( + manager.0.lock().unwrap().as_ref().unwrap().status, + Status::Uncertain + ); + manager.fail("live", error.clone(), false); + assert_eq!(manager.0.lock().unwrap().as_ref().unwrap().status, error); +} + +#[test] +fn mismatched_legacy_code_sends_user_denied_without_exporting() { + let (session, qr) = PairingSession::new_source("wss://relay.test".into()); + let (mut phone, offer) = PairingSession::new_target(&qr).unwrap(); + let mut exchange = Exchange { + session, + payload: Some(Zeroizing::new("fixture".into())), + code_entry: false, + }; + exchange.receive(&offer).unwrap(); + let event = exchange + .session + .abort(AbortReason::UserDenied) + .unwrap() + .expect("legacy peer is known"); + assert_eq!(phone.handle_abort(&event).unwrap(), AbortReason::UserDenied); + assert_eq!(exchange.session.state(), SessionState::Aborted); + assert!( + exchange.payload.is_some(), + "denial must not release the identity" + ); + assert!(exchange.confirm().is_err()); + let (mut code_entry, _, _, _) = entry(); + assert!(code_entry + .session + .abort(AbortReason::UserDenied) + .unwrap() + .is_some()); + assert_eq!(code_entry.session.state(), SessionState::Aborted); + assert!(code_entry.payload.is_some()); +} + +// Exercise the actual post-setup owner, not just PairingSession::abort. +async fn local_sockets() -> ( + relay::Socket, + tokio_tungstenite::WebSocketStream, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + tokio_tungstenite::accept_async(listener.accept().await.unwrap().0) + .await + .unwrap() + }); + let (client, _) = tokio_tungstenite::connect_async(format!("ws://{address}")) + .await + .unwrap(); + (client, server.await.unwrap()) +} + +#[tokio::test] +async fn cancellation_notifies_a_known_peer_before_transfer_and_waits_for_teardown() { + use futures_util::StreamExt; + let (exchange, target, _, _) = entry(); + let (mut socket, mut server) = local_sockets().await; + let (tx, mut rx) = mpsc::channel(1); + let manager = Pairing::default(); + let cancel = CancellationToken::new(); + let finished = CancellationToken::new(); + *manager.0.lock().unwrap() = Some(Active { + id: "live".into(), + cancel: cancel.clone(), + finished: finished.clone(), + confirm: tx, + status: Status::Code { + code: "000000".into(), + code_entry: true, + }, + payload_sent: false, + }); + let owner = manager.clone(); + let task = tokio::spawn(async move { + let mut exchange = exchange; + let mut auth = relay::Authentication::default(); + let result = exchange_until_deadline( + ExchangeContext { + pairing: &owner, + id: "live", + relay_url: &url::Url::parse("wss://relay.test").unwrap(), + pending: vec![], + }, + &mut exchange, + &mut socket, + &mut auth, + &mut rx, + &cancel, + tokio::time::Instant::now() + Duration::from_secs(120), + ) + .await; + finished.cancel(); + result + }); + manager.cancel("live").await.unwrap(); + assert!(task.is_finished(), "cancel returned before owner teardown"); + assert!(task.await.unwrap().is_ok()); + let frame = tokio::time::timeout(Duration::from_secs(2), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let json: serde_json::Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(json[0], "EVENT"); + let event = Event::from_json(json[1].to_string()).unwrap(); + let plaintext = + nostr_pairing::nips::nip44::decrypt(target.secret_key(), &event.pubkey, &event.content) + .unwrap(); + assert!(matches!( + serde_json::from_str::(&plaintext).unwrap(), + PairingMessage::Abort { + reason: AbortReason::UserDenied + } + )); +} + +#[tokio::test] +async fn cancellation_after_transfer_closes_without_aborting_the_importing_phone() { + use futures_util::{SinkExt, StreamExt}; + let (exchange, target, _, code) = entry(); + let request = submission(&exchange, &target, &code, 1); + let (socket, mut server) = local_sockets().await; + let (tx, mut rx) = mpsc::channel(1); + let manager = Pairing::default(); + let cancel = CancellationToken::new(); + let finished = CancellationToken::new(); + *manager.0.lock().unwrap() = Some(Active { + id: "live".into(), + cancel: cancel.clone(), + finished: finished.clone(), + confirm: tx, + status: Status::Code { + code: code.clone(), + code_entry: true, + }, + payload_sent: false, + }); + let owner = manager.clone(); + let owner_cancel = cancel.clone(); + let task = tokio::spawn(async move { + let mut exchange = exchange; + let mut socket = socket; + let mut auth = relay::Authentication::default(); + let result = exchange_until_deadline( + ExchangeContext { + pairing: &owner, + id: "live", + relay_url: &url::Url::parse("wss://relay.test").unwrap(), + pending: vec![], + }, + &mut exchange, + &mut socket, + &mut auth, + &mut rx, + &owner_cancel, + tokio::time::Instant::now() + Duration::from_secs(120), + ) + .await; + finished.cancel(); + result + }); + server + .send(tokio_tungstenite::tungstenite::Message::Text( + serde_json::json!(["EVENT", "pair", request]) + .to_string() + .into(), + )) + .await + .unwrap(); + let mut messages = vec![]; + for _ in 0..2 { + let frame = tokio::time::timeout(Duration::from_secs(2), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let json: serde_json::Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(json[0], "EVENT"); + let event = Event::from_json(json[1].to_string()).unwrap(); + let plaintext = + nostr_pairing::nips::nip44::decrypt(target.secret_key(), &event.pubkey, &event.content) + .unwrap(); + messages.push(serde_json::from_str::(&plaintext).unwrap()); + } + assert!(matches!(messages[1], PairingMessage::Payload { .. })); + // Cancel before any status poll observes the transfer. + assert_eq!(manager.cancel("live").await.unwrap(), Status::Uncertain); + assert!(task.await.unwrap().is_ok()); + assert_eq!( + manager.0.lock().unwrap().as_ref().unwrap().status, + Status::Uncertain, + "a later status read must not report an unsent cancellation" + ); + assert!( + !matches!( + server.next().await, + Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) + ), + "post-publication abort was sent" + ); +} + +#[tokio::test] +async fn native_deadline_expires_without_a_peer_or_an_abort() { + use futures_util::StreamExt; + let (mut exchange, _, _, _) = entry(); + let (mut socket, mut server) = local_sockets().await; + let (_tx, mut rx) = mpsc::channel(1); + let mut auth = relay::Authentication::default(); + let result = exchange_until_deadline( + ExchangeContext { + pairing: &Pairing::default(), + id: "live", + relay_url: &url::Url::parse("wss://relay.test").unwrap(), + pending: vec![], + }, + &mut exchange, + &mut socket, + &mut auth, + &mut rx, + &CancellationToken::new(), + tokio::time::Instant::now(), + ) + .await; + assert!(matches!(result, Err(Failure::Expired))); + drop(socket); + assert!(!matches!( + server.next().await, + Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) + )); +} + +#[test] +fn denial_keeps_native_code_until_abort_has_finished() { + let manager = Pairing::default(); + let (tx, mut rx) = mpsc::channel(1); + *manager.0.lock().unwrap() = Some(Active { + id: "live".into(), + cancel: CancellationToken::new(), + finished: CancellationToken::new(), + confirm: tx, + status: Status::Code { + code: "123456".into(), + code_entry: false, + }, + payload_sent: false, + }); + decide(&manager, "live", Decision::Deny).unwrap(); + assert!(matches!(rx.try_recv(), Ok(Decision::Deny))); + assert!(matches!( + manager.0.lock().unwrap().as_ref().unwrap().status, + Status::Code { .. } + )); + manager.fail( + "live", + Status::Error { + message: "Pairing was canceled.".into(), + }, + false, + ); + assert!(matches!( + manager.0.lock().unwrap().as_ref().unwrap().status, + Status::Error { .. } + )); +} + +#[tokio::test] +async fn delayed_subscription_expires_before_sidecar_connection_cap() { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message; + + let (mut socket, mut server) = local_sockets().await; + let relay_url = url::Url::parse("wss://relay.test/pair").unwrap(); + let (session, _) = PairingSession::new_source(relay_url.to_string()); + let subscription = tokio::spawn(async move { + let (pending, auth) = relay::subscribe(&mut socket, &session, &relay_url) + .await + .unwrap(); + (socket, session, relay_url, pending, auth) + }); + assert!(server + .next() + .await + .unwrap() + .unwrap() + .to_text() + .unwrap() + .contains("REQ")); + assert!(!subscription.is_finished(), "QR must wait for EOSE"); + server + .send(Message::Text(r#"["EOSE","pair"]"#.into())) + .await + .unwrap(); + let (mut socket, mut session, relay_url, pending, mut auth) = subscription.await.unwrap(); + session.start_source_lifetime(); + // Model five seconds consumed by the held EOSE without waiting on wall time. + // Pause only after real subscription I/O, avoiding auto-advance during I/O. + tokio::time::pause(); + let connection_started = tokio::time::Instant::now() - Duration::from_secs(5); + let deadline = exchange_deadline(&session, connection_started); + assert_eq!( + deadline - tokio::time::Instant::now(), + Duration::from_secs(110) + ); + let task = tokio::spawn(async move { + let mut exchange = Exchange { + session, + payload: Some(Zeroizing::new("fixture".into())), + code_entry: false, + }; + let (_tx, mut rx) = mpsc::channel(1); + exchange_until_deadline( + ExchangeContext { + pairing: &Pairing::default(), + id: "live", + relay_url: &relay_url, + pending, + }, + &mut exchange, + &mut socket, + &mut auth, + &mut rx, + &CancellationToken::new(), + deadline, + ) + .await + }); + tokio::time::advance(Duration::from_secs(109)).await; + assert!(!task.is_finished()); + tokio::time::advance(Duration::from_secs(1)).await; + assert!(matches!(task.await.unwrap(), Err(Failure::Expired))); + // The native expiry maps to Expired (covered above), which the existing UI + // renewal regression consumes. The sidecar cap has not arrived yet. + assert!(tokio::time::Instant::now() < connection_started + Duration::from_secs(120)); + tokio::time::advance(Duration::from_secs(5)).await; + drop(server); +} + +#[tokio::test] +async fn early_connection_loss_is_error_before_publication_and_uncertain_after() { + for payload_sent in [false, true] { + let (mut socket, server) = local_sockets().await; + let (session, _) = PairingSession::new_source("wss://relay.test".into()); + let manager = Pairing::default(); + let (tx, mut rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + *manager.0.lock().unwrap() = Some(Active { + id: "live".into(), + cancel: cancel.clone(), + finished: CancellationToken::new(), + confirm: tx, + status: Status::Qr { + svg: "fixture".into(), + }, + payload_sent, + }); + let deadline = exchange_deadline(&session, tokio::time::Instant::now()); + let mut exchange = Exchange { + session, + payload: Some(Zeroizing::new("fixture".into())), + code_entry: false, + }; + drop(server); + let result = exchange_until_deadline( + ExchangeContext { + pairing: &manager, + id: "live", + relay_url: &url::Url::parse("wss://relay.test").unwrap(), + pending: vec![], + }, + &mut exchange, + &mut socket, + &mut relay::Authentication::default(), + &mut rx, + &cancel, + deadline, + ) + .await; + let Err(Failure::Transport(message)) = result else { + panic!("unexpected result: {result:?}"); + }; + manager.fail("live", Status::Error { message }, true); + let active = manager.0.lock().unwrap(); + let status = &active.as_ref().unwrap().status; + if payload_sent { + assert_eq!(status, &Status::Uncertain); + } else { + assert!(matches!(status, Status::Error { .. })); + } + } +} diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 9a0e91cf7..548a2478a 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -99,14 +99,34 @@ test("the app runtime exposes ready bundled pages and removes them on disable", ); assert.deepEqual(services.channelTemplates.snapshot(), []); assert.deepEqual( - services.settingsCards.snapshot().map((card) => card.pluginId), + services.settingsCards + .snapshot() + .map((card) => card.pluginId) + .sort(), [ "block.builderlab", - "buzz.channels", "block.hosted-communities", + "buzz.channels", "buzz.emoji", + "buzz.pairing", ], ); + const pairingCard = services.settingsCards + .snapshot() + .find((card) => card.key === "buzz.pairing/mobile"); + assert.ok(pairingCard); + await services.plugins.change("disable", "buzz.pairing"); + assert.equal(services.settingsCards.has("buzz.pairing/mobile"), false); + await services.plugins.change("enable", "buzz.pairing"); + await vi.waitFor(() => + assert.ok(services.settingsCards.has("buzz.pairing/mobile")), + ); + assert.notEqual( + services.settingsCards + .snapshot() + .find((card) => card.key === "buzz.pairing/mobile"), + pairingCard, + ); assert.equal( services.panels.snapshot().some((p) => p.pluginId === "buzz.todos"), false, @@ -152,12 +172,16 @@ test("the app runtime exposes ready bundled pages and removes them on disable", await services.plugins.change("disable", "buzz.channel-templates"); assert.deepEqual(services.channelTemplates.snapshot(), []); assert.deepEqual( - services.settingsCards.snapshot().map((card) => card.pluginId), + services.settingsCards + .snapshot() + .map((card) => card.pluginId) + .sort(), [ "block.builderlab", - "buzz.channels", "block.hosted-communities", + "buzz.channels", "buzz.emoji", + "buzz.pairing", ], ); await services.plugins.change("enable", "buzz.channel-templates"); diff --git a/src/bundled/index.ts b/src/bundled/index.ts index 1184783c1..f2c6c06cc 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -1,3 +1,5 @@ +import pairingManifest from "./pairing/manifest.json"; +import * as pairing from "./pairing"; import todosManifest from "./todos/manifest.json"; import builderlabManifest from "./builderlab/manifest.json"; import * as builderlab from "./builderlab"; @@ -50,6 +52,11 @@ export const bundledPlugins: readonly BundledPlugin[] = [ module: builderlab, enabledByDefault: true, }, + { + manifest: { ...pairingManifest, apiVersion: 1 }, + module: pairing, + enabledByDefault: true, + }, { manifest: { ...feedbackManifest, apiVersion: 1 }, module: feedback, diff --git a/src/bundled/pairing/PairingSettings.test.tsx b/src/bundled/pairing/PairingSettings.test.tsx new file mode 100644 index 000000000..6655cf2d6 --- /dev/null +++ b/src/bundled/pairing/PairingSettings.test.tsx @@ -0,0 +1,432 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { PairingSettings } from "./PairingSettings"; +import type { ClientSnapshot } from "../../features/communities/service"; +import type { + PairingNative, + PairingStatus, +} from "../../features/pairing/client"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); +it.each([ + { live: "1", tauri: true, available: false }, + { live: "0", tauri: true, available: true }, + { live: "0", tauri: false, available: false }, +])( + "uses native identity availability (live: $live, tauri: $tauri)", + ({ live, tauri, available }) => { + vi.stubGlobal("isTauri", tauri); + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + vi.stubEnv("VITE_BUZZ_LIVE", live); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); + const native = { + account: vi.fn(async () => "a".repeat(64)), + start: vi.fn(async () => {}), + status: vi.fn(async (): Promise => ({ phase: "idle" })), + cancel: vi.fn( + async (): Promise => ({ phase: "cancelled" }), + ), + confirm: vi.fn(async () => {}), + deny: vi.fn(async () => {}), + } satisfies PairingNative; + const snapshot: ClientSnapshot = { + status: "ready", + relayAvailable: true, + viewer: "a".repeat(64), + selected: null, + profile: { name: "", picture: "" }, + memberships: [], + }; + render( + snapshot, subscribe: () => () => {} }} + active={() => true} + native={native} + />, + ); + if (available) { + expect( + screen.queryByText(/unavailable with the development broker/), + ).toBeNull(); + expect(screen.getByLabelText("Community address")).toBeTruthy(); + } else { + expect(screen.getByRole("status").textContent).toContain( + tauri + ? "unavailable with the development broker" + : "Open the Buzz desktop app", + ); + expect(screen.queryByLabelText("Community address")).toBeNull(); + } + expect(native.account).not.toHaveBeenCalled(); + expect(native.start).not.toHaveBeenCalled(); + }, +); +it.each([true, false])( + "renews a committed manual destination and pairs another phone (known viewer: %s)", + async (knownViewer) => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); + const viewer = "a".repeat(64); + const snapshot: ClientSnapshot = { + status: "ready", + relayAvailable: true, + ...(knownViewer ? { viewer } : {}), + selected: null, + profile: { name: "", picture: "" }, + memberships: [], + }; + let status: PairingStatus = { phase: "idle" }; + const native = { + account: vi.fn(async () => viewer), + start: vi.fn(async () => { + status = { phase: "qr", svg: "" }; + }), + status: vi.fn(async () => status), + cancel: vi.fn( + async (): Promise => ({ phase: "cancelled" }), + ), + confirm: vi.fn(async () => {}), + deny: vi.fn(async () => {}), + } satisfies PairingNative; + render( + snapshot, subscribe: () => () => {} }} + active={() => true} + available + native={native} + />, + ); + if (!knownViewer) + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: "Use existing Buzz account" }), + ); + }); + const input = screen.getByLabelText("Community address"); + fireEvent.change(input, { target: { value: "https://community.example" } }); + expect(native.start).not.toHaveBeenCalled(); + await act(async () => { + fireEvent.blur(input); + }); + expect(native.start).toHaveBeenCalledTimes(1); + status = { phase: "expired" }; + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(native.start).toHaveBeenCalledTimes(2); + expect(native.start.mock.calls[1]).toEqual([ + expect.any(String), + viewer, + "https://community.example", + ]); + status = { phase: "complete" }; + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + await act(async () => { + fireEvent.click( + screen.getByRole("button", { name: "Pair another phone" }), + ); + }); + expect(native.start).toHaveBeenCalledTimes(3); + expect(native.start.mock.calls[2]).toEqual([ + expect.any(String), + viewer, + "https://community.example", + ]); + }, +); + +it.each(["uncertain", "cancelled"] as const)( + "keeps %s terminal until a deliberate new attempt", + async (phase) => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); + const viewer = "a".repeat(64); + let snapshot: ClientSnapshot = { + status: "ready", + relayAvailable: true, + viewer, + selected: "https://community.example", + profile: { name: "", picture: "" }, + memberships: [], + }; + let status: PairingStatus = { phase: "idle" }; + const native = { + account: vi.fn(async () => viewer), + start: vi.fn(async () => { + status = { phase: "qr", svg: "" }; + }), + status: vi.fn(async () => status), + cancel: vi.fn( + async (): Promise => ({ phase: "cancelled" }), + ), + confirm: vi.fn(async () => {}), + deny: vi.fn(async () => {}), + } satisfies PairingNative; + let mounted!: ReturnType; + const view = () => ( + snapshot, subscribe: () => () => {} }} + active={() => true} + available + native={native} + /> + ); + await act(async () => { + mounted = render( + snapshot, subscribe: () => () => {} }} + active={() => true} + available + native={native} + />, + ); + }); + expect(native.start).toHaveBeenCalledTimes(1); + status = { phase: "transferring" }; + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + status = { phase }; + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + if (phase === "uncertain") + expect( + screen.getByRole("heading", { name: "Check your phone" }), + ).toBeTruthy(); + expect( + screen.queryByAltText("Scan this QR code with Buzz on your phone"), + ).toBeNull(); + await act(async () => { + await vi.advanceTimersByTimeAsync(240000); + }); + expect(native.start).toHaveBeenCalledTimes(1); + await act(async () => { + fireEvent.click( + screen.getByRole("button", { + name: phase === "uncertain" ? "Start a new pairing" : "Try again", + }), + ); + }); + expect(native.start).toHaveBeenCalledTimes(2); + snapshot = { + ...snapshot, + viewer: "b".repeat(64), + selected: "https://other.example", + }; + await act(async () => { + mounted.rerender(view()); + }); + expect(native.start).toHaveBeenCalledTimes(3); + expect(native.start.mock.calls[2]).toEqual([ + expect.any(String), + "b".repeat(64), + "https://other.example", + ]); + }, +); + +it("rejects a legacy mismatch and requires explicit retry", async () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); + const viewer = "a".repeat(64); + const snapshot: ClientSnapshot = { + status: "ready", + relayAvailable: true, + viewer, + selected: "https://community.example", + profile: { name: "", picture: "" }, + memberships: [], + }; + let status: PairingStatus = { + phase: "code", + code: "123456", + codeEntry: false, + }; + const native = { + account: vi.fn(async () => viewer), + start: vi.fn(async () => {}), + status: vi.fn(async () => status), + confirm: vi.fn(async () => {}), + deny: vi.fn(async () => { + status = { phase: "error", message: "Codes did not match." }; + }), + cancel: vi.fn(async (): Promise => ({ phase: "cancelled" })), + } satisfies PairingNative; + render( + snapshot, subscribe: () => () => {} }} + active={() => true} + available + native={native} + />, + ); + await act(async () => {}); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await vi.advanceTimersByTimeAsync(400); + }); + expect(native.deny).toHaveBeenCalledTimes(1); + expect(native.confirm).not.toHaveBeenCalled(); + expect(screen.getByRole("alert").textContent).toContain( + "Codes did not match", + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(240000); + }); + expect(native.start).toHaveBeenCalledTimes(1); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + }); + expect(native.start).toHaveBeenCalledTimes(2); +}); + +it("keeps rejection progress truthful until native denial finishes", async () => { + vi.useFakeTimers(); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); + const viewer = "a".repeat(64); + const snapshot: ClientSnapshot = { + status: "ready", + relayAvailable: true, + viewer, + selected: "https://community.example", + profile: { name: "", picture: "" }, + memberships: [], + }; + let status: PairingStatus = { + phase: "code", + code: "123456", + codeEntry: false, + }; + const native = { + account: vi.fn(async () => viewer), + start: vi.fn(async () => {}), + status: vi.fn(async () => status), + confirm: vi.fn(async () => {}), + deny: vi.fn(async () => {}), + cancel: vi.fn(async (): Promise => ({ phase: "cancelled" })), + } satisfies PairingNative; + render( + snapshot, subscribe: () => () => {} }} + active={() => true} + available + native={native} + />, + ); + try { + await act(async () => {}); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + }); + expect(native.deny).toHaveBeenCalledTimes(1); + expect(screen.getByRole("status").textContent).toContain( + "Cancelling pairing", + ); + expect(screen.queryByText("Creating pairing code…")).toBeNull(); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(native.status).toHaveBeenCalledTimes(2); + expect(screen.getByRole("status").textContent).toContain( + "Cancelling pairing", + ); + expect(screen.queryByRole("button", { name: "Codes match" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + } finally { + status = { phase: "error", message: "Pairing was canceled." }; + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(screen.getByRole("alert").textContent).toContain( + "Pairing was canceled.", + ); + } +}); + +it.each([ + ["cancelled", "Pairing was canceled.", "Try again"], + ["uncertain", "Check your phone", "Start a new pairing"], +] as const)( + "explains code-entry %s cancellation before retry", + async (outcome, text, retry) => { + vi.stubGlobal("matchMedia", () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); + const viewer = "a".repeat(64); + const snapshot: ClientSnapshot = { + status: "ready", + relayAvailable: true, + viewer, + selected: "https://community.example", + profile: { name: "", picture: "" }, + memberships: [], + }; + const native = { + account: vi.fn(async () => viewer), + start: vi.fn(async () => {}), + status: vi.fn( + async (): Promise => ({ + phase: "code", + code: "123456", + codeEntry: true, + }), + ), + confirm: vi.fn(async () => {}), + deny: vi.fn(async () => {}), + cancel: vi.fn(async (): Promise => ({ phase: outcome })), + } satisfies PairingNative; + render( + snapshot, subscribe: () => () => {} }} + active={() => true} + available + native={native} + />, + ); + await act(async () => {}); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + }); + expect(native.cancel).toHaveBeenCalledTimes(1); + expect(screen.getByRole("status").textContent).toContain(text); + expect(screen.getByRole("button", { name: retry })).toBeTruthy(); + }, +); diff --git a/src/bundled/pairing/PairingSettings.tsx b/src/bundled/pairing/PairingSettings.tsx new file mode 100644 index 000000000..371893cc4 --- /dev/null +++ b/src/bundled/pairing/PairingSettings.tsx @@ -0,0 +1,401 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + CheckIcon as Check, + CircleNotchIcon as LoaderCircle, +} from "../../shared/design-system/icons"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Input } from "../../shared/design-system/ui/Input"; +import type { CommunityReader } from "../../features/communities/service"; +export type PairingSettingsProps = { + communities: CommunityReader; + active(): boolean; +}; +import { + communityDestination, + relayOrigin, +} from "../../features/communities/destination"; +import { + createPairingClient, + nativePairing, + pairingAvailable, + type PairingNative, +} from "../../features/pairing/client"; + +export function PairingSettings({ + communities, + active, + native = nativePairing, + available = pairingAvailable(), +}: PairingSettingsProps & { native?: PairingNative; available?: boolean }) { + const [reducedMotion, setReducedMotion] = useState( + () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, + ); + useEffect(() => { + const media = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReducedMotion(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + const client = useSyncExternalStore( + communities.subscribe, + communities.snapshot, + ); + const pairing = useMemo(() => createPairingClient(native), [native]); + const state = useSyncExternalStore(pairing.subscribe, pairing.snapshot); + const [account, setAccount] = useState(); + const [manualOrigin, setManualOrigin] = useState(); + const [reading, setReading] = useState(false); + const [error, setError] = useState(); + const [relay, setRelay] = useState(() => + client.selected ? communityDestination(client.selected).url : "", + ); + const lifetime = useRef(0); + useEffect(() => { + lifetime.current++; + return () => { + lifetime.current++; + void pairing.cancel(true); + }; + }, [pairing]); + // Changing accounts/communities invalidates the pairing being displayed. + useEffect(() => { + lifetime.current++; + setReading(false); + setManualOrigin(undefined); + setAccount(client.viewer); + setRelay(client.selected ? communityDestination(client.selected).url : ""); + return () => { + void pairing.cancel(true); + }; + }, [client.viewer, client.selected, pairing]); + const busy = [ + "connecting", + "qr", + "code", + "transferring", + "cancelling", + ].includes(state.phase); + async function prepare() { + const attempt = lifetime.current; + setError(undefined); + setReading(true); + try { + const viewer = await native.account(); + if (attempt !== lifetime.current) return; + if (client.viewer && viewer !== client.viewer) + throw new Error( + "Your saved Buzz account doesn’t match this account. Open the matching account before pairing.", + ); + setAccount(viewer); + } catch (error) { + if (attempt === lifetime.current) setError(String(error)); + } finally { + if (attempt === lifetime.current) setReading(false); + } + } + function start() { + setError(undefined); + try { + if (!account || !active()) return; + const origin = relayOrigin(relay); + if (!client.selected) setManualOrigin(origin); + void pairing.start(account, origin); + } catch { + setError("Enter your community’s https:// or wss:// address."); + } + } + useEffect(() => { + if ( + !available || + !account || + (!client.selected && !manualOrigin) || + !active() || + (client.viewer && account !== client.viewer) || + (client.selected && + relay !== communityDestination(client.selected).url) || + !["idle", "expired"].includes(state.phase) + ) + return; + const origin = client.selected ? relayOrigin(relay) : manualOrigin; + if (origin) void pairing.start(account, origin); + }, [ + available, + account, + client.viewer, + client.selected, + manualOrigin, + active, + relay, + state.phase, + pairing, + ]); + const scanned = ["code", "transferring", "complete", "uncertain"].includes( + state.phase, + ); + const confirmed = ["transferring", "complete"].includes(state.phase); + const done = state.phase === "complete"; + const legacy = state.phase === "code" && !state.codeEntry; + const steps = [ + { + id: "scan", + title: "Scan QR code", + detail: + "Open Buzz on your phone and choose Add community to scan the code shown here.", + complete: scanned, + }, + { + id: "verify", + title: legacy ? "Confirm mobile code" : "Enter code on your phone", + detail: legacy + ? "This phone uses code comparison. Check that both devices show the same six digits." + : "After scanning, enter the six-digit code shown on this desktop into your phone.", + complete: confirmed, + }, + { + id: "finish", + title: done ? "Paired" : "Pair your mobile app", + detail: done + ? "Your account was sent. Check that it’s signed in on your phone." + : "Your mobile app will connect after you verify the code.", + complete: done, + }, + ]; + return ( +
+

+ Pair mobile +

+

+ Connect the Buzz mobile app to this community. Pairing is secured with + end-to-end encryption and a verification code. +

+
+

+ {state.phase === "code" + ? `Verification code ${state.code.split("").join(" ")}. ${legacy ? "Confirm the matching code." : "Enter this code on your phone."}` + : done + ? "Your phone is paired." + : ""} +

+
+
+
+ {!available ? ( +

+ {isTauri() + ? "Pairing uses this app’s native identity and is unavailable with the development broker. Restart with BUZZ_DEV_VIEWER empty to use native sign-in." + : "Open the Buzz desktop app to pair your phone."} +

+ ) : ( + <> + {!busy && + !done && + state.phase !== "uncertain" && + (!account ? ( + + ) : ( + <> + {!client.selected && ( +
+ + setRelay(e.target.value)} + onBlur={() => { + if (relay.trim()) start(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && relay.trim()) start(); + }} + autoComplete="off" + spellCheck={false} + /> +
+ )} + {["error", "cancelled"].includes(state.phase) && ( + + )} + + ))} + {state.phase === "connecting" && ( +
+
+ )} + {state.phase === "qr" && ( + [\s\S]*?<\/style>/g, "") : state.svg)}`} + alt="Scan this QR code with Buzz on your phone" + width={240} + height={240} + /> + )} + {state.phase === "code" && ( +
+

+ {legacy + ? "Check the code on your phone" + : "Enter this code on your phone"} +

+
+ Pairing code + + {state.code.split("").join(" ")} + + +
+
+ {legacy ? ( + + ) : ( +

+ Pairing continues when you enter all six digits on + your phone. +

+ )} + +
+
+ )} + {state.phase === "cancelling" && ( +

+ Cancelling pairing… +

+ )} + {state.phase === "cancelled" && ( +

+ Pairing was canceled. +

+ )} + {state.phase === "transferring" && ( +
+
+ )} + {state.phase === "uncertain" && ( +
+

Check your phone

+

+ The account was sent, but your phone hasn’t confirmed. + It may already be paired. +

+ +
+ )} + {done && ( +
+
+ )} + {(error || state.phase === "error") && ( +
+

+ {error ?? + (state.phase === "error" ? state.message : "")} +

+
+ )} + + )} +
+
+
    + {steps.map((step, index) => ( +
  1. + +
    +

    + {step.title} + + {step.complete ? ", complete" : ""} + +

    +

    + {step.detail} +

    +
    +
  2. + ))} +
+
+
+
+ ); +} diff --git a/src/bundled/pairing/index.tsx b/src/bundled/pairing/index.tsx new file mode 100644 index 000000000..f42737571 --- /dev/null +++ b/src/bundled/pairing/index.tsx @@ -0,0 +1,14 @@ +import type { PluginModule } from "../../plugins/api"; +import { PairingSettings } from "./PairingSettings"; +export const inject = ["settingsCards", "communityReader"]; +export const apply: PluginModule["apply"] = (ctx) => { + const communities = ctx.communityReader; + ctx.settingsCards.register({ + id: "mobile", + title: "Pair mobile", + group: "Account", + component: ({ active }) => ( + + ), + }); +}; diff --git a/src/bundled/pairing/manifest.json b/src/bundled/pairing/manifest.json new file mode 100644 index 000000000..91944df39 --- /dev/null +++ b/src/bundled/pairing/manifest.json @@ -0,0 +1 @@ +{ "id": "buzz.pairing", "name": "Pair mobile", "apiVersion": 1 } diff --git a/src/features/pairing/client.test.ts b/src/features/pairing/client.test.ts new file mode 100644 index 000000000..b2123890b --- /dev/null +++ b/src/features/pairing/client.test.ts @@ -0,0 +1,206 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { + createPairingClient, + type PairingNative, + type PairingStatus, +} from "./client"; +function native() { + return { + account: vi.fn(async () => "a".repeat(64)), + start: vi.fn(async () => {}), + status: vi.fn( + async (): Promise => ({ phase: "qr", svg: "fixture" }), + ), + confirm: vi.fn(async () => {}), + deny: vi.fn(async () => {}), + cancel: vi.fn(async (): Promise => ({ phase: "cancelled" })), + } satisfies PairingNative; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +afterEach(() => vi.useRealTimers()); +it("cancels a late native start without displaying its QR or polling", async () => { + const api = native(); + const ready = deferred(); + api.start.mockReturnValue(ready.promise); + const client = createPairingClient(api); + const start = client.start("viewer", "https://relay.test"); + await Promise.resolve(); + const cancel = client.cancel(); + ready.resolve(); + await Promise.all([start, cancel]); + expect(api.cancel).toHaveBeenCalledWith(api.start.mock.calls[0]?.[0]); + expect(api.status).not.toHaveBeenCalled(); + expect(client.snapshot().phase).toBe("cancelled"); +}); +it("drops late status after cancel and stops polling", async () => { + vi.useFakeTimers(); + const api = native(); + const late = deferred(); + api.status.mockReturnValue(late.promise); + const client = createPairingClient(api); + const start = client.start("viewer", "https://relay.test"); + await vi.waitFor(() => expect(api.status).toHaveBeenCalled()); + await client.cancel(); + late.resolve({ phase: "complete" }); + await start; + await vi.advanceTimersByTimeAsync(1000); + expect(client.snapshot().phase).toBe("cancelled"); + expect(api.status).toHaveBeenCalledTimes(1); +}); +it("displays the native outcome when cancel wins the poll gap after transfer", async () => { + vi.useFakeTimers(); + const api = native(); + api.status.mockResolvedValue({ + phase: "code", + code: "123456", + codeEntry: true, + }); + const client = createPairingClient(api); + await client.start("viewer", "https://relay.test"); + const stopped = deferred(); + api.cancel.mockReturnValue(stopped.promise); + const cancel = client.cancel(); + expect(client.snapshot().phase).toBe("cancelling"); + stopped.resolve({ phase: "uncertain" }); + await cancel; + expect(client.snapshot().phase).toBe("uncertain"); + await vi.advanceTimersByTimeAsync(1000); + expect(api.status).toHaveBeenCalledTimes(1); +}); +it("allows desktop confirmation only for a legacy phone", async () => { + const api = native(); + const client = createPairingClient(api); + api.status.mockResolvedValue({ + phase: "code", + code: "001234", + codeEntry: true, + }); + await client.start("viewer", "https://relay.test"); + await client.confirm(); + expect(api.confirm).not.toHaveBeenCalled(); + api.status.mockResolvedValue({ + phase: "code", + code: "001234", + codeEntry: false, + }); + await client.start("viewer", "https://relay.test"); + await client.confirm(); + expect(api.confirm).toHaveBeenCalledTimes(1); + await client.cancel(); +}); +it("waits for cancellation before creating a replacement", async () => { + const api = native(); + const client = createPairingClient(api); + await client.start("viewer", "https://relay.test"); + const stopped = deferred(); + api.cancel.mockReturnValue(stopped.promise); + const replacement = client.start("viewer", "https://other.test"); + await Promise.resolve(); + expect(api.start).toHaveBeenCalledTimes(1); + stopped.resolve({ phase: "cancelled" }); + await replacement; + expect(api.start).toHaveBeenCalledTimes(2); + await client.cancel(); +}); +it("reports cleanup failures instead of starting another live session", async () => { + const api = native(); + const client = createPairingClient(api); + await client.start("viewer", "https://relay.test"); + api.cancel.mockRejectedValue(new Error("cancel unavailable")); + await client.start("viewer", "https://other.test"); + expect(api.start).toHaveBeenCalledTimes(1); + expect(client.snapshot().phase).toBe("error"); +}); + +it("does not revive legacy confirmation from a status response already in flight", async () => { + vi.useFakeTimers(); + const api = native(); + api.status.mockResolvedValue({ + phase: "code", + code: "001234", + codeEntry: false, + }); + const client = createPairingClient(api); + await client.start("viewer", "https://relay.test"); + const late = deferred(); + api.status.mockReturnValue(late.promise); + await vi.advanceTimersByTimeAsync(400); + await client.confirm(); + late.resolve({ phase: "code", code: "001234", codeEntry: false }); + await Promise.resolve(); + expect(client.snapshot().phase).toBe("transferring"); + await client.confirm(); + expect(api.confirm).toHaveBeenCalledTimes(1); + await client.cancel(); +}); + +it("resets client-controlled cleanup without treating native cancellation as idle", async () => { + const api = native(); + const client = createPairingClient(api); + api.status.mockResolvedValue({ phase: "cancelled" }); + await client.start("viewer", "https://relay.test"); + expect(client.snapshot().phase).toBe("cancelled"); + await client.cancel(true); + expect(client.snapshot().phase).toBe("idle"); + api.cancel.mockRejectedValue(new Error("cleanup failed")); + await client.start("viewer", "https://relay.test"); + await client.cancel(true); + expect(client.snapshot().phase).toBe("error"); +}); + +it("denies only a live legacy comparison", async () => { + const api = native(); + const client = createPairingClient(api); + api.status.mockResolvedValue({ + phase: "code", + code: "123456", + codeEntry: true, + }); + await client.start("viewer", "https://relay.test"); + await client.deny(); + expect(api.deny).not.toHaveBeenCalled(); + api.status.mockResolvedValue({ + phase: "code", + code: "123456", + codeEntry: false, + }); + await client.start("viewer", "https://relay.test"); + await client.deny(); + expect(api.deny).toHaveBeenCalledTimes(1); + expect(client.snapshot().phase).toBe("cancelling"); + await client.cancel(); +}); + +it("does not revive a decision while native denial is pending after IPC returns", async () => { + vi.useFakeTimers(); + const api = native(); + let status: PairingStatus = { + phase: "code", + code: "123456", + codeEntry: false, + }; + api.status.mockImplementation(async () => status); + const client = createPairingClient(api); + await client.start("viewer", "https://relay.test"); + try { + await client.deny(); // Native enqueues Deny immediately; abort delivery is still pending. + expect(api.deny).toHaveBeenCalledTimes(1); + expect(client.snapshot().phase).toBe("cancelling"); + await vi.advanceTimersByTimeAsync(800); + expect(api.status).toHaveBeenCalledTimes(3); + expect(client.snapshot().phase).toBe("cancelling"); + await client.confirm(); + expect(api.confirm).not.toHaveBeenCalled(); + status = { phase: "error", message: "Pairing was canceled." }; + await vi.advanceTimersByTimeAsync(400); + expect(client.snapshot()).toEqual(status); + } finally { + await client.cancel(); + } +}); diff --git a/src/features/pairing/client.ts b/src/features/pairing/client.ts new file mode 100644 index 000000000..bed75ddc0 --- /dev/null +++ b/src/features/pairing/client.ts @@ -0,0 +1,177 @@ +import { invoke } from "@tauri-apps/api/core"; +import { nativeIdentityEnabled } from "../identity/service"; +export type PairingStatus = + | { + phase: + | "uncertain" + | "expired" + | "idle" + | "connecting" + | "transferring" + | "cancelling" + | "complete" + | "cancelled"; + } + | { phase: "qr"; svg: string } + | { phase: "code"; code: string; codeEntry: boolean } + | { phase: "error"; message: string }; +export type PairingNative = { + account(): Promise; + start(id: string, viewer: string, community: string): Promise; + status(id: string): Promise; + confirm(id: string): Promise; + deny(id: string): Promise; + cancel(id: string): Promise; +}; +export const nativePairing: PairingNative = { + account: () => invoke("pairing_account"), + start: (id, viewer, community) => + invoke("pairing_start", { id, viewer, community }), + status: (id) => invoke("pairing_status", { id }), + confirm: (id) => invoke("pairing_confirm", { id }), + deny: (id) => invoke("pairing_deny", { id }), + cancel: (id) => invoke("pairing_cancel", { id }), +}; +export const pairingAvailable = nativeIdentityEnabled; + +export function createPairingClient(native: PairingNative = nativePairing) { + let state: PairingStatus = { phase: "idle" }; + const listeners = new Set<() => void>(); + let generation = 0; + let active: + | { + id: string; + started: Promise; + deciding?: boolean; + timer?: ReturnType; + } + | undefined; + let cleanup: Promise = Promise.resolve(undefined); + function update(next: PairingStatus) { + state = next; + for (const listener of listeners) listener(); + } + function stop() { + const previous = active; + active = undefined; + if (previous) { + clearTimeout(previous.timer); + // A late start must finish registering before cancellation. Cleanup failures + // remain visible and block a replacement instead of abandoning a live session. + cleanup = previous.started + .catch(() => {}) + .then(() => native.cancel(previous.id)); + } + return cleanup; + } + return { + snapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async start(viewer: string, community: string) { + const attempt = ++generation; + update({ phase: "connecting" }); + try { + await stop(); + if (attempt !== generation) return; + const id = crypto.randomUUID(); + const session: NonNullable = { + id, + started: native.start(id, viewer, community), + }; + active = session; + await session.started; + const poll = async () => { + try { + const status = await native.status(id); + if (active !== session || attempt !== generation) return; + if ( + !( + (session.deciding || + state.phase === "transferring" || + state.phase === "cancelling") && + (status.phase === "code" || status.phase === "transferring") + ) + ) + update(status); + if ( + ["connecting", "qr", "code", "transferring"].includes( + status.phase, + ) + ) + active.timer = setTimeout(() => void poll(), 400); + } catch { + if (active !== session) return; + const cancelledGeneration = generation + 1; + await this.cancel(); + if ( + generation !== cancelledGeneration || + state.phase !== "cancelled" + ) + return; + update({ + phase: "error", + message: "Couldn’t check pairing. Try again.", + }); + } + }; + if (active === session && attempt === generation) await poll(); + } catch (error) { + if (attempt === generation) + update({ phase: "error", message: String(error) }); + } + }, + async decide(kind: "confirm" | "deny") { + const session = active; + if ( + !session || + state.phase !== "code" || + state.codeEntry || + session.deciding + ) + return; + session.deciding = true; + // Do not invent a new pairing operation while an abort is pending. + update({ phase: kind === "confirm" ? "transferring" : "cancelling" }); + try { + await native[kind](session.id); + } catch (error) { + if (active === session) + update({ phase: "error", message: String(error) }); + } finally { + session.deciding = false; + } + }, + async confirm() { + return this.decide("confirm"); + }, + async deny() { + return this.decide("deny"); + }, + async cancel(reset = false) { + const attempt = ++generation; + // Native reports whether the account may already have been sent. + update({ phase: "cancelling" }); + const stopping = active; + try { + const outcome = await stop(); + if (attempt !== generation) return; + // Without a live session, an earlier cleanup result is not this outcome. + if (reset || !stopping || !outcome) + update({ phase: reset ? "idle" : "cancelled" }); + else update(outcome); + } catch { + if (attempt === generation) + update({ + phase: "error", + message: + "Couldn’t cancel pairing. Close this window before trying again.", + }); + } + }, + }; +} diff --git a/tests/browser/build.mjs b/tests/browser/build.mjs index b9a6f55c2..f23d82c25 100644 --- a/tests/browser/build.mjs +++ b/tests/browser/build.mjs @@ -11,7 +11,13 @@ const root = fileURLToPath(new URL("../../", import.meta.url)); // Playwright owns this worker-scoped build. Only compiled assets are shared; // each test still owns its server, identities, relay state and browser storage. export async function buildApp( - { developmentReact, pluginFixtures, companionFixture, agentManagement }, + { + developmentReact, + pluginFixtures, + companionFixture, + agentManagement, + pairingFixture, + }, use, ) { const directory = await mkdtemp(join(tmpdir(), "buzz-browser-build-")); @@ -24,6 +30,21 @@ export async function buildApp( logLevel: "error", plugins: [ react(), + ...(pairingFixture + ? [ + { + name: "pairing-fixture", + transform(code, id) { + if (id !== join(root, "src/bundled/pairing/index.tsx")) + return; + return code.replace( + 'import { PairingSettings } from "./PairingSettings";', + `import { PairingFixture as PairingSettings } from ${JSON.stringify(join(root, "tests/browser/pairing-fixture.tsx"))};`, + ); + }, + }, + ] + : []), ...(pluginFixtures || companionFixture ? [ { diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index c012e5418..97b44a5af 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -77,6 +77,7 @@ export const test = base.extend({ developmentReact: [false, { option: true, scope: "worker" }], pluginFixtures: [false, { option: true, scope: "worker" }], agentManagement: [false, { option: true, scope: "worker" }], + pairingFixture: [false, { option: true, scope: "worker" }], companionFixture: [false, { option: true, scope: "worker" }], compiledApp: [buildApp, { scope: "worker" }], app: async ( diff --git a/tests/browser/new-message.spec.mjs b/tests/browser/new-message.spec.mjs index b74a1c2a2..683a0ff93 100644 --- a/tests/browser/new-message.spec.mjs +++ b/tests/browser/new-message.spec.mjs @@ -16,6 +16,7 @@ const test = base.extend({ pluginFixtures: [false, { scope: "worker" }], agentManagement: [false, { scope: "worker" }], companionFixture: [false, { scope: "worker" }], + pairingFixture: [false, { scope: "worker" }], compiledApp: [buildApp, { scope: "worker" }], app: async ({ compiledApp, page, context }, use) => { const key = generateSecretKey(), diff --git a/tests/browser/pairing-fixture.tsx b/tests/browser/pairing-fixture.tsx new file mode 100644 index 000000000..df24d4f96 --- /dev/null +++ b/tests/browser/pairing-fixture.tsx @@ -0,0 +1,51 @@ +import { PairingSettings } from "../../src/bundled/pairing/PairingSettings"; +import type { PairingSettingsProps } from "../../src/bundled/pairing/PairingSettings"; +import type { + PairingNative, + PairingStatus, +} from "../../src/features/pairing/client"; +const fixture = { + calls: [] as { action: string; id?: string }[], + status: { phase: "connecting" } as PairingStatus, + delay: false, + release: undefined as (() => void) | undefined, +}; +Object.assign(window, { pairingFixture: fixture }); +const native: PairingNative = { + async account() { + return "f".repeat(64); + }, + async start(id) { + fixture.calls.push({ action: "start", id }); + if (fixture.delay) + await new Promise((resolve) => { + fixture.release = resolve; + }); + fixture.status = { + phase: "qr", + svg: 'Fixture QR — not scannable', + }; + }, + async status() { + return fixture.status; + }, + async confirm(id) { + fixture.calls.push({ action: "confirm", id }); + fixture.status = { phase: "transferring" }; + }, + async deny(id) { + fixture.calls.push({ action: "deny", id }); + fixture.status = { phase: "cancelled" }; + }, + async cancel(id) { + fixture.calls.push({ action: "cancel", id }); + if (fixture.status.phase === "transferring") + fixture.status = { phase: "uncertain" }; + else if (!["uncertain", "complete", "error"].includes(fixture.status.phase)) + fixture.status = { phase: "cancelled" }; + return fixture.status; + }, +}; +export function PairingFixture(props: PairingSettingsProps) { + return ; +} diff --git a/tests/browser/pairing.spec.mjs b/tests/browser/pairing.spec.mjs new file mode 100644 index 000000000..d0086633d --- /dev/null +++ b/tests/browser/pairing.spec.mjs @@ -0,0 +1,254 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; +test.use({ pairingFixture: true }); +async function settings(page, app) { + await open(page, app); + await page.getByRole("button", { name: "Your profile", exact: true }).click(); + await page.getByRole("menuitem", { name: "Settings", exact: true }).click(); + await page + .getByRole("complementary", { name: "Settings sidebar" }) + .getByRole("button", { name: "Pair mobile", exact: true }) + .click(); +} +const phone = (page, status) => + page.evaluate((status) => { + window.pairingFixture.status = status; + }, status); +test("code entry replaces QR, preserves leading zeros, and waits for phone completion", async ({ + page, + app, +}) => { + await settings(page, app); + await expect( + page.getByAltText("Scan this QR code with Buzz on your phone"), + ).toBeVisible(); + await phone(page, { phase: "code", code: "001234", codeEntry: true }); + await expect( + page.getByRole("heading", { name: "Enter this code on your phone" }), + ).toBeVisible(); + await expect(page.getByText("001 234", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Codes match" })).toHaveCount( + 0, + ); + await expect(page.getByRole("group", { name: "Pairing code" })).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("pairing-code.png") }); + await page.setViewportSize({ width: 800, height: 800 }); + await expect(page.getByText("001 234", { exact: true })).toBeVisible(); + const code = await page + .getByRole("group", { name: "Pairing code" }) + .boundingBox(); + const steps = await page + .getByRole("list", { name: "Pairing steps" }) + .boundingBox(); + expect(steps.y).toBeGreaterThan(code.y + code.height); + await expect( + page.getByRole("region", { name: "Pair mobile", exact: true }), + ).toBeInViewport(); + await page.screenshot({ + path: test.info().outputPath("pairing-code-narrow.png"), + }); + await page.setViewportSize({ width: 1440, height: 950 }); + await phone(page, { phase: "transferring" }); + await expect( + page.getByText("Finishing pairing on your phone…"), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Phone paired" })).toHaveCount( + 0, + ); + await phone(page, { phase: "complete" }); + await expect( + page.getByRole("heading", { name: "Phone paired" }), + ).toBeVisible(); +}); +test("legacy phone confirmation, retry after expiry, and leaving Settings cancel the session", async ({ + page, + app, +}) => { + await settings(page, app); + await expect( + page.getByAltText("Scan this QR code with Buzz on your phone"), + ).toBeVisible(); + await phone(page, { phase: "code", code: "654321", codeEntry: false }); + await page.getByRole("button", { name: "Codes match", exact: true }).click(); + await expect + .poll(() => + page.evaluate( + () => + window.pairingFixture.calls.filter((c) => c.action === "confirm") + .length, + ), + ) + .toBe(1); + await phone(page, { + phase: "error", + message: "Pairing expired. Create a new code and try again.", + }); + await expect(page.getByRole("alert")).toContainText("Pairing expired"); + await page.getByRole("button", { name: "Try again", exact: true }).click(); + await expect( + page.getByAltText("Scan this QR code with Buzz on your phone"), + ).toBeVisible(); + await page + .getByRole("complementary", { name: "Settings sidebar" }) + .getByRole("button", { name: "Plugins", exact: true }) + .click(); + await expect + .poll(() => page.evaluate(() => window.pairingFixture.calls.at(-1)?.action)) + .toBe("cancel"); + await page + .getByRole("switch", { name: "Enable Pair mobile", exact: true }) + .click(); + await expect( + page + .getByRole("complementary", { name: "Settings sidebar" }) + .getByRole("button", { name: "Pair mobile", exact: true }), + ).toHaveCount(0); +}); +test("leaving during native setup cancels its late result", async ({ + page, + app, +}) => { + await open(page, app); + await page.evaluate(() => { + window.pairingFixture.delay = true; + }); + await page.getByRole("button", { name: "Your profile", exact: true }).click(); + await page.getByRole("menuitem", { name: "Settings", exact: true }).click(); + const sections = page.getByRole("complementary", { + name: "Settings sidebar", + }); + await sections + .getByRole("button", { name: "Pair mobile", exact: true }) + .click(); + await expect + .poll(() => page.evaluate(() => !!window.pairingFixture.release)) + .toBe(true); + await sections + .getByRole("button", { name: "Appearance", exact: true }) + .click(); + await page.evaluate(() => { + window.pairingFixture.delay = false; + window.pairingFixture.release(); + }); + await expect + .poll(() => page.evaluate(() => window.pairingFixture.calls.at(-1)?.action)) + .toBe("cancel"); + await sections + .getByRole("button", { name: "Pair mobile", exact: true }) + .click(); + await expect( + page.getByAltText("Scan this QR code with Buzz on your phone"), + ).toBeVisible(); +}); + +test("reloading a contributed Settings route waits for its plugin to activate", async ({ + page, + app, +}) => { + await settings(page, app); + await expect( + page.getByAltText("Scan this QR code with Buzz on your phone"), + ).toBeVisible(); + await page.reload(); + await expect( + page.getByRole("heading", { name: "Pair mobile", exact: true }), + ).toBeVisible(); + await expect( + page.getByAltText("Scan this QR code with Buzz on your phone"), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Pair mobile", exact: true }), + ).toHaveCount(1); +}); + +test("expired QR refreshes automatically and leaving stops it", async ({ + page, + app, +}) => { + await settings(page, app); + await expect( + page.getByAltText("Scan this QR code with Buzz on your phone"), + ).toBeVisible(); + const starts = () => + page.evaluate( + () => + window.pairingFixture.calls.filter((c) => c.action === "start").length, + ); + const before = await starts(); + await phone(page, { phase: "expired" }); + await expect.poll(starts).toBe(before + 1); + await expect( + page.getByAltText("Scan this QR code with Buzz on your phone"), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Cancel pairing", exact: true }), + ).toHaveCount(0); + await page + .getByRole("complementary", { name: "Settings sidebar" }) + .getByRole("button", { name: "Appearance", exact: true }) + .click(); + await expect + .poll(() => page.evaluate(() => window.pairingFixture.calls.at(-1)?.action)) + .toBe("cancel"); + expect(await starts()).toBe(before + 1); +}); + +test("pairing positions stay fixed across states", async ({ page, app }) => { + await settings(page, app); + for (const width of [1440, 800]) { + await page.setViewportSize({ width, height: 1000 }); + await phone(page, { phase: "connecting" }); + await expect(page.getByText("Creating pairing code…")).toBeVisible(); + const steps = page.getByRole("list", { name: "Pairing steps" }); + const before = await steps.boundingBox(); + await phone(page, { phase: "code", code: "001234", codeEntry: true }); + await expect(page.getByText("001 234", { exact: true })).toBeVisible(); + expect(await steps.boundingBox()).toEqual(before); + await phone(page, { phase: "transferring" }); + await expect( + page.getByText("Finishing pairing on your phone…"), + ).toBeVisible(); + expect(await steps.boundingBox()).toEqual(before); + } +}); + +test("reduced motion removes QR animation styles", async ({ page, app }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await settings(page, app); + await phone(page, { + phase: "qr", + svg: '', + }); + const qr = page.getByAltText("Scan this QR code with Buzz on your phone"); + await expect + .poll(async () => decodeURIComponent(await qr.getAttribute("src"))) + .not.toContain("