block / block/buzz

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

Open
#3,181 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
32.7k
Forks
4.3k
Avg merge
1d 13h
Merged PRs (30d)
253

Description

## 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`:

```dart
_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`:

```rust
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:

```dart
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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.