Skip to content

Pairing: source completes the full SAS ceremony before the target validates the payload, and complete(false) carries no reason #3181

Open

Description

@Starlord22

Summary

Desktop pairing runs the entire NIP-AB ceremony — QR, ECDH, SAS confirmation on both
devices — before the target ever inspects the credential payload. When the target then
rejects the payload locally (its own SSRF/scheme guard), the source shows only
"Mobile device reported failure importing credentials", with no indication of what was
wrong or which side rejected it.

Two independent gaps:

  1. No pre-flight validation on the source. The source builds the payload from the
    active workspace relay URL and never checks it against the rules the target
    enforces. A workspace whose relay is http://localhost:3000 produces a payload the
    target can never accept, but the QR still renders and the ceremony still runs to
    completion.
  2. complete(false) carries no reason. The target knows exactly why it refused
    (a FormatException with a specific message) and discards it.

Impact

The failure appears at the last possible moment, after both users have compared and
confirmed SAS codes, and the error text points at the phone ("mobile device reported
failure") when the actual defect is the desktop's own configuration. This is a
maximally expensive place to fail and a misleading direction to send the user.

It reproduces easily on any self-hosted relay: it is a normal state to have both a
local dev community and a public one configured in the desktop, and nothing in the
pairing UI indicates that the active workspace determines the payload.

Reproduction

  1. Self-hosted relay reachable at a public HTTPS URL, closed mode (NIP-43), with a
    pairing sidecar advertised via NIP-11 pairing_relay_url.
  2. In the desktop, have two communities configured; leave the local one
    (ws://localhost:3000, e.g. over an SSH tunnel) as the active workspace.
  3. Settings → Mobile → Pair. Scan the QR on iOS.
  4. SAS codes appear on both devices, match, and are confirmed on both.
  5. Desktop: "Mobile device reported failure importing credentials."

What the server observed

This is the part that may be useful — the server-side evidence isolates the failure
precisely.

Pairing sidecar (buzz-pair-relay) — both peers connect and close cleanly, so the
NIP-AB transport and the whole handshake including SAS completed:

18:01:45  conn opened conn_id=4 active=1     ← desktop (source)
18:02:02  conn opened conn_id=5 active=2     ← phone (target)
18:02:27  conn closed conn_id=5 active=1
18:02:27  conn closed conn_id=4 active=0

Main relay journal for the same window (18:01:00–18:06:00): completely empty. The
target never opened a connection to the relay at all.

That silence localises the fault: the target failed before the network step, i.e. in
_validateRelayUrl, not in _validateCredentials.

Code references

mobile/lib/features/pairing/pairing_provider.dart — _processPayload:

_validateRelayUrl(relayUrl);                                  // local, throws first
await _validateCredentials(relayUrl: relayUrl, nsec: nsec);   // would open a socket

_validateRelayUrl rejects, in release builds, any scheme that is not https, plus
localhost/127.0.0.1/RFC1918 hosts. Correct behaviour — a target must not import a
credential pointing at a private address it cannot verify.

desktop/src-tauri/src/commands/pairing.rs:

let http_url = relay_api_base_url_with_override(&state);   // ACTIVE workspace
…
let payload_json = serde_json::json!({
    "relayUrl": http_url,
    "pubkey": pubkey_hex,
    "nsec": nsec,
});

desktop/src-tauri/src/relay.rs:44-55 — both the ws URL and the HTTP base URL come
from the active workspace override.

Note the asymmetry that makes this confusing: the QR's pairing relay URL does not
come from the workspace — it comes from the relay's NIP-11 pairing_relay_url
(commands/pairing.rs:420-490), which is global. So with a localhost workspace the QR
still points at the correct public sidecar, the phone connects, and everything looks
healthy right up to the import.

The target's error is then discarded:

void _sendComplete(bool success) {
  final content = _encryptMessage({'type': 'complete', 'success': success});
  …
}

Suggested fixes

  1. Pre-validate on the source, before rendering the QR. Apply the target's rules
    (https scheme, non-private host) to the payload's relayUrl in start_pairing and
    fail immediately with something actionable: "Pairing requires a public HTTPS relay.
    The active community is http://localhost:3000 — switch to your public community
    and try again."
  2. Carry the reason in complete. Add an optional reason to the
    {'type': 'complete', 'success': false} message and surface it on the source:
    "Phone rejected the relay URL (must use HTTPS)". The field is inside the NIP-44
    encrypted channel, so it leaks nothing to the relay.
  3. Optional, cosmetic: show the target relay URL under the QR code, so the user
    can see what is about to be transferred.

Item 1 alone removes the failure mode; item 2 makes every other post-SAS rejection
diagnosable instead of guessable.

Environment

  • Relay: self-hosted from block/buzz (commit ab7aa8b plus local loopback-binding
    patches), closed mode, NIP-43 membership enforced
  • Desktop: 0.4.26
  • Mobile: iOS, current App Store build
  • Pairing sidecar: buzz-pair-relay behind Caddy at wss://…/pair

After switching the desktop's active workspace to the public community, the identical
ceremony succeeded on the first try — SAS matched, import completed, phone connected.
No client or relay code was changed between the two attempts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions