fix(nostr-client): reject non-ws(s):// relays in the spire seed

The npubs in the seed are bech32-checksummed, so a mis-scanned character is
caught — but the relay strings are raw inside the base64. A QR misread silently
turned `ws://192.168.0.32:5001/...` into `As://192.168.0.32:5001/...`, which
parsed fine and then crash-looped the machine on an unreachable NIP-46 relay.

Validate every `relays[]` entry (and `bunker_relay`) is a `ws://`/`wss://` URL
at parse time, so a garbled scan is rejected as an invalid seed instead of
persisted. Part of bitspire-#70 pairing robustness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-01 23:43:28 +02:00 • committed by padreug
commit 5179a21da6
2 changed files with 19 additions and 0 deletions

View file

@ -80,7 +80,10 @@ describe('parseSpireSeed', () => {
['missing bunker_secret', { ...VALID, bunker_secret: undefined }],
['empty relays', { ...VALID, relays: [] }],
['non-string relay', { ...VALID, relays: [123] }],
['non-ws relay (scan corruption ws://→As://)', { ...VALID, relays: ['As://events.relay/'] }],
['non-ws relay (http)', { ...VALID, relays: ['http://events.relay/'] }],
['empty bunker_relay', { ...VALID, bunker_relay: '' }],
['non-ws bunker_relay', { ...VALID, bunker_relay: 'As://bunker.relay/' }],
])('rejects %s', (_label, json) => {
expect(() => parseSpireSeed(makeSeed(json))).toThrow()
})

View file

@ -53,6 +53,20 @@ export interface SpireSeed {
const HEX64 = /^[0-9a-f]{64}$/
/**
* A relay must be a `ws://` or `wss://` URL. Unlike the npubs (bech32-checksummed,
* so a mis-scanned character is caught), the relay strings are raw inside the
* seed's base64 — a QR misread can silently corrupt `ws://` into e.g. `As://`
* and the pairing then crash-loops on an unreachable relay. Reject at parse time
* so the wizard refuses a garbled scan instead of persisting it (bitspire#70).
*/
const WS_URL = /^wss?:\/\/[^\s]+$/
function assertRelayUrl(value: string, field: string): void {
if (!WS_URL.test(value)) {
throw new Error(`parseSpireSeed: ${field} must be a ws:// or wss:// URL (got "${value}")`)
}
}
/** Decode an unpadded base64url string in both browser and Node. */
function base64urlDecode(input: string): string {
const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=')
@ -118,6 +132,7 @@ export function parseSpireSeed(seedUrl: string): SpireSeed {
if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) {
throw new Error('parseSpireSeed: relays must be a non-empty string array')
}
relays.forEach((r, i) => assertRelayUrl(r as string, `relays[${i}]`))
// Optional bunker relay; default to the first event relay. Keeps the common
// case (bunker on the same relay) one field lighter, while still allowing a
@ -127,6 +142,7 @@ export function parseSpireSeed(seedUrl: string): SpireSeed {
if (typeof obj.bunker_relay !== 'string' || obj.bunker_relay.length === 0) {
throw new Error('parseSpireSeed: bunker_relay, if present, must be a non-empty string')
}
assertRelayUrl(obj.bunker_relay, 'bunker_relay')
bunkerRelay = obj.bunker_relay
}