Skip to content

Mobile: QR pairing fails at credential import for private/VPN-only relays (relay URL must be HTTPS + public host) #4198

Open

Description

@nitinmotgi

Summary

Buzz Desktop fully supports private, VPN-only relays, and the NIP-AB pairing flow (including the buzz-pair-relay sidecar from #467/#470/#1799) works end-to-end against them. But the mobile app rejects the community credentials at the final import step, making mobile unusable for private-relay deployments.

Environment

  • Self-hosted relay (docker compose, image ghcr.io/block/buzz:sha-3e48f1b), NIP-43 auth enabled
  • Relay reachable only inside a WireGuard tunnel: ws://10.88.0.1:3000
  • buzz-pair-relay sidecar deployed and advertised via NIP-11 pairing_relay_url
  • Mobile: current release build (iOS)

What happens

  1. Desktop shows QR → phone (on the VPN) connects to the pairing sidecar
  2. NIP-AB handshake completes, SAS codes match and confirm
  3. Credential payload transfer starts, then mobile errors:
    Failed to import credentials: FormatException: Relay URL must use HTTPS

Why

mobile/lib/features/pairing/pairing_provider.dart → _validateRelayUrl() requires https scheme in release builds, and _isPrivateHost() rejects RFC1918 addresses. A VPN-only relay URL like ws://10.88.0.1:3000 fails both, with no way to override. mobile/lib/shared/relay/relay_validation.dart enforces the same policy for invites (wss + no non-public addresses).

The SSRF motivation for these checks makes sense for untrusted invite links, but at the end of a NIP-AB pairing the user has just cryptographically verified the source device via SAS — the relay URL comes from their own desktop, not an untrusted third party.

Ask

A supported path for private/VPN-only relays on mobile. Possible shapes:

  • Treat SAS-verified pairing payloads as trusted enough to accept ws:// + private-range relay URLs (perhaps behind an explicit "this is a private relay — connect anyway?" confirmation), or
  • A user-visible setting/build flag to allow private relay origins, or
  • Documented guidance if the intended answer is "front the relay with WSS + public DNS" — including how an existing community keyed by a ws:// URL is supposed to migrate, since docs state the relay URL keys the community and must never change.

Happy to test a patch against our deployment.

Activity

  1. mgoulart commented on Aug 2, 2026

    @mgoulart

    VPN-only relays can pair today — the blocker is the raw ws://<private-IP> URL form, not the VPN. If you front the relay with an HTTPS DNS hostname instead of a bare private-IP ws:// URL, both mobile checks pass and pairing completes end-to-end. Confirmed working just now.

    Setup that works:

    • Self-hosted relay, docker compose, closed (NIP-43, require_relay_membership=true), reachable only inside a Tailscale tailnet.
    • Exposed via tailscale serve (tailnet-only, no funnel), which terminates TLS and gives a real HTTPS hostname with a valid Let's Encrypt cert: https://<host>.<tailnet>.ts.net.
    • Desktop v0.5.3 → mobile pairing via Scan QR: NIP-AB handshake, SAS match, credential import succeeded.

    Why it passes where ws://10.88.0.1:3000 fails: the credential payload's relayUrl is https://<host>.ts.net — the https scheme satisfies _validateRelayUrl(), and the host is a DNS name, not an RFC1918 literal, so _isPrivateHost() doesn't trip. Notably our name resolves to CGNAT 100.64/10 within the tailnet and pairing still succeeded — so _isPrivateHost() appears to gate on the URL's literal host form, not the resolved target IP. That's the crux: the check is really "is the host string a private IP literal," and any DNS+TLS front (tailscale serve, or any reverse proxy with a resolvable name + cert) sidesteps it.

    This doesn't remove the need for the real fix — an explicit "I trust this relay" override at the post-SAS import step, as you describe — but it's a working path for private-relay deployments in the meantime, and it scopes the bug to the raw-private-IP URL form specifically.

  2. nitinmotgi commented on Aug 3, 2026

    @nitinmotgi
    Author

    Thanks @mgoulart — that is a very useful data point, and it narrows the issue nicely.

    The key detail for us is that _isPrivateHost() gates on the URL's literal host string rather than the resolved address: your *.ts.net name resolves into CGNAT 100.64/10 inside the tailnet and still passed. That means any DNS name fronted with a valid TLS cert works regardless of where it actually points, which confirms the bug is scoped to raw private-IP URL literals.

    Our deployment is WireGuard rather than Tailscale, so there is no tailscale serve equivalent to lean on — the analogous setup is a public DNS record pointing at the in-VPN address (relay.example → 10.x.x.x) with a reverse proxy on the relay host terminating TLS via a DNS-01 issued certificate. No public exposure, same shape as yours. We plan to validate that and will report back here with the result either way.

    One thing that keeps this from being a clean workaround for existing deployments rather than new ones: the relay URL keys the community, and the documentation states it must not change. Moving from ws://<private-ip>:3000 to wss://<hostname> is therefore a URL change on an already-established community, and it is not clear from the docs what that implies for existing channels, membership, and history. If a maintainer can confirm whether changing the relay URL re-keys the community — and whether there is a supported migration path — that would be valuable independent of the mobile fix.

    Agreed that the real fix is still an explicit post-SAS trust override at the credential import step. Happy to test a patch against a WireGuard-only relay.

  3. frischeDaten commented on Aug 8, 2026

    @frischeDaten

    The scheme half of this validator also rejects public, HTTPS-reachable relays, not just private/VPN ones — so the blast radius is wider than the title suggests.

    Setup: self-hosted relay, public DNS, HTTPS terminated by a Cloudflare Tunnel. Nothing private, nothing on RFC1918. Mobile still refuses at credential import:

    Failed to import credentials: FormatException: Relay URL must use HTTPS
    

    Cause is _validateRelayUrl requiring the scheme to be exactly https in release builds:

    // mobile/lib/features/pairing/pairing_provider.dart:719
    if (!kDebugMode && uri.scheme != 'https') {
      throw const FormatException('Relay URL must use HTTPS');
    }

    The trap is that wss:// is the natural thing to put in a relay URL — it's what RELAY_URL uses, what the NIP-11 pairing_relay_url field carries, and what every Nostr client expects. A payload carrying relayUrl: "wss://relay.example.com" is rejected outright even though that host is HTTPS-reachable and the derived https:// form would validate fine. Switching the payload to https://relay.example.com made it import successfully.

    Two suggestions, either of which would have saved the debugging round:

    1. Accept wss:///ws:// and normalise to https/http before the private-host checks — the transport scheme carries no security signal the host check doesn't already cover.
    2. Failing that, make the message name the offending value, e.g. Relay URL must use HTTPS (got: wss://…). As written it reads as "your relay isn't using TLS", which is misleading when the relay is HTTPS and only the scheme string differs.

    Separately, worth flagging for anyone landing here from the CLI side: buzz-pair source --nsec sends PayloadType::Nsec (a bare bech32 string), while mobile's _processPayload unconditionally jsonDecodes and reads {relayUrl, pubkey, nsec}. That fails one gate earlier with FormatException: Unexpected character (at character 1). The two gates in order are: envelope shape → URL scheme → NIP-42 credential validation.

  4. xandorklein commented on Sep 7, 2026

    @xandorklein

    Opened a fix PR for this: #7266

    Approach matches the discussion here: after successful NIP-AB SAS, treat the desktop-sourced relayUrl as verified (allow ws/wss/http/https + private/VPN hosts), keep legacy buzz:// / invite validation strict, and normalize ws(s) → http(s) before storage so public wss:// payloads stop failing with the misleading “must use HTTPS” error.

    Happy to adjust if maintainers want a different trust boundary (e.g. explicit confirm dialog).

  5. dazligth commented on Sep 15, 2026

    @dazligth

    Also hitting this on a self-hosted deployment — relay behind Headscale (self-hosted Tailscale control plane), mesh-only ws://<magicdns-name>:3000. Can confirm the same failure mode reported here; #7266 looks like the right fix for our case too (SAS already verified by that point).

    Re: @nitinmotgi's open question above about relay-URL changes re-keying the community — checked this directly against main (crates/buzz-db/src/store/community.rs): communities is keyed only by lower(host) via ON CONFLICT (lower(host)); there's no secondary stable identifier. So yes, changing the relay-facing host/URL does create a brand-new community with a fresh UUID — the existing one (and its history) stays behind under the old host, unreachable. Confirms this isn't a safe workaround for already-established deployments; #7266 (or an equivalent post-SAS trust fix) is the real path for those of us with existing history.

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