You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Mobile: QR pairing fails at credential import for private/VPN-only relays (relay URL must be HTTPS + public host) #4198
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.
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
Desktop shows QR → phone (on the VPN) connects to the pairing sidecar
NIP-AB handshake completes, SAS codes match and confirm
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.
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/10within 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.
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.
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:719if (!kDebugMode && uri.scheme !='https') {
throwconstFormatException('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:
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.
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.
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).
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.
Activity
mgoulart commentedon Aug 2, 2026
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-IPws://URL, both mobile checks pass and pairing completes end-to-end. Confirmed working just now.Setup that works:
docker compose, closed (NIP-43,require_relay_membership=true), reachable only inside a Tailscale tailnet.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.Why it passes where
ws://10.88.0.1:3000fails: the credential payload'srelayUrlishttps://<host>.ts.net— thehttpsscheme satisfies_validateRelayUrl(), and the host is a DNS name, not an RFC1918 literal, so_isPrivateHost()doesn't trip. Notably our name resolves to CGNAT100.64/10within 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.
nitinmotgi commentedon Aug 3, 2026
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.netname resolves into CGNAT100.64/10inside 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 serveequivalent 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>:3000towss://<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.
frischeDaten commentedon Aug 8, 2026
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:
Cause is
_validateRelayUrlrequiring the scheme to be exactlyhttpsin release builds:The trap is that
wss://is the natural thing to put in a relay URL — it's whatRELAY_URLuses, what the NIP-11pairing_relay_urlfield carries, and what every Nostr client expects. A payload carryingrelayUrl: "wss://relay.example.com"is rejected outright even though that host is HTTPS-reachable and the derivedhttps://form would validate fine. Switching the payload tohttps://relay.example.commade it import successfully.Two suggestions, either of which would have saved the debugging round:
wss:///ws://and normalise tohttps/httpbefore the private-host checks — the transport scheme carries no security signal the host check doesn't already cover.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 --nsecsendsPayloadType::Nsec(a bare bech32 string), while mobile's_processPayloadunconditionallyjsonDecodes and reads{relayUrl, pubkey, nsec}. That fails one gate earlier withFormatException: Unexpected character (at character 1). The two gates in order are: envelope shape → URL scheme → NIP-42 credential validation.feat(desktop): BUZZ_PAIRING_RELAY_URL pairing override + pair-relay p…
fix(mobile): allow SAS-verified private/ws(s) relay URLs on pairing
xandorklein commentedon Sep 7, 2026
Opened a fix PR for this: #7266
Approach matches the discussion here: after successful NIP-AB SAS, treat the desktop-sourced
relayUrlas verified (allowws/wss/http/https+ private/VPN hosts), keep legacybuzz:/// invite validation strict, and normalizews(s)→http(s)before storage so publicwss://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).
dazligth commentedon Sep 15, 2026
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):communitiesis keyed only bylower(host)viaON 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.Phone pairing on the compose bundle, tested with an iPhone