From d57e88f631e369abaf0e3e5d8127e575de0327a5 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 18:33:59 +0100 Subject: [PATCH 1/7] Relay: relay-auth proves the node's key; 4004/4006 stop for good again; a relay setting never stops the node Key proof (R3-3, the root cause, replacing the retry): relay-auth carries keyProof "mmp-relay-auth-v1" when the node holds its identity key; on a relay-challenge (sym-relay 0.8.0) the node sends relay-auth again with publicKey and an Ed25519 proof over lp("mmp-relay-auth-v1") || lp(nonce) || lp(room) || lp(nodeId) || lp(origin dialed) (lib/core/relay-auth-proof.js, vector tests/relay-auth-proof-v1.json shared with sym-relay). A relay that never challenges takes the first frame as before: relayVerifiesKeys false, said once. Removed from the 0.14.3 transport fix: the 4004/4006 long-backoff retry, the ping-pong detector, identityRetry/relayIdentityRetry(Ms), pingPong/retryInMs and their tests. 4004 and 4006 are hard stops again (MMP 4.4.7), said with their cause: another process proved this identity's key, or the relay does not verify keys. identity-collision is { nodeId, name, code, kind, reason } plus relayVerifiesKeys; the metric is relay-hard-stop again. Slots: roster and presence entries with key/proven; the bound key's slot is the peer, another key is never a candidate and its departure ends nothing; directed frames name toKey (unicast and fan-out) when the relay lists key-proof and a slot proven for the bound key. B1: http(s) relay URLs are ws(s) everywhere a relay URL enters (SymNode, RelayConnection, parseInvite, the daemon); a setting that is no relay URL disables the relay (status phase disabled, one log line, a relay-disabled event at start) and never throws or fails start(). --- bin/sym-daemon.js | 14 +- lib/core/relay-auth-proof.js | 97 +++++++ lib/invite.js | 15 +- lib/node.js | 48 ++-- lib/relay.js | 461 +++++++++++++++++++++----------- tests/node-lifecycle.test.js | 19 +- tests/relay-auth-proof-v1.json | 27 ++ tests/relay-key-proof.test.js | 360 +++++++++++++++++++++++++ tests/relay-resilience.test.js | 68 +---- tests/relay-state.test.js | 43 +-- tests/relay-url-setting.test.js | 133 +++++++++ 11 files changed, 1013 insertions(+), 272 deletions(-) create mode 100644 lib/core/relay-auth-proof.js create mode 100644 tests/relay-auth-proof-v1.json create mode 100644 tests/relay-key-proof.test.js create mode 100644 tests/relay-url-setting.test.js diff --git a/bin/sym-daemon.js b/bin/sym-daemon.js index c804073b..1a14cad6 100755 --- a/bin/sym-daemon.js +++ b/bin/sym-daemon.js @@ -45,6 +45,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const { SymNode, migrateStores } = require('../lib/node'); +const { normalizeRelayUrl } = require('../lib/relay'); // ── Global error handlers ───────────────────────────────────── process.on('uncaughtException', (err) => { @@ -91,7 +92,12 @@ if (!process.env.SYM_RELAY_URL) { } } -const relayUrl = process.env.SYM_RELAY_URL || null; +// The relay setting never stops the daemon (B1): http:// and https:// are the same relay as ws:// and +// wss://, and a setting that is no relay URL is handed to the node as it is, which disables its relay +// and says why (status, log, 'relay-disabled'); the daemon runs on the LAN. It crash-looped under +// KeepAlive on a 0.14.3 candidate that threw EBADRELAY for an https:// URL. +const RELAY_SETTING = process.env.SYM_RELAY_URL || null; +const relayUrl = RELAY_SETTING ? normalizeRelayUrl(RELAY_SETTING) : null; const relayToken = process.env.SYM_RELAY_TOKEN || null; // ── CLI Commands ─────────────────────────────────────────────── @@ -165,7 +171,7 @@ try { const n = migrateStores(); if (n) log(`Migrated ${n} node store(s): meshme // Bonjour. `SYM_RELAY_ONLY=1` (also persisted by `sym start --relay-only`). Without it the // node advertises and browses on the LAN as before. const RELAY_ONLY = /^(1|true|yes)$/i.test(String(process.env.SYM_RELAY_ONLY || '')); // relay.env is folded into process.env above -if (RELAY_ONLY) log(`relay-only: LAN discovery off (SYM_RELAY_ONLY); ${relayUrl ? `joining ${relayUrl}` : 'WARNING — no SYM_RELAY_URL, so this node will have no peers at all'}`); +if (RELAY_ONLY) log(`relay-only: LAN discovery off (SYM_RELAY_ONLY); ${relayUrl ? `joining ${relayUrl}` : RELAY_SETTING ? 'WARNING — SYM_RELAY_URL is not a relay URL, so the relay is disabled and this node will have no peers at all' : 'WARNING — no SYM_RELAY_URL, so this node will have no peers at all'}`); const node = new SymNode({ name: NODE_NAME, @@ -175,7 +181,7 @@ const node = new SymNode({ room: ROOM, discoveryServiceType: '_sym._tcp', discoveryBrowseTypes: LEGACY_BROWSE ? [LEGACY_BROWSE] : [], - relay: relayUrl, + relay: RELAY_SETTING, relayToken: relayToken, silent: false, }); @@ -881,7 +887,7 @@ function stopRoomBeacon() { async function main() { log(`sym-daemon starting: ${NODE_NAME}`); - log(` relay: ${relayUrl || 'none'}`); + log(` relay: ${relayUrl || (RELAY_SETTING ? 'disabled (SYM_RELAY_URL is not a relay URL)' : 'none')}`); log(` socket: ${SOCKET_PATH}`); // A socket another daemon answers on is its: this start stops before its node joins anything. diff --git a/lib/core/relay-auth-proof.js b/lib/core/relay-auth-proof.js new file mode 100644 index 00000000..3bcae27d --- /dev/null +++ b/lib/core/relay-auth-proof.js @@ -0,0 +1,97 @@ +'use strict'; + +/** + * @module sym/core/relay-auth-proof + * @description RELAY-AUTH KEY PROOF, the client's half (sym 0.14.3; sym-relay 0.8.0 verifies it). + * + * A relay-auth's nodeId used to be a claim: the relay keyed a connection's slot by whatever nodeId the + * client named, so any holder of the channel token could claim a member's nodeId and have the member + * closed (4004) or refused (4006). The node now proves its identity key to a relay that asks: + * + * node → relay-auth { nodeId, name, token?, room?, engine?, keyProof: "mmp-relay-auth-v1" } + * A relay that does not know key proof (sym-relay before 0.8.0) ignores the field and + * authenticates this frame as it always did: relay-peers arrives with no challenge, and the + * node records that this relay does not verify keys. + * relay → relay-challenge { keyProof: "mmp-relay-auth-v1", nonce: <32 bytes, base64url> } + * node → relay-auth { …the same fields…, keyProof, publicKey, proof } + * relay → relay-peers (the slot is the node's, under its key) | 4002 (the proof does not verify) + * + * The signed bytes are domain-separated and every field is length-prefixed (4 bytes, big-endian): + * + * lp("mmp-relay-auth-v1") ‖ lp(nonce) ‖ lp(room) ‖ lp(nodeId) ‖ lp(origin) + * + * room is the room the relay keys the slot by (`default` when relay-auth names none); nodeId the + * lowercase nodeId of the relay-auth; origin the WHATWG origin of the URL this node DIALED + * (`wss://host`, `ws://127.0.0.1:8787`), never one the relay names: a proof made for one relay + * does not verify at another, so a relay that forwards another's challenge gains nothing. The token + * is never signed. tests/relay-auth-proof-v1.json is the vector sym-relay carries too. + * + * @copyright 2026 SYM.BOT Ltd. + * @license Apache-2.0 + */ + +const crypto = require('crypto'); +const { privateKeyObject } = require('./cmb-signing'); +const { verifyStrict, CANONICAL_KEY_B64URL } = require('./ed25519'); + +const KEY_PROOF = 'mmp-relay-auth-v1'; +const NONCE_BYTES = 32; + +function lp(buf) { + const len = Buffer.alloc(4); + len.writeUInt32BE(buf.length, 0); + return Buffer.concat([len, buf]); +} + +/** + * The bytes the node signs. + * @param {{ nonce: Buffer, room: string, nodeId: string, origin: string }} t + * @returns {Buffer} + */ +function transcript({ nonce, room, nodeId, origin }) { + if (!Buffer.isBuffer(nonce) || nonce.length !== NONCE_BYTES) throw new TypeError('relay-auth transcript: nonce must be 32 bytes'); + for (const [k, v] of Object.entries({ room, nodeId, origin })) { + if (typeof v !== 'string' || !v) throw new TypeError(`relay-auth transcript: ${k} must be a non-empty string`); + } + return Buffer.concat([ + lp(Buffer.from(KEY_PROOF, 'utf8')), + lp(nonce), + lp(Buffer.from(room, 'utf8')), + lp(Buffer.from(nodeId, 'utf8')), + lp(Buffer.from(origin, 'utf8')), + ]); +} + +/** + * The challenge's nonce, or null when it is not 32 bytes in canonical unpadded base64url. + * @param {*} nonce + * @returns {Buffer|null} + */ +function challengeNonce(nonce) { + if (typeof nonce !== 'string' || !CANONICAL_KEY_B64URL.test(nonce)) return null; + const b = Buffer.from(nonce, 'base64url'); + return b.length === NONCE_BYTES ? b : null; +} + +/** The WHATWG origin of a ws:// or wss:// URL, or null. */ +function originOf(url) { + try { + const u = new URL(url); + return u.protocol === 'ws:' || u.protocol === 'wss:' ? u.origin : null; + } catch { return null; } +} + +/** + * The proof: Ed25519 (pure) over the transcript with the identity's private key (raw, base64url), + * in canonical unpadded base64url. + */ +function signProof(t, identityPrivateKeyB64url) { + return crypto.sign(null, transcript(t), privateKeyObject(identityPrivateKeyB64url)).toString('base64url'); +} + +/** Whether `proof` by `publicKey` verifies over the transcript (MMP §18.3.2). For tests and relays. */ +function verifyProof(t, publicKey, proof) { + try { return verifyStrict(transcript(t), publicKey, proof); } catch { return false; } +} + +module.exports = { KEY_PROOF, NONCE_BYTES, transcript, challengeNonce, originOf, signProof, verifyProof }; diff --git a/lib/invite.js b/lib/invite.js index 018d411b..2e4dbd14 100644 --- a/lib/invite.js +++ b/lib/invite.js @@ -24,7 +24,7 @@ */ const { isIdentityKey } = require('./roster-keys'); -const { isRelayUrl } = require('./relay'); +const { normalizeRelayUrl } = require('./relay'); const INVITE_URL_RE = /^([a-z][a-z0-9-]+):\/\/(room|team)\/([^/?#]+)(?:\/([^?#]+))?(?:\?([^#]*))?$/i; // The canonical lowercase form only (§3.1.1; security review B): an upper-case nodeId is refused. @@ -85,14 +85,17 @@ function parseInvite(url) { } issuer = { nodeId: query.node, publicKey: query.key }; } - // The relay is checked here, where it comes in (R3-11): a URL ws cannot parse used to reach the - // node and throw from start(), after the node had half started. - if (query.relay && !isRelayUrl(query.relay)) { - return { error: `the invite's relay ${JSON.stringify(String(query.relay).slice(0, 120))} is not a ws:// or wss:// URL` }; + // The relay is checked here, where it comes in: http:// and https:// are the same relay as ws:// and + // wss:// and are spelled so (the node dials and signs its key proof's origin over that spelling); + // anything else is not a relay, and the invite says so. (A node given one anyway runs without its + // relay, never fails: SymNode.) + const relayUrl = query.relay ? normalizeRelayUrl(query.relay) : null; + if (query.relay && !relayUrl) { + return { error: `the invite's relay ${JSON.stringify(String(query.relay).slice(0, 120))} is not a ws://, wss://, http:// or https:// URL` }; } return { appScheme, kind: m[2].toLowerCase(), room, roomId: rawId, roomName: rawName, - relayUrl: query.relay || null, relayToken: query.token || null, issuer, + relayUrl, relayToken: query.token || null, issuer, }; } diff --git a/lib/node.js b/lib/node.js index 67d0e6d0..865c4527 100644 --- a/lib/node.js +++ b/lib/node.js @@ -94,7 +94,7 @@ const ATTEST_FRAMES = new Set(Object.values(ATTEST_FRAME)); /** Legacy hellos refused on the Core Secure listener are said at most once a minute per address. */ const LEGACY_REFUSAL_REPORT_MS = 60_000; const { SEND_FAILURE, MAX_FRAME_SIZE } = require('./frame-parser'); -const { RelayConnection, assertRelayUrl } = require('./relay'); +const { RelayConnection } = require('./relay'); const { BonjourDiscovery, NullDiscovery, mmpLists20 } = require('./discovery'); /** @@ -873,15 +873,17 @@ class SymNode extends EventEmitter { }); this._wakeManager.loadWakeChannels(); - // Relay. The URL is checked here, before anything starts (R3-11): one `ws` cannot parse threw from - // start(), after the listener was up, and left a half-started node behind. - this._relayUrl = opts.relay || null; - assertRelayUrl(this._relayUrl, 'SymNode: relay'); + // Relay. The relay is an optional transport, so its setting never stops the node: http:// and + // https:// are taken as ws:// and wss:// (sym 0.14.2 dialled them, and the hosted relay is often + // written that way), and a setting that is still no relay URL DISABLES the relay — said once + // here, in status() (relayState.phase 'disabled', with the reason) and as a 'relay-disabled' event + // at start() — while the node runs on the LAN. (A 0.14.3 candidate threw EBADRELAY from this + // constructor, R3-11, which stopped the daemon and mesh-channel at startup for an https:// URL.) this._relayToken = opts.relayToken || null; this._relayOnly = opts.relayOnly || false; this._relay = new RelayConnection({ - relayUrl: this._relayUrl, + relayUrl: opts.relay || null, relayToken: this._relayToken, wakeChannel: this._wakeChannel, log: (msg) => this._log(msg), @@ -900,21 +902,26 @@ class SymNode extends EventEmitter { guard: (frame, take) => this._guardInbound('relay', 'relay', frame, 'relay', take), isKnown: (nodeId) => !!(this._peers && this._peers.has(nodeId)), rate: opts.relayRate, - identityRetry: opts.relayIdentityRetry, - identityRetryMs: opts.relayIdentityRetryMs, + // The key this node binds to a nodeId: a relay that verifies keys lists every key that proved + // a nodeId, and the bound one is the peer (relay.js, _preferredSlot) and is named in `toKey`. + keyOf: (nodeId) => this._identityKey(nodeId), // Records the pacer never wrote come back when the connection closes (R3-4): held for the peer's // next session, and listed in pendingDeliveries() until they are written to one. onUnflushed: (items) => this._holdUnflushed(items), onFlushed: (meta) => this._redeliveryFlushed(meta), onIdentityCollision: (info) => { - // Surface the collision as an event so hosts can take action. The relay layer has logged it - // loudly and retries on a long backoff (0.14.3, R3-3: it used to stop for good); LAN peers are - // unaffected meanwhile. `info.code` says which close (4004 replaced, 4006 duplicate rejected), - // `info.pingPong` whether two processes are replacing each other, and - // status().relayState.stopped keeps the last one. A single close proves no second process - // (relay-auth carries no key proof): a host that exits should exit on `pingPong`. + // Surface the collision as an event so hosts can take action. + // Default behavior (if no listener): the relay layer has already + // logged loudly and stopped reconnecting; the node remains alive + // but in a degraded state (no relay transport, only Bonjour LAN + // peers reachable). Hosts that prefer to exit hard should listen + // and call process.exit() themselves. `info.code` says which stop + // (4004 replaced, 4006 duplicate rejected) and status().relayState.stopped keeps it. + // `info.relayVerifiesKeys`: true when this connection had proved its key, so another process + // holding the same key took the slot; false when the relay does not verify keys, so any + // holder of the token could have. this.emit('identity-collision', info); - this.emit('metric', { type: 'relay-identity-close', code: info.code, kind: info.kind || null, nodeId: info.nodeId, pingPong: !!info.pingPong }); + this.emit('metric', { type: 'relay-hard-stop', code: info.code, kind: info.kind || null, nodeId: info.nodeId, relayVerifiesKeys: !!info.relayVerifiesKeys }); }, onAuthRefused: (info) => { // The relay's channel table does not hold our token. The relay layer has logged the @@ -924,6 +931,12 @@ class SymNode extends EventEmitter { }, nodeName: this.name, }); + this._relayUrl = this._relay.state().url; // as dialled (ws:// or wss://), or null + this._relayDisabled = this._relay.state().disabled; + if (this._relayDisabled) { + this._log(`RELAY DISABLED: ${this._relayDisabled.reason}, so this node runs on the LAN only. ` + + `Fix: set SYM_RELAY_URL (or the invite's relay) to a ws://, wss://, http:// or https:// URL and restart.`); + } // Frame handler — cliHostMode forwards frames without storing or SVAF. // Local CLI-host peer pattern: hosts the IPC surface for the sym CLI @@ -2543,6 +2556,11 @@ class SymNode extends EventEmitter { if (this._relayUrl) { this._relay.connect(); + } else if (this._relayDisabled && !this._relayDisabledTold) { + // Told to the host once, here where its listeners are attached; the node keeps starting. + this._relayDisabledTold = true; + this.emit('relay-disabled', { reason: this._relayDisabled.reason }); + this.emit('metric', { type: 'relay-disabled', reason: this._relayDisabled.reason }); } this._legacy.start(); diff --git a/lib/relay.js b/lib/relay.js index 7e140623..bb4dbf52 100644 --- a/lib/relay.js +++ b/lib/relay.js @@ -14,6 +14,8 @@ const { PEER_INFO_MAX } = require('./core/wake'); const { wireNodeId, wireName } = require('./wire-identity'); const RELAY_ENVELOPE_ALLOWANCE = 4096; const { nestedTooDeep } = require('./core/json-depth'); +const keyProof = require('./core/relay-auth-proof'); +const { CANONICAL_KEY_B64URL } = require('./core/ed25519'); /** * THE CONNECTION CEILING IS NOT THE MESSAGE BOUND (R3-1, R3-2). A delivered envelope is at most * MAX_FRAME_SIZE + RELAY_ENVELOPE_ALLOWANCE (MMP §4.4.4), and this client used that as the ws @@ -30,26 +32,44 @@ const MESSAGE_MAX = MAX_FRAME_SIZE + RELAY_ENVELOPE_ALLOWANCE; const ROSTER_PREFIX = /^\s*\{\s*"type"\s*:\s*"relay-peers"/; /** - * Whether `url` is a relay URL this client can dial: an absolute ws:// or wss:// URL with a host. - * Checked where a relay URL comes in (the node's options, an invite), so a URL `ws` cannot parse - * never reaches start() (R3-11: it threw there, after the node had half started). + * A RELAY URL, as this client dials it: an absolute ws:// or wss:// URL with a host. http:// and + * https:// are the same relay (ws maps them, and sym 0.14.2 dialled them), so they are taken and + * spelled ws:// and wss:// here, where every relay URL comes in (SymNode, RelayConnection, an + * invite): the URL the node dials, shows and signs its key proof's origin over is that one. * @param {*} url - * @returns {boolean} + * @returns {string|null} the URL to dial, or null when it is not a relay URL (relayUrlProblem says why) */ -function isRelayUrl(url) { - if (typeof url !== 'string' || !url || url.length > 2048) return false; +function normalizeRelayUrl(url) { + if (typeof url !== 'string') return null; + const s = url.trim(); + if (!s || s.length > 2048) return null; + const spelled = s.replace(/^https?(?=:)/i, (m) => (m.length === 5 ? 'wss' : 'ws')); let u; - try { u = new URL(url); } catch { return false; } - return (u.protocol === 'ws:' || u.protocol === 'wss:') && !!u.hostname; + try { u = new URL(spelled); } catch { return null; } + if ((u.protocol !== 'ws:' && u.protocol !== 'wss:') || !u.hostname) return null; + return spelled; } -/** Throw EBADRELAY unless `url` is absent or a relay URL (isRelayUrl). */ -function assertRelayUrl(url, where = 'relay') { - if (url === null || url === undefined || url === '') return; - if (isRelayUrl(url)) return; - const e = new TypeError(`${where}: ${JSON.stringify(String(url).slice(0, 120))} is not a ws:// or wss:// URL`); - e.code = 'EBADRELAY'; - throw e; +/** Whether `url` is a relay URL this client can dial (normalizeRelayUrl). */ +function isRelayUrl(url) { + return normalizeRelayUrl(url) !== null; +} + +/** + * Why `url` is not a relay URL, in words that never repeat it (it may carry a secret): what it is, + * and how long. Null when it is one. + */ +function relayUrlProblem(url) { + if (normalizeRelayUrl(url) !== null) return null; + if (typeof url !== 'string') return `the relay setting is a ${url === null ? 'null' : typeof url}, not a URL`; + const s = url.trim(); + if (!s) return 'the relay setting is empty'; + if (s.length > 2048) return `the relay setting is ${s.length} characters long (at most 2048)`; + let u = null; + try { u = new URL(s); } catch { /* not a URL at all */ } + if (!u) return `the relay setting (${s.length} characters) is not a URL`; + if (!/^(wss?|https?):$/.test(u.protocol)) return `the relay setting is a ${u.protocol.replace(/:$/, '')}: URL, not ws://, wss://, http:// or https://`; + return 'the relay setting names no host'; } /** * Peers the relay's announcements (join notices, peer-list entries) may add to the peer table: @@ -192,45 +212,62 @@ class FairQueue { const FANOUT_FEATURE = 'fanout'; /** - * The IDENTITY CLOSES: the relay says another connection holds this nodeId. - * 4004 replaced — another connection authenticated as this nodeId and the relay took it; - * 4006 duplicate rejected — this node is the newcomer and the existing holder is younger than 5 s. - * - * UNTIL 0.14.3 BOTH WERE PERMANENT STOPS (MMP §4.4.7: "MUST NOT automatically reconnect" after 4004, - * "SHOULD NOT" after 4006), and that let anyone holding the channel token switch a member off for - * good (R3-3): relay-auth carries no key proof, so a token holder that read a member's nodeId from - * the roster could claim it once, leave, and the member never came back. Now an identity close is - * retried on a LONG backoff (identityRetryMs, 1 min, doubling to 30 min), said loudly every time, with - * a PING-PONG DETECTOR: PINGPONG_CLOSES identity closes inside PINGPONG_WINDOW_MS mean two live - * processes hold this identity and replace each other, which is said as that, told to the host - * (onIdentityCollision with pingPong: true) and retried only at the longest backoff. A relay-side key - * proof would end the impostor case properly; it needs a spec ruling and is not implemented here. - * `identityRetry: false` keeps the §4.4.7 behaviour (no reconnect after an identity close). + * Close codes after which the client never reconnects on its own (MMP §4.4.7, §4.4.9): each says + * another connection holds this node's relay slot, and reconnecting would only fight it. + * 4004 replaced — another connection authenticated as this nodeId and the relay took the slot. On a + * relay that verifies keys (sym-relay ≥ 0.8.0; this connection proved its key, see + * core/relay-auth-proof.js) only a holder of this node's private key can do that: another + * process proved this identity's key on the relay, it is the newest holder, and this one stops. + * On a relay that does not verify keys any holder of the channel token can, by claiming the + * nodeId; the client says so (upgrade the relay) and still stops. + * 4006 duplicate rejected — a relay that does not verify keys found another live connection + * claiming this nodeId (first writer wins inside 5 s). A relay that verifies keys never sends + * it to a proven connection. + * A 0.14.3 candidate retried both on a long backoff with a ping-pong detector: a patch over an + * identity the relay did not verify, and it let two processes holding one key flap forever. It is + * gone; the key proof is the fix, and on a proven connection the newest proof of a key wins, once. * * Every other close is reconnected with backoff (MMP 2.0 update 1, §4.4.9): 4005, 4008, and the - * RFC 6455 closes 1001, 1009, 1011 and 1013 by the table, and 4007 too. 4007 belongs to relay-auth - * key proof (draft PR meshcognition-website#20), which is deferred past 0.14.0 and not in MMP: its - * review found a lockout in "MUST NOT reconnect after 4007", so it is an ordinary close here. + * RFC 6455 closes 1001, 1009, 1011 and 1013 by the table, and 4002 and 4007 too. 4007 belonged to + * the key-proof draft of meshcognition-website#20, whose review found a lockout in "MUST NOT + * reconnect after 4007"; a relay that refuses a proof here closes with 4002 (fix the auth frame). */ -const IDENTITY_CLOSES = { +const HARD_STOPS = { 4004: { phase: 'collision', kind: 'replaced' }, 4006: { phase: 'duplicate-rejected', kind: 'duplicate-rejected' }, }; -/** @deprecated the name before 0.14.3, when these closes stopped the client for good. */ -const HARD_STOPS = IDENTITY_CLOSES; -const IDENTITY_RETRY_MS = 60_000; -const IDENTITY_RETRY_MAX_MS = 30 * 60_000; -const PINGPONG_WINDOW_MS = 30 * 60_000; -const PINGPONG_CLOSES = 3; +const STOP_PHASES = Object.values(HARD_STOPS).map((h) => h.phase); +const TERMINAL_PHASES = ['connected', 'refused', ...STOP_PHASES, 'disabled', 'off']; +/** The relay's feature token for key proof: keyed slots, `key`/`proven` entries, and `toKey`. */ +const KEY_PROOF_FEATURE = 'key-proof'; /** - * A ping-pong, once found, holds until a connection has stayed up this long since its auth without an - * identity close: the other process has gone. (At the longest backoff the closes of a real ping-pong - * are an hour apart, so the window that finds one could never keep it.) + * Slots one nodeId may hold in this client's view of the relay at once (a relay that verifies keys + * lists one entry per key that proved it, and one for a legacy claim). Past it, an announcement of + * another key for that nodeId is ignored and counted (state().slotsIgnored). */ -const PINGPONG_CLEAR_MS = 2 * IDENTITY_RETRY_MAX_MS + 10 * 60_000; -const STOP_PHASES = Object.values(IDENTITY_CLOSES).map((h) => h.phase); -const TERMINAL_PHASES = ['connected', 'refused', ...STOP_PHASES, 'off']; +const MAX_SLOTS_PER_NODE = 16; const FANOUT_MAX = 64; + +/** Whether `identity` holds what a key proof needs: its Ed25519 key pair (base64url, raw). */ +function canProve(identity) { + return !!identity && typeof identity.privateKey === 'string' && identity.privateKey.length > 0 + && typeof identity.publicKey === 'string' && CANONICAL_KEY_B64URL.test(identity.publicKey); +} + +/** + * The slot a roster or presence entry names: its key when the relay says it is proven (a canonical + * base64url key and `proven: true`, sym-relay 0.8.0), '' for a legacy claim or an older relay. + */ +function slotKeyOf(entry) { + return entry && entry.proven === true && typeof entry.key === 'string' && CANONICAL_KEY_B64URL.test(entry.key) ? entry.key : ''; +} + +/** The cause of a hard stop, in the words the log, describe() and the docs use. */ +function hardStopCause(relayVerifiesKeys) { + return relayVerifiesKeys + ? 'another process proved this identity\'s key on this relay; this one stopped' + : 'this relay does not verify keys, so any holder of the token can cause this; upgrade the relay'; +} /** The relay's own frames (auth, pong) waiting for a token at most; see _sendControl. */ const CONTROL_QUEUE_MAX = 8; class RelayConnection { @@ -254,16 +291,17 @@ class RelayConnection { * @param {Map} opts.peerWakeChannels — shared peer wake channels Map * @param {function} opts.saveWakeChannels — () => void * @param {function} opts.learnWakeChannel — (nodeId, channel, {source, lastSeen}) => outcome (WakeManager) - * @param {function} [opts.onIdentityCollision] — ({nodeId, name, code, kind, reason, pingPong, - * retryInMs}) => void — called at the first identity close (4004 / 4006) of an - * episode, and again when the ping-pong detector finds two processes replacing - * each other (pingPong: true). The relay layer has logged it and retries on the - * long backoff (IDENTITY_CLOSES); a single close is no proof of a second process - * (relay-auth carries no key proof), so a host that exits on it lets any token - * holder stop it: exit on `pingPong` if at all. - * @param {boolean} [opts.identityRetry=true] — false: no reconnect after an identity close - * (MMP §4.4.7 as written). - * @param {number} [opts.identityRetryMs] — the first retry after an identity close (1 min). + * @param {function} [opts.onIdentityCollision] — ({nodeId, name, code, kind, reason, + * relayVerifiesKeys}) => void — called once when the relay closes with 4004 or + * 4006 (HARD_STOPS). The relay layer has logged it loudly and stopped + * reconnecting; LAN peers are unaffected. `relayVerifiesKeys` true: this + * connection had proved its key, so another process proved the same key (stop + * one of them); false: the relay does not verify keys, so any token holder could + * have caused it (upgrade the relay). + * @param {function} [opts.keyOf] — (nodeId) => key | undefined — the identity key this node binds to + * `nodeId` (base64url), if any. A relay that verifies keys lists every key that + * proved a nodeId; the one bound here is preferred, and directed frames name it + * (`toKey`) so the relay hands them to that slot only. * @param {function} [opts.onUnflushed] — (items) => void — frames the pacer still held when the * connection closed, each `{ to, bytes, meta }`, for those sent with a `meta` * (sendTo's third argument); the node re-offers them to the next session. @@ -277,8 +315,12 @@ class RelayConnection { * 4003 (default 10 minutes). */ constructor(opts) { - assertRelayUrl(opts.relayUrl, 'RelayConnection'); - this._relayUrl = opts.relayUrl; + // A relay setting that is not a relay URL disables the relay, said in state() and describe(), + // and never throws: the relay is an optional transport, and the node runs on the LAN without it. + const given = opts.relayUrl; + const hasSetting = !(given === null || given === undefined || given === ''); + this._relayUrl = hasSetting ? normalizeRelayUrl(given) : null; + this._disabled = hasSetting && !this._relayUrl ? { reason: relayUrlProblem(given), at: Date.now() } : null; this._relayToken = opts.relayToken; this._wakeChannel = opts.wakeChannel; this._log = opts.log; @@ -323,13 +365,19 @@ class RelayConnection { this._authRefusedRetryMs = opts.authRefusedRetryMs || 10 * 60 * 1000; this._authRefused = false; this._nodeName = opts.nodeName; - this._identityCollision = false; // inside an identity-close episode (4004 / 4006), until an auth succeeds - this._stopped = null; // the last identity close { code, kind, reason, at, closes, pingPong } - this._identityRetry = opts.identityRetry !== false; - this._identityRetryMs = Number.isFinite(opts.identityRetryMs) && opts.identityRetryMs > 0 ? opts.identityRetryMs : IDENTITY_RETRY_MS; - this._identityCloses = []; // when each identity close came, inside PINGPONG_WINDOW_MS - this._pingPong = false; // a ping-pong found, until a connection outlasts PINGPONG_CLEAR_MS - this._authAt = 0; // when the relay last took this client's auth + this._identityCollision = false; // a hard stop (4004 / 4006): never reconnect on its own + this._stopped = null; // { code, kind, reason, at, relayVerifiesKeys } — why, for state() and describe() + // Key proof (core/relay-auth-proof.js). Per connection: whether this node asked for a challenge, + // the challenge's nonce once it came, and whether the relay took the proof (relay-peers after it). + this._keyOf = typeof opts.keyOf === 'function' ? opts.keyOf : () => undefined; + this._proof = { requested: false, nonce: null, proven: false }; + this._relayVerifiesKeys = null; // what the last relay answer said: true, false, or null before one + this._unverifiedSaid = false; // "this relay does not verify keys" is said once + this._keyProofFeature = false; // the relay lists `key-proof` on this connection + this._authFrameSent = null; // the relay-auth this connection last sent (relay-reauth re-sends it) + // The relay's slots: nodeId -> Map(key, or '' for a legacy claim -> { name, key, proven }). + this._slots = new Map(); + this._slotsIgnored = 0; this._onUnflushed = typeof opts.onUnflushed === 'function' ? opts.onUnflushed : null; this._onFlushed = typeof opts.onFlushed === 'function' ? opts.onFlushed : null; this._oversizeDropped = 0; // relay messages over the per-message bound, dropped on their own @@ -344,7 +392,7 @@ class RelayConnection { // agent driving the session could not tell which it was, let alone what to do. Every // transition below is recorded so state() can say the phase, the last close, and the // next retry, and describe() can put the fix in one line. - this._phase = this._relayUrl ? 'idle' : 'off'; + this._phase = this._disabled ? 'disabled' : this._relayUrl ? 'idle' : 'off'; this._phaseSince = Date.now(); this._attempts = 0; // consecutive connects since the last successful auth this._nextRetryAt = null; @@ -359,15 +407,20 @@ class RelayConnection { /** * Snapshot of the relay connection as the host should reason about it. - * phase: off | idle | connecting | authenticating | connected | reconnecting | refused | collision (4004) - * | duplicate-rejected (4006) — the last two are identity closes, retried on the long backoff - * (IDENTITY_CLOSES); `stopped` is the last one ({ code, kind, reason, at, closes, pingPong }), - * kept under its pre-0.14.3 name. + * phase: off | disabled | idle | connecting | authenticating | connected | reconnecting | refused + * | collision (4004) | duplicate-rejected (4006) — the last two are hard stops (HARD_STOPS), + * `stopped` says which ({ code, kind, reason, at, relayVerifiesKeys }); `disabled` is a relay + * setting that is not a relay URL (`disabled.reason`), and the node runs on the LAN only. + * relayVerifiesKeys: true when the relay took this node's key proof on its last answer, false when it + * authenticated it without one (a relay that does not verify keys), null before an answer. */ state() { return { url: this._relayUrl || null, phase: this._phase, + disabled: this._disabled, + relayVerifiesKeys: this._relayVerifiesKeys, + slotsIgnored: this._slotsIgnored, since: this._phaseSince, attempts: this._attempts, nextRetryAt: this._nextRetryAt, @@ -392,10 +445,15 @@ class RelayConnection { const inS = (t) => `${Math.max(0, Math.round((t - Date.now()) / 1000))}s`; switch (s.phase) { case 'off': return 'not configured (LAN only)'; + case 'disabled': + return `DISABLED: ${s.disabled.reason}, so the relay is off and this node runs on the LAN only. ` + + `Fix: set SYM_RELAY_URL (or the invite's relay) to a ws://, wss://, http:// or https:// URL and restart.`; case 'idle': return `configured: ${s.url} (not started)`; case 'connecting': return `connecting to ${s.url} (attempt ${s.attempts})`; case 'authenticating': return `authenticating with ${s.url} (attempt ${s.attempts})`; - case 'connected': return `connected to ${s.url} for ${ago(s.since)}, ${s.peers} relay peer(s)`; + case 'connected': + return `connected to ${s.url} for ${ago(s.since)}, ${s.peers} relay peer(s); ` + + (s.relayVerifiesKeys ? 'the relay verified this node\'s key' : 'this relay does not verify keys (any holder of its token can claim this node\'s nodeId there; upgrade the relay)'); case 'refused': return `REFUSED by ${s.url} (${s.refused.code}: ${s.refused.reason}) since ${ago(s.refused.at)} ago — ` + `the token this session presents is not accepted by that relay. Fix: mint a token with sym_invite_create ` + @@ -405,15 +463,10 @@ class RelayConnection { case 'duplicate-rejected': { const st = s.stopped || {}; const what = s.phase === 'collision' - ? `${s.url} replaced this connection with another that claims this identity (4004)` - : `${s.url} refused this connection because another live connection claims this identity (4006)`; - const retry = s.nextRetryAt ? `Retrying in ${inS(s.nextRetryAt)}` : 'Not reconnecting (identityRetry is off)'; - if (st.pingPong) { - return `PING-PONG: ${what}, ${st.closes} times in ${Math.round(PINGPONG_WINDOW_MS / 60000)} min: two live processes ` + - `hold this identity and replace each other. Stop one of them. ${retry}.`; - } - return `IDENTITY CLOSE: ${what}. Another process may hold this keypair, or a holder of the relay token ` + - `claimed this nodeId (relay-auth carries no key proof). ${retry}.`; + ? `${s.url} replaced this connection (4004)` + : `${s.url} refused this connection as a duplicate of another live one (4006)`; + return `STOPPED: ${what}: ${hardStopCause(st.relayVerifiesKeys)}. Not reconnecting — ` + + (st.relayVerifiesKeys ? 'stop the other process holding this identity, then restart this one.' : 'restart this node to rejoin, and upgrade the relay.'); } case 'reconnecting': { const last = s.lastClose ? `last close ${s.lastClose.code}${s.lastClose.reason ? `: ${s.lastClose.reason}` : ''}` : @@ -474,17 +527,23 @@ class RelayConnection { // the WebSocket opened, so a relay that accepted the socket and then closed it (1013 Try Again // Later, 4008, 1009) was dialled every second for as long as it did so. this._relayReconnectDelay = 1000; - // An identity-close episode ends with an auth the relay took; its history stays for the - // ping-pong detector (PINGPONG_WINDOW_MS), and state().lastClose still names the close. - this._identityCollision = false; - this._stopped = null; - this._authAt = Date.now(); + // The relay took this connection's auth: with this node's key proof when it had asked for one + // and this node answered it, without one otherwise (core/relay-auth-proof.js). + this._proof.proven = !!this._proof.nonce; + this._relayVerifiesKeys = this._proof.proven; + if (!this._proof.proven && !this._unverifiedSaid) { + this._unverifiedSaid = true; + this._log(`Relay ${this._relayUrl} does not verify keys (it took relay-auth without a key proof${this._proof.requested ? '' : '; this node has no key to prove'}): any holder of its token can claim this node's nodeId there and close this connection. Upgrade the relay (sym-relay ≥ 0.8.0) to have it verify keys.`); + } this._setPhase('connected'); // The relay's features (sym-relay 0.6.0 advertises fan-out here). Absent means an older relay: // every frame goes on its own, paced. // Unknown tokens are ignored; an absent list lists nothing. const features = Array.isArray(msg.features) ? msg.features.filter((f) => typeof f === 'string') : []; this._fanout = features.includes(FANOUT_FEATURE) ? { max: FANOUT_MAX } : null; + // `key-proof` (sym-relay 0.8.0): `toKey` is understood. A relay before it refuses a fan-out entry + // carrying one, so it is named only to a relay that lists this. + this._keyProofFeature = features.includes(KEY_PROOF_FEATURE); // A room's peer list is short. Like a peer's peer-info, the relay's is read for its first // PEER_INFO_MAX entries: a longer one (a faulty or compromised relay) cannot keep the event loop. // Its wake channels name OTHER nodes and come from the relay, so they are hints, never stored @@ -495,7 +554,7 @@ class RelayConnection { // Taken at the door (wire-identity.js): an entry that cannot be named by a string is skipped. const id = p && typeof p === 'object' ? wireNodeId(p.nodeId) : null; if (!id || p.offline) continue; - this._handleRelayPeerJoined(id, wireName(p.name)); + this._handleRelayPeerJoined(id, wireName(p.name), slotKeyOf(p)); } } @@ -506,7 +565,7 @@ class RelayConnection { * ever received categories in the clear could not be checked, because auth carried no * version) into a query on the relay's log. */ - _authFrame() { + _authFrame(nonce = null) { const identity = this._getIdentity(); const auth = { type: 'relay-auth', @@ -522,13 +581,47 @@ class RelayConnection { const room = this._getRoom(); if (typeof room === 'string' && room && room !== 'default') auth.room = room; if (this._relayToken) auth.token = this._relayToken; + // Key proof (core/relay-auth-proof.js): a node holding its identity key asks for a challenge, and + // answers one with the proof. A relay that does not know `keyProof` authenticates the first frame. + if (canProve(identity)) { + auth.keyProof = keyProof.KEY_PROOF; + if (nonce) { + auth.publicKey = identity.publicKey; + auth.proof = keyProof.signProof({ nonce, room: auth.room || 'default', nodeId: auth.nodeId, origin: keyProof.originOf(this._relayUrl) }, identity.privateKey); + } + } return auth; } + /** @private Send the relay-auth, remembered for relay-reauth. */ + _sendAuth(ws, nonce = null) { + const frame = this._authFrame(nonce); + if (frame.keyProof && !nonce) this._proof.requested = true; + this._authFrameSent = frame; + this._sendControl(ws, frame); + } + + /** + * The relay's challenge (core/relay-auth-proof.js): taken once per connection, only when this node + * asked for it and before the relay has taken an auth; answered with the proof. + * @private + */ + _onChallenge(ws, msg) { + if (!this._proof.requested || this._proof.nonce || this._phase === 'connected') return; + const nonce = keyProof.challengeNonce(msg.nonce); + if (msg.keyProof !== keyProof.KEY_PROOF || !nonce) { + this._log('Relay challenge refused: not a mmp-relay-auth-v1 challenge with a 32-byte nonce'); + return; + } + this._proof.nonce = nonce; + try { this._sendAuth(ws, nonce); } catch (err) { + this._log(`Relay key proof could not be made: ${err && err.message}`); + } + } + connect() { if (!this._isRunning() || !this._relayUrl) return; - // With identityRetry off, an identity close stops this client (MMP §4.4.7 as written). - if (this._identityCollision && !this._identityRetry) return; + if (this._identityCollision) return; // a hard stop (4004 / 4006): never reconnected on its own // One socket at a time: a dial while one is open or opening is not a second connection. if (this._relayWs && (this._relayWs.readyState === 0 || this._relayWs.readyState === 1)) return; if (this._relayReconnectTimer) { clearTimeout(this._relayReconnectTimer); this._relayReconnectTimer = null; } @@ -554,6 +647,8 @@ class RelayConnection { return; } this._relayWs = ws; + this._proof = { requested: false, nonce: null, proven: false }; // per connection + this._authFrameSent = null; const connection = this._connections = (this._connections || 0) + 1; // this connection, for lanes this._attempts++; this._nextRetryAt = null; @@ -585,7 +680,7 @@ class RelayConnection { this._sendControl(ws, { type: 'relay-pong' }); }, 20000); - this._sendControl(ws, this._authFrame()); + this._sendAuth(ws); }); ws.on('message', (data) => { @@ -647,22 +742,22 @@ class RelayConnection { // written to the socket: a frame sent with a `meta` (a record the node dispatched) goes back to // the node, which holds it for the peer's next session (R3-4); the rest is dropped. this._present.clear(); + this._slots.clear(); this._fanout = null; + this._keyProofFeature = false; const unflushed = this._takeUnflushed(); this._control.length = 0; if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; } try { this._onDisconnected(); } catch (err) { this._log(`Relay disconnect handling failed: ${err && err.message}`); } this._giveBackUnflushed(unflushed); - // MMP identity invariant: nodeId is bound to a keypair. A 4004 ("Replaced by new connection") - // or 4006 says another connection CLAIMS this nodeId — another process holding the same key - // (a restart race, an orphan), or anyone with the channel token who read the nodeId from the - // roster, since relay-auth proves no key. Reconnecting at once would fight a real second - // process every second; never reconnecting let a token holder switch this node off for good - // (R3-3). So: say it loudly, retry on the long backoff, and detect a ping-pong (IDENTITY_CLOSES). - const identityClose = IDENTITY_CLOSES[code]; - if (identityClose) { - this._identityClosed(code, identityClose, reasonStr); + // MMP identity invariant: nodeId is bound to a keypair. A 4004 or 4006 says another connection + // holds this node's slot. On a connection that proved its key only another holder of the key can + // have done it (the newest proof wins, once); on one that did not, anyone with the token could. + // Either way the client stops (MMP §4.4.7) and says which it was (HARD_STOPS). + const hard = HARD_STOPS[code]; + if (hard) { + this._hardStop(code, hard, reasonStr); return; } @@ -704,48 +799,29 @@ class RelayConnection { } /** - * An identity close (4004 / 4006): recorded, said, told to the host at the first of an episode and - * at a ping-pong, and retried on the long backoff (see IDENTITY_CLOSES). + * A hard stop (4004 / 4006): recorded, said once loudly with its cause, told to the host once, and + * never reconnected on its own (HARD_STOPS). * @private */ - _identityClosed(code, close, reasonStr) { + _hardStop(code, hard, reasonStr) { const id = this._getIdentity(); - const now = Date.now(); - this._identityCollision = true; - // A connection that outlasted PINGPONG_CLEAR_MS ends a ping-pong: the other process has gone. - if (this._pingPong && this._authAt && now - this._authAt > PINGPONG_CLEAR_MS) { this._pingPong = false; this._identityCloses = []; } - this._identityCloses = this._identityCloses.filter((t) => now - t < PINGPONG_WINDOW_MS); - this._identityCloses.push(now); - const closes = this._identityCloses.length; - const wasPingPong = this._pingPong; - if (closes >= PINGPONG_CLOSES) this._pingPong = true; - const pingPong = this._pingPong; - // The host is told at the first close in the window, and when a ping-pong is found. - const first = closes === 1; - const delay = !this._identityRetry ? null - : pingPong ? IDENTITY_RETRY_MAX_MS - : Math.min(this._identityRetryMs * 2 ** (closes - 1), IDENTITY_RETRY_MAX_MS); + const first = !this._identityCollision; + // Whether THIS connection had proved its key (the relay took the proof): only then is the cause known. + const relayVerifiesKeys = !!this._proof.proven; const reason = reasonStr.slice(0, 200); - this._stopped = { code, kind: close.kind, reason, at: now, closes, pingPong }; + this._identityCollision = true; + this._stopped = { code, kind: hard.kind, reason, at: Date.now(), relayVerifiesKeys }; this._nextRetryAt = null; - this._setPhase(close.phase); - const what = code === 4004 - ? 'replaced this connection with another that claims this nodeId' - : 'refused this connection: another live connection claims this nodeId'; - const then = delay === null ? 'Not reconnecting (identityRetry is off).' : `Retrying in ${Math.round(delay / 1000)}s.`; - if (pingPong) { - this._log(`FATAL: relay ${this._relayUrl} ${what} (${code}${reason ? `: ${reason}` : ''}; nodeId=${id.nodeId}, name=${this._nodeName}), ` + - `${closes} times in ${Math.round(PINGPONG_WINDOW_MS / 60000)} min: PING-PONG — two live processes hold this identity and replace each other. Stop one of them. ${then}`); - } else { - this._log(`IDENTITY CLOSE: relay ${this._relayUrl} ${what} (${code}${reason ? `: ${reason}` : ''}; nodeId=${id.nodeId}, name=${this._nodeName}). ` + - `Another process may hold this keypair, or a holder of the relay token claimed this nodeId. ${then}`); - } - if ((first || (pingPong && !wasPingPong)) && this._onIdentityCollision) { - try { this._onIdentityCollision({ nodeId: id.nodeId, name: this._nodeName, code, kind: close.kind, reason, pingPong, retryInMs: delay }); } catch (err) { + this._setPhase(hard.phase); + if (!first) return; + const what = code === 4004 ? 'replaced this connection' : 'refused this connection as a duplicate of another live one'; + this._log(`FATAL: relay ${this._relayUrl} ${what} (${code}${reason ? `: ${reason}` : ''}; nodeId=${id.nodeId}, name=${this._nodeName}): ` + + `${hardStopCause(relayVerifiesKeys)}. Not reconnecting.`); + if (this._onIdentityCollision) { + try { this._onIdentityCollision({ nodeId: id.nodeId, name: this._nodeName, code, kind: hard.kind, reason, relayVerifiesKeys }); } catch (err) { this._log(`onIdentityCollision callback threw: ${err.message}`); } } - if (delay !== null) this._scheduleReconnect(delay, { keepPhase: true }); } /** @@ -777,19 +853,22 @@ class RelayConnection { // prints them (see wire-identity.js): a peer that cannot be named by a string is not added. if (msg.type === 'relay-peer-joined') { const id = wireNodeId(msg.nodeId); - if (id) this._handleRelayPeerJoined(id, wireName(msg.name)); + if (id) this._handleRelayPeerJoined(id, wireName(msg.name), slotKeyOf(msg)); } else if (msg.type === 'relay-peer-left') { const id = wireNodeId(msg.nodeId); - if (id) this._handleRelayPeerLeft(id, wireName(msg.name)); + if (id) this._handleRelayPeerLeft(id, wireName(msg.name), slotKeyOf(msg)); } else if (msg.type === 'relay-peers') { this._handleRelayPeers(msg); + } else if (msg.type === 'relay-challenge') { + this._onChallenge(ws, msg); } else if (msg.type === 'relay-ping') { this._sendControl(ws, { type: 'relay-pong' }); } else if (msg.type === 'relay-reauth') { // Server lost our registration (e.g. relay restarted while TCP survived). - // Re-send auth to re-register without dropping the connection. + // Re-send auth to re-register without dropping the connection: the frame this connection last + // sent, its proof (over this connection's challenge) included. this._log('Relay requested re-auth — re-sending identity'); - this._sendControl(ws, this._authFrame()); + this._sendControl(ws, this._authFrameSent || this._authFrame()); } else if (msg.type === 'relay-error') { // { kind, code, message } as sym-relay sends them; each is printed only when it is what it // should be, and nothing else in the frame is turned into text (0.13.17). @@ -806,10 +885,46 @@ class RelayConnection { } } - _handleRelayPeerJoined(peerId, peerName) { + /** + * The slot of `nodeId` this node takes as that peer (a relay that verifies keys lists one per key + * that proved the nodeId, and one for a legacy claim): the slot proven for the key this node binds + * to the nodeId; else the legacy claim's (it may be the bound peer on an older engine); else, with + * no binding, the newest proven one. A slot proven for ANOTHER key than the bound one is never the + * peer: it is ignored, so an impostor that proved its own key under a member's nodeId is no + * candidate and its departure ends nothing. Null when no slot is the peer. + * @private + */ + _preferredSlot(nodeId) { + const slots = this._slots.get(nodeId); + if (!slots || !slots.size) return null; + let bound; + try { bound = this._keyOf(nodeId); } catch { bound = undefined; } + if (typeof bound === 'string' && bound) return slots.get(bound) || slots.get('') || null; + let newest = null; + for (const slot of slots.values()) if (slot.proven) newest = slot; + return newest || slots.get('') || null; + } + + /** + * The key to name in `toKey` for a directed frame to `nodeId`, or null: the key this node binds to + * it, when the relay understands `toKey` and lists a slot proven for exactly that key. Otherwise the + * frame goes to every slot of the nodeId (sealed end to end, so a slot that cannot open it gains + * nothing), which is also how it reaches a bound peer on an engine that proves no key. + * @private + */ + _toKeyFor(nodeId) { + if (!this._keyProofFeature) return null; + const slots = this._slots.get(nodeId); + if (!slots) return null; + let bound; + try { bound = this._keyOf(nodeId); } catch { bound = undefined; } + return typeof bound === 'string' && bound && slots.has(bound) ? bound : null; + } + + _handleRelayPeerJoined(peerId, peerName, slotKey = '') { const identity = this._getIdentity(); if (!peerId || peerId === identity.nodeId) return; - if (!this._present.has(peerId) && this._present.size >= this._maxAnnounced && !this._isKnown(peerId)) { + if (!this._present.has(peerId) && !this._slots.has(peerId) && this._slots.size >= this._maxAnnounced && !this._isKnown(peerId)) { // A candidate costs a handshake attempt: past the bound, an announcement for a nodeId not // already held is ignored (said once, counted in state().announcementsIgnored). One that // leaves (relay-peer-left, or the relay link closing) frees its place. @@ -817,13 +932,47 @@ class RelayConnection { if (this._announcementsIgnored === 1) this._log(`Relay: ${this._maxAnnounced} announced peers held; announcements for more are ignored (counted in relay state)`); return; } + let slots = this._slots.get(peerId); + if (!slots) { slots = new Map(); this._slots.set(peerId, slots); } + if (!slots.has(slotKey) && slots.size >= MAX_SLOTS_PER_NODE) { + this._slotsIgnored++; + if (this._slotsIgnored === 1) this._log(`Relay: ${MAX_SLOTS_PER_NODE} keys already listed for one nodeId; announcements of more are ignored (counted in relay state)`); + return; + } + // Re-inserted, so the newest announcement of a slot is last (_preferredSlot's "newest proven"). + slots.delete(slotKey); + const slot = { name: peerName, key: slotKey || null, proven: !!slotKey }; + slots.set(slotKey, slot); + const preferred = this._preferredSlot(peerId); + if (preferred !== slot) { + // Another slot is this nodeId's peer (or none is: a key other than the bound one). Nothing + // changes for the peer; a slot proven for a key this node does not bind is never a candidate. + if (!preferred) this._present.delete(peerId); + return; + } this._present.set(peerId, peerName); try { this._onPeerPresent(peerId, peerName); } catch (err) { this._log(`Relay peer-joined handling failed: ${err && err.message}`); } } - _handleRelayPeerLeft(peerId, peerName) { + _handleRelayPeerLeft(peerId, peerName, slotKey = '') { + const slots = this._slots.get(peerId); + const before = slots ? this._preferredSlot(peerId) : null; + if (slots) { + slots.delete(slotKey); + if (!slots.size) this._slots.delete(peerId); + } + const after = this._slots.has(peerId) ? this._preferredSlot(peerId) : null; + // The peer this node took for the nodeId is still there: another slot left (an impostor's key, a + // legacy claim beside a proven peer), and nothing ends. + if (before && after === before) return; this._present.delete(peerId); try { this._onPeerGone(peerId, peerName); } catch (err) { this._log(`Relay peer-left error for ${peerName || peerId}: ${err && err.message}`); } + if (after) { + // The slot taken for the peer left and another remains (a legacy claim behind a proven peer): + // that one is the candidate now, as a fresh announcement. + this._present.set(peerId, after.name); + try { this._onPeerPresent(peerId, after.name); } catch (err) { this._log(`Relay peer-joined handling failed: ${err && err.message}`); } + } } /** @@ -872,13 +1021,16 @@ class RelayConnection { sendTo(to, payload, meta) { const ws = this._relayWs; if (!ws || ws.readyState !== 1) return { ok: false, reason: SEND_FAILURE.NOT_CONNECTED }; + // `toKey` (sym-relay 0.8.0): the frame goes only to the slot proven for the key this node binds to + // `to` (_toKeyFor). Decided now, for this connection: a frame never outlives its connection. + const toKey = this._toKeyFor(to); let data; - try { data = JSON.stringify({ to, payload }); } catch { return { ok: false, reason: SEND_FAILURE.WRITE_FAILED }; } + try { data = JSON.stringify(toKey ? { to, toKey, payload } : { to, payload }); } catch { return { ok: false, reason: SEND_FAILURE.WRITE_FAILED }; } const bytes = Buffer.byteLength(data, 'utf8'); // MMP §4.1: senders MUST NOT produce frames over MAX_FRAME_SIZE; the relay would close the whole // shared connection (1009) rather than drop one frame. if (bytes > MAX_FRAME_SIZE) return { ok: false, reason: SEND_FAILURE.TOO_LARGE, bytes }; - const refused = this._queue.push(classOf(payload), { to, payload, data, bytes, meta: meta || null }); + const refused = this._queue.push(classOf(payload), { to, toKey, payload, data, bytes, meta: meta || null }); if (refused) return { ok: false, reason: refused, bytes }; this._scheduleFlush(); return { ok: true, bytes }; @@ -928,7 +1080,7 @@ class RelayConnection { size += q.bytes + 1; } if (!batch.length) batch.push(this._queue.take()); - data = batch.length === 1 ? batch[0].data : JSON.stringify({ fanout: batch.map((q) => ({ to: q.to, payload: q.payload })) }); + data = batch.length === 1 ? batch[0].data : JSON.stringify({ fanout: batch.map((q) => (q.toKey ? { to: q.to, toKey: q.toKey, payload: q.payload } : { to: q.to, payload: q.payload })) }); if (batch.length > 1) this._sentFanout++; taken = batch; } else { @@ -988,7 +1140,7 @@ class RelayConnection { * @param {number} [fixedDelayMs] — use this delay instead of the exponential backoff and * leave the backoff state untouched (the slow retry after an auth refusal). */ - _scheduleReconnect(fixedDelayMs, { keepPhase = false } = {}) { + _scheduleReconnect(fixedDelayMs) { if (!this._isRunning() || !this._relayUrl) return; const base = fixedDelayMs || this._relayReconnectDelay; @@ -997,9 +1149,9 @@ class RelayConnection { this._log(`Relay reconnecting in ${Math.round(delay / 1000)}s`); this._nextRetryAt = Date.now() + delay; - // A refusal keeps its phase (the slow retry is part of the episode), and so does an identity - // close; everything else is "reconnecting" until the next outcome. - if (this._phase !== 'refused' && !keepPhase) this._setPhase('reconnecting'); + // A refusal keeps its phase (the slow retry is part of the episode); everything else + // is "reconnecting" until the next outcome. + if (this._phase !== 'refused') this._setPhase('reconnecting'); if (this._relayReconnectTimer) clearTimeout(this._relayReconnectTimer); this._relayReconnectTimer = setTimeout(() => { this._relayReconnectTimer = null; this.connect(); }, delay); @@ -1020,6 +1172,7 @@ class RelayConnection { if (this._flushTimer) { clearTimeout(this._flushTimer); this._flushTimer = null; } this._present.clear(); + this._slots.clear(); const unflushed = this._takeUnflushed(); this._control.length = 0; @@ -1030,20 +1183,14 @@ class RelayConnection { try { if (ws.readyState === 0) ws.terminate(); else ws.close(1000, 'node stopped'); } catch { /* already gone */ } } this._nextRetryAt = null; - // The last identity close stays in state().stopped; the phase is idle, since nothing is pending. - // With identityRetry off, the stop outlives destroy() as it did before 0.14.3: the identity fact - // behind it does not change with a restart of this node object. - if (this._identityRetry || !this._stopped) { - this._identityCollision = false; - this._setPhase(this._relayUrl ? 'idle' : 'off'); - } else { - this._setPhase(IDENTITY_CLOSES[this._stopped.code].phase); - } + // A hard stop outlives destroy(): the identity fact behind it does not change with a restart of + // this node object (state().stopped keeps the reason). So does a disabled relay setting. + this._setPhase(this._stopped ? HARD_STOPS[this._stopped.code].phase : this._disabled ? 'disabled' : (this._relayUrl ? 'idle' : 'off')); this._giveBackUnflushed(unflushed); } } module.exports = { - RelayConnection, RELAY_RATE_PER_SECOND, RELAY_BURST, FANOUT_FEATURE, FANOUT_MAX, HARD_STOPS, IDENTITY_CLOSES, FairQueue, CLASS2_MAX, - RELAY_CONNECTION_MAX, MESSAGE_MAX, PINGPONG_CLOSES, PINGPONG_WINDOW_MS, IDENTITY_RETRY_MS, IDENTITY_RETRY_MAX_MS, isRelayUrl, assertRelayUrl, + RelayConnection, RELAY_RATE_PER_SECOND, RELAY_BURST, FANOUT_FEATURE, FANOUT_MAX, HARD_STOPS, FairQueue, CLASS2_MAX, + RELAY_CONNECTION_MAX, MESSAGE_MAX, KEY_PROOF_FEATURE, MAX_SLOTS_PER_NODE, isRelayUrl, normalizeRelayUrl, relayUrlProblem, hardStopCause, }; diff --git a/tests/node-lifecycle.test.js b/tests/node-lifecycle.test.js index 11f7411b..7536e9d8 100644 --- a/tests/node-lifecycle.test.js +++ b/tests/node-lifecycle.test.js @@ -161,11 +161,22 @@ describe('node lifecycle: one state machine on one chain (R3-5, R3-9, R3-11)', ( } finally { await cleanup(n); } }); - it('a relay URL ws cannot parse is refused in the constructor and in an invite, never at start() (R3-11)', () => { - assert.throws(() => new SymNode({ name: uniq('bad-url'), silent: true, relay: 'relay.example.com', relayToken: 'x'.repeat(40), discovery: new NullDiscovery() }), { code: 'EBADRELAY' }); - assert.throws(() => new SymNode({ name: uniq('bad-url'), silent: true, relay: 'https://relay.example.com', discovery: new NullDiscovery() }), { code: 'EBADRELAY' }); + it('a relay setting ws cannot parse never fails the constructor or start(): the relay is disabled (R3-11, B1)', async () => { + // R3-11 found a URL ws could not parse throwing from start(), after the node had half started. The + // first fix threw EBADRELAY from the constructor instead, which stopped the daemon and mesh-channel + // at startup for an https:// URL (B1). The relay is an optional transport: a setting that is not a + // relay URL disables it, said in status(), and the node starts (tests/relay-url-setting.test.js). + const n = new SymNode({ name: uniq('bad-url'), silent: true, relay: 'relay.example.com', relayToken: 'x'.repeat(40), discovery: new NullDiscovery() }); + try { + await n.start(); + assert.strictEqual(n.status().running, true); + assert.strictEqual(n.status().relayState.phase, 'disabled'); + } finally { await cleanup(n); } + const https = new SymNode({ name: uniq('https-url'), silent: true, relay: 'https://relay.example.com', discovery: new NullDiscovery() }); + assert.strictEqual(https.status().relay, 'wss://relay.example.com', 'https:// is the same relay as wss://'); + await cleanup(https); const bad = parseInvite('sym://team/r?relay=relay.example.com&token=t'); - assert.ok(bad.error && /relay/.test(bad.error), `an invite naming a relay that is not a ws:// or wss:// URL is refused (${bad.error})`); + assert.ok(bad.error && /relay/.test(bad.error), `an invite naming a relay that is not a URL is refused (${bad.error})`); const good = parseInvite(`sym://team/r?relay=${encodeURIComponent('wss://relay.example.com')}&token=t`); assert.strictEqual(good.error, undefined); assert.strictEqual(good.relayUrl, 'wss://relay.example.com'); diff --git a/tests/relay-auth-proof-v1.json b/tests/relay-auth-proof-v1.json new file mode 100644 index 00000000..11e603f5 --- /dev/null +++ b/tests/relay-auth-proof-v1.json @@ -0,0 +1,27 @@ +{ + "scheme": "mmp-relay-auth-v1", + "description": "relay-auth key proof (sym-relay 0.8.0, sym 0.14.3). transcript = lp(\"mmp-relay-auth-v1\") || lp(nonce) || lp(room) || lp(nodeId) || lp(origin), lp(x) = 4-byte big-endian length || x; room is the room the relay keys the slot by (default when relay-auth names none); origin is the WHATWG URL origin of the URL the client dialed. proof = Ed25519 (RFC 8032, pure) over the transcript by the identity key, canonical unpadded base64url. The same file is in both repositories.", + "seedHex": "eb40d030716aea1147e3ce220aafce4812400611820a8bd611ac730a0fb33eed", + "publicKey": "THlVSk-UL9bK5MsBDG4O4a_wO005JePE1_Ez-vsai70", + "nonce": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + "cases": [ + { + "label": "default room, wss origin with the default port elided", + "dialed": "wss://Relay.Example.org:443/mesh?x=1", + "room": "default", + "nodeId": "01900000-0000-7000-8000-000000000001", + "origin": "wss://relay.example.org", + "transcriptHex": "000000116d6d702d72656c61792d617574682d763100000020000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000000764656661756c740000002430313930303030302d303030302d373030302d383030302d303030303030303030303031000000177773733a2f2f72656c61792e6578616d706c652e6f7267", + "proof": "gecL8RRGpuy06p5BKKKkDSe4BPQSIGiCD0g2X0N7zLQViSiUC0FuWEotrc81S8H58G5uJxtS_WnRlLukS8vIBg" + }, + { + "label": "a named room, ws origin with a port", + "dialed": "ws://127.0.0.1:8787", + "room": "team-alpha", + "nodeId": "01900000-0000-7000-8000-000000000002", + "origin": "ws://127.0.0.1:8787", + "transcriptHex": "000000116d6d702d72656c61792d617574682d763100000020000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f0000000a7465616d2d616c7068610000002430313930303030302d303030302d373030302d383030302d3030303030303030303030320000001377733a2f2f3132372e302e302e313a38373837", + "proof": "huIMqmedYAYs46gKZ2DK1Kdmm-ZPDOjV7T0ZJ_oA9EVih1C8FCqygZf9ky2jnxzU8IRu4S1rBlGz3hXo9jtyDQ" + } + ] +} diff --git a/tests/relay-key-proof.test.js b/tests/relay-key-proof.test.js new file mode 100644 index 00000000..4c0b1bbb --- /dev/null +++ b/tests/relay-key-proof.test.js @@ -0,0 +1,360 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME before lib/config loads + +/** + * RELAY-AUTH KEY PROOF, the client (core/relay-auth-proof.js; sym-relay 0.8.0 verifies it). + * + * Until now a relay keyed this node's slot by a nodeId it only claimed, so any holder of the channel + * token could claim it and have this node closed (4004) or refused (4006), and a 0.14.3 candidate's + * answer was to retry those closes with a ping-pong detector: a patch over an identity nobody + * verified, removed here. These pin the client's half of the design: + * + * - it asks for a challenge (`keyProof`) and signs it with its identity key, over the nonce, the + * room, the nodeId and the origin of the URL IT dialed; the vector is shared with sym-relay; + * - a relay that never challenges (sym-relay before 0.8.0) takes the first frame as it always did; + * the node connects at once and records relayVerifiesKeys: false, said once; + * - 4004 and 4006 stop it for good (MMP §4.4.7), said with their cause: on a connection whose key + * the relay verified, another process proved this identity's key; otherwise the relay does not + * verify keys and any token holder could have done it. The event is { nodeId, name, code, kind, + * reason, relayVerifiesKeys }; + * - directed frames name `toKey` when the relay understands it and lists a slot proven for the key + * this node binds to the recipient; roster entries for that key are the peer, an impostor's key + * is never a candidate, and its departure ends nothing. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { WebSocketServer } = require('ws'); +const { RelayConnection } = require('../lib/relay'); +const proof = require('../lib/core/relay-auth-proof'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { identity, until } = require('./_core-secure'); + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const TOKEN = 'p'.repeat(40); + +/** + * A relay for these tests. `verifies`: it answers keyProof with a relay-challenge and checks the + * proof as sym-relay 0.8.0 does (the origin it was dialled at), listing `key-proof`; otherwise it is a + * relay before 0.8.0 and takes the first relay-auth. `after(ws, auth, n)` runs once the n-th + * connection is admitted. Every frame received is kept. + */ +function relay({ verifies = true, after = null, peers = [] } = {}) { + const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + const port = () => wss.address().port; + const st = { dials: 0, frames: [], proofs: [], admitted: 0, refused: 0, sockets: [] }; + wss.on('connection', (ws) => { + st.dials++; + st.sockets.push(ws); + let nonce = null; let admitted = false; + ws.on('message', (d) => { + const m = JSON.parse(String(d)); + st.frames.push(m); + if (m.type !== 'relay-auth' || admitted) return; + if (verifies && m.keyProof === proof.KEY_PROOF && !m.proof) { + nonce = crypto.randomBytes(32); + ws.send(JSON.stringify({ type: 'relay-challenge', keyProof: proof.KEY_PROOF, nonce: nonce.toString('base64url') })); + return; + } + if (verifies && m.proof) { + const origin = `ws://127.0.0.1:${port()}`; + const ok = !!nonce && proof.verifyProof({ nonce, room: m.room || 'default', nodeId: m.nodeId, origin }, m.publicKey, m.proof); + st.proofs.push({ ok, origin, publicKey: m.publicKey }); + if (!ok) { st.refused++; ws.close(4002, 'Invalid key proof'); return; } + } + admitted = true; + st.admitted++; + ws.send(JSON.stringify({ type: 'relay-peers', peers, ...(verifies ? { features: ['fanout', 'key-proof'] } : { features: ['fanout'] }) })); + if (after) after(ws, m, st.admitted); + }); + }); + return { + st, ready: new Promise((r) => wss.once('listening', r)), + url: () => `ws://127.0.0.1:${port()}`, + close: () => new Promise((r) => { for (const c of wss.clients) c.terminate(); wss.close(() => r()); }), + }; +} + +function client(url, id, opts = {}) { + const logs = []; + const told = []; + const present = []; + const gone = []; + let running = true; + const rc = new RelayConnection({ + relayUrl: url, + relayToken: TOKEN, + log: (line) => logs.push(line), + getIdentity: () => id, + isRunning: () => running, + nodeName: 'proof-test', + onIdentityCollision: (info) => told.push(info), + onPeerPresent: (nodeId, name) => present.push({ nodeId, name }), + onPeerGone: (nodeId, name) => gone.push({ nodeId, name }), + ...opts, + }); + return { rc, logs, told, present, gone, stop: () => { running = false; rc.destroy(); } }; +} + +test('the shared vector: the transcript and the proof are what sym-relay verifies (tests/relay-auth-proof-v1.json)', () => { + const file = path.join(__dirname, 'relay-auth-proof-v1.json'); + const raw = fs.readFileSync(file); + // The same bytes as sym-relay's test/fixtures/relay-auth-proof-v1.json. + assert.equal(crypto.createHash('sha256').update(raw).digest('hex'), '2da01ece220d02114b45c21e465176dd3f2069d96fb70b30aec1d592d726c1d5'); + const v = JSON.parse(raw); + const nonce = Buffer.from(v.nonce, 'base64url'); + const priv = Buffer.from(v.seedHex, 'hex').toString('base64url'); + for (const c of v.cases) { + assert.equal(proof.originOf(c.dialed), c.origin); + assert.equal(proof.transcript({ nonce, room: c.room, nodeId: c.nodeId, origin: c.origin }).toString('hex'), c.transcriptHex); + assert.equal(proof.signProof({ nonce, room: c.room, nodeId: c.nodeId, origin: c.origin }, priv), c.proof); + assert.equal(proof.verifyProof({ nonce, room: c.room, nodeId: c.nodeId, origin: c.origin }, v.publicKey, c.proof), true); + } +}); + +test('a node holding its key asks for a challenge and answers it with a proof for the URL it dialed; the relay verified it', async () => { + const r = relay(); + await r.ready; + const id = identity('prover'); + const c = client(r.url(), id, { getRoom: () => 'team-x' }); + try { + c.rc.connect(); + const s = await c.rc.awaitOutcome(5000); + assert.equal(s.phase, 'connected'); + assert.equal(s.relayVerifiesKeys, true); + const [ask, answer] = r.st.frames.filter((f) => f.type === 'relay-auth'); + assert.equal(ask.keyProof, proof.KEY_PROOF); + assert.equal(ask.proof, undefined, 'the first frame asks; it proves nothing'); + assert.equal(ask.token, TOKEN, 'and is a whole relay-auth, which a relay before 0.8.0 takes as it is'); + assert.equal(answer.publicKey, id.publicKey); + assert.equal(answer.room, 'team-x'); + assert.deepEqual(r.st.proofs.map((p) => p.ok), [true], 'the proof verifies, over room team-x and the dialed origin'); + assert.match(c.rc.describe(), /the relay verified this node's key$/); + assert.ok(!c.logs.some((l) => l.includes(TOKEN)), 'the token is never logged'); + } finally { c.stop(); await r.close(); } +}); + +test('http:// is the same relay as ws://: dialled as ws://, and the proof names that origin', async () => { + const r = relay(); + await r.ready; + const c = client(r.url().replace(/^ws:/, 'http:'), identity('http')); + try { + assert.equal(c.rc.state().url, r.url()); + c.rc.connect(); + assert.equal((await c.rc.awaitOutcome(5000)).relayVerifiesKeys, true); + } finally { c.stop(); await r.close(); } +}); + +test('a relay that never challenges (sym-relay before 0.8.0) takes the first frame: connected at once, relayVerifiesKeys false, said once', async () => { + const r = relay({ verifies: false }); + await r.ready; + const c = client(r.url(), identity('old-relay')); + try { + const t0 = Date.now(); + c.rc.connect(); + const s = await c.rc.awaitOutcome(5000); + assert.equal(s.phase, 'connected'); + assert.ok(Date.now() - t0 < 1500, 'no wait for a challenge that never comes'); + assert.equal(s.relayVerifiesKeys, false); + assert.equal(r.st.frames.filter((f) => f.type === 'relay-auth').length, 1, 'one relay-auth, the one the old relay took'); + assert.match(c.rc.describe(), /this relay does not verify keys/); + // Reconnect: said once per connection object, not on every connect. + for (const ws of r.st.sockets) ws.close(1001, 'restart'); + assert.ok(await until(() => r.st.admitted === 2, 5000)); + assert.equal(c.logs.filter((l) => /does not verify keys/.test(l)).length, 1); + } finally { c.stop(); await r.close(); } +}); + +test('a relay that does not verify keys may not be sent toKey: a fan-out entry carrying it is malformed there', async () => { + const peerKey = identity('pk').publicKey; + const P = '01900000-0000-7000-8000-0000000000e1'; + const r = relay({ verifies: false, peers: [{ nodeId: P, name: 'peer' }] }); + await r.ready; + const c = client(r.url(), identity('nokey'), { keyOf: () => peerKey }); + try { + c.rc.connect(); + await c.rc.awaitOutcome(5000); + c.rc.sendTo(P, { type: 'cmb-encrypted', n: 1 }); + assert.ok(await until(() => r.st.frames.some((f) => f.payload && f.payload.n === 1), 3000)); + assert.equal(r.st.frames.find((f) => f.payload && f.payload.n === 1).toKey, undefined); + } finally { c.stop(); await r.close(); } +}); + +for (const [label, verifies, cause] of [ + ['the relay verified this connection\'s key', true, /another process proved this identity's key on this relay; this one stopped/], + ['the relay does not verify keys', false, /this relay does not verify keys, so any holder of the token can cause this; upgrade the relay/], +]) { + test(`4004 when ${label}: stopped for good, said once with its cause, told to the host once`, async () => { + const r = relay({ verifies, after: (ws) => setTimeout(() => ws.close(4004, 'Replaced: this identity\'s key connected again (key proven)'), 30) }); + await r.ready; + const id = identity('stopped'); + const c = client(r.url(), id); + try { + c.rc.connect(); + assert.ok(await until(() => c.rc.state().phase === 'collision', 5000)); + await wait(1500); // past the first ordinary backoff (1 s) + assert.equal(r.st.dials, 1, 'never redialled'); + const s = c.rc.state(); + assert.equal(s.nextRetryAt, null, 'nothing scheduled'); + assert.equal(s.stopped.code, 4004); + assert.equal(s.stopped.relayVerifiesKeys, verifies); + assert.match(c.rc.describe(), /^STOPPED: .*\(4004\): /); + assert.match(c.rc.describe(), cause); + assert.equal(c.logs.filter((l) => l.startsWith('FATAL')).length, 1, 'said once, loudly'); + assert.match(c.logs.find((l) => l.startsWith('FATAL')), cause); + assert.deepEqual(c.told, [{ nodeId: id.nodeId, name: 'proof-test', code: 4004, kind: 'replaced', reason: 'Replaced: this identity\'s key connected again (key proven)', relayVerifiesKeys: verifies }]); + c.rc.connect(); + await wait(200); + assert.equal(r.st.dials, 1, 'a later connect() does not dial either'); + c.rc.destroy(); + assert.equal(c.rc.state().phase, 'collision', 'destroy() does not hide the stop'); + } finally { c.stop(); await r.close(); } + }); +} + +test('4006 (a relay that does not verify keys) stops the same way', async () => { + const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + await new Promise((r) => wss.once('listening', r)); + let dials = 0; + wss.on('connection', (ws) => { dials++; ws.on('message', () => ws.close(4006, 'Existing connection too fresh — duplicate identity rejected')); }); + const c = client(`ws://127.0.0.1:${wss.address().port}`, identity('dup')); + try { + c.rc.connect(); + const s = await c.rc.awaitOutcome(5000); + assert.equal(s.phase, 'duplicate-rejected'); + await wait(1500); + assert.equal(dials, 1); + assert.equal(c.told.length, 1); + assert.equal(c.told[0].code, 4006); + assert.equal(c.told[0].relayVerifiesKeys, false); + assert.match(c.rc.describe(), /\(4006\): this relay does not verify keys/); + } finally { c.stop(); await new Promise((r) => { for (const x of wss.clients) x.terminate(); wss.close(() => r()); }); } +}); + +test('toKey names the bound key when the relay lists a slot proven for it; fan-out entries carry it too', async () => { + const P = '01900000-0000-7000-8000-0000000000e2', Q = '01900000-0000-7000-8000-0000000000e3'; + const bound = identity('bound').publicKey, impostor = identity('imp').publicKey; + const r = relay({ peers: [ + { nodeId: P, name: 'peer', key: bound, proven: true }, + { nodeId: P, name: 'impostor', key: impostor, proven: true }, + { nodeId: Q, name: 'legacy-peer' }, + ] }); + await r.ready; + const keys = new Map([[P, bound], [Q, identity('q').publicKey]]); + const c = client(r.url(), identity('sender'), { keyOf: (n) => keys.get(n) }); + try { + c.rc.connect(); + await c.rc.awaitOutcome(5000); + c.rc.sendTo(P, { type: 'cmb-encrypted', n: 1 }); + assert.ok(await until(() => r.st.frames.some((f) => f.payload && f.payload.n === 1), 3000)); + assert.equal(r.st.frames.find((f) => f.payload && f.payload.n === 1).toKey, bound, 'the bound key'); + c.rc.sendTo(Q, { type: 'cmb-encrypted', n: 2 }); + assert.ok(await until(() => r.st.frames.some((f) => f.payload && f.payload.n === 2), 3000)); + assert.equal(r.st.frames.find((f) => f.payload && f.payload.n === 2).toKey, undefined, 'a peer listed without a proven slot for its key is reached without toKey'); + // Two in one turn go as one fan-out; each entry names its own key or none. + c.rc.sendTo(P, { type: 'cmb-encrypted', n: 3 }); + c.rc.sendTo(Q, { type: 'cmb-encrypted', n: 4 }); + assert.ok(await until(() => r.st.frames.some((f) => Array.isArray(f.fanout)), 3000)); + const fan = r.st.frames.find((f) => Array.isArray(f.fanout)).fanout; + assert.deepEqual(fan.map((e) => [e.to, e.toKey]), [[P, bound], [Q, undefined]]); + } finally { c.stop(); await r.close(); } +}); + +test('the bound key\'s slot is the peer: an impostor\'s key is no candidate, and its departure ends nothing', async () => { + const P = '01900000-0000-7000-8000-0000000000e4'; + const bound = identity('b').publicKey, impostor = identity('i').publicKey; + let relaySocket = null; + const r = relay({ after: (ws) => { relaySocket = ws; } }); + await r.ready; + const c = client(r.url(), identity('watcher'), { keyOf: (n) => (n === P ? bound : undefined) }); + const announce = (type, name, key) => relaySocket.send(JSON.stringify({ type, nodeId: P, name, ...(key ? { key, proven: true } : {}) })); + try { + c.rc.connect(); + await c.rc.awaitOutcome(5000); + announce('relay-peer-joined', 'impostor', impostor); + await wait(100); + assert.equal(c.rc.present.has(P), false, 'only a key other than the bound one: not the peer'); + assert.equal(c.present.length, 0); + announce('relay-peer-joined', 'member', bound); + assert.ok(await until(() => c.present.length === 1, 2000)); + assert.equal(c.rc.present.get(P), 'member'); + announce('relay-peer-joined', 'impostor-2', identity('i2').publicKey); + announce('relay-peer-left', 'impostor', impostor); + await wait(100); + assert.equal(c.gone.length, 0, 'an impostor leaving ends nothing'); + assert.equal(c.rc.present.get(P), 'member', 'and renames nothing'); + announce('relay-peer-left', 'member', bound); + assert.ok(await until(() => c.gone.length === 1, 2000), 'the member leaving is the peer gone'); + assert.equal(c.rc.present.has(P), false); + } finally { c.stop(); await r.close(); } +}); + +test('with no binding the newest proven slot is the candidate; a legacy claim behind a proven peer takes over when it leaves', async () => { + const P = '01900000-0000-7000-8000-0000000000e5'; + const k1 = identity('k1').publicKey; + let sock = null; + const r = relay({ after: (ws) => { sock = ws; } }); + await r.ready; + const c = client(r.url(), identity('w2')); + try { + c.rc.connect(); + await c.rc.awaitOutcome(5000); + sock.send(JSON.stringify({ type: 'relay-peer-joined', nodeId: P, name: 'legacy' })); + sock.send(JSON.stringify({ type: 'relay-peer-joined', nodeId: P, name: 'proven', key: k1, proven: true })); + assert.ok(await until(() => c.rc.present.get(P) === 'proven', 2000)); + sock.send(JSON.stringify({ type: 'relay-peer-left', nodeId: P, name: 'legacy' })); + await wait(100); + assert.equal(c.gone.length, 0, 'the legacy claim leaving ends nothing while the proven one is the peer'); + sock.send(JSON.stringify({ type: 'relay-peer-joined', nodeId: P, name: 'legacy' })); + sock.send(JSON.stringify({ type: 'relay-peer-left', nodeId: P, name: 'proven', key: k1, proven: true })); + assert.ok(await until(() => c.gone.length === 1 && c.rc.present.get(P) === 'legacy', 2000), 'the proven peer gone, the legacy claim is the candidate now'); + } finally { c.stop(); await r.close(); } +}); + +test('a challenge this node did not ask for, or one that is malformed, is ignored', async () => { + const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + await new Promise((r) => wss.once('listening', r)); + const frames = []; + wss.on('connection', (ws) => { + ws.on('message', (d) => { + frames.push(JSON.parse(String(d))); + if (frames.length === 1) ws.send(JSON.stringify({ type: 'relay-challenge', keyProof: proof.KEY_PROOF, nonce: 'short' })); + if (frames.length === 1) setTimeout(() => ws.send(JSON.stringify({ type: 'relay-peers', peers: [] })), 100); + }); + }); + const unasked = client(`ws://127.0.0.1:${wss.address().port}`, { nodeId: '01900000-0000-7000-8000-0000000000e6' }); + try { + unasked.rc.connect(); + const s = await unasked.rc.awaitOutcome(5000); + assert.equal(s.phase, 'connected'); + assert.equal(frames.length, 1, 'no proof sent: a node without its key pair asks for none, and a malformed challenge is not answered'); + assert.equal(frames[0].keyProof, undefined); + assert.equal(s.relayVerifiesKeys, false); + } finally { unasked.stop(); await new Promise((r) => { for (const x of wss.clients) x.terminate(); wss.close(() => r()); }); } +}); + +test('a SymNode proves its key, reports it in status(), and its identity-collision event has the restored shape', async () => { + const r = relay({ after: (ws, auth, n) => { if (n === 1) setTimeout(() => ws.close(4004, 'Replaced: this identity\'s key connected again (key proven)'), 300); } }); + await r.ready; + const n = new SymNode({ name: `proof-node-${process.pid}`, silent: true, discovery: new NullDiscovery(), relay: r.url(), relayToken: TOKEN }); + const events = []; + const metrics = []; + n.on('identity-collision', (e) => events.push(e)); + n.on('metric', (m) => { if (/^relay-/.test(m.type)) metrics.push(m); }); + try { + await n.start(); + assert.ok(await until(() => n.status().relayState.phase === 'connected', 5000)); + assert.equal(n.status().relayState.relayVerifiesKeys, true); + assert.deepEqual(r.st.proofs.map((p) => [p.ok, p.publicKey]), [[true, n._identity.publicKey]]); + assert.ok(await until(() => events.length === 1, 5000)); + assert.deepEqual(Object.keys(events[0]).sort(), ['code', 'kind', 'name', 'nodeId', 'reason', 'relayVerifiesKeys']); + assert.equal(events[0].relayVerifiesKeys, true); + assert.deepEqual(metrics.map((m) => m.type), ['relay-hard-stop']); + assert.match(n.status().relayStatus, /another process proved this identity's key on this relay; this one stopped/); + } finally { await n.stop(); await r.close(); } +}); diff --git a/tests/relay-resilience.test.js b/tests/relay-resilience.test.js index 577728a2..34748a99 100644 --- a/tests/relay-resilience.test.js +++ b/tests/relay-resilience.test.js @@ -9,9 +9,8 @@ require('./_isolate-home'); // redirect $HOME before lib/config loads * connection, again on every reconnect. Now it is dropped on its own and counted; * R3-10 — the backoff started over when the socket opened, not when the relay took the auth, * so a relay that closed after the open (1013, 4008, 1009) was dialled every second; - * R3-3 — 4004 and 4006 stopped the client for good, so any token holder could switch a member - * off by claiming its nodeId once. Now a loud retry on a long backoff with a ping-pong - * detector (`identityRetry: false` keeps MMP §4.4.7 as written); + * (R3-3, 4004 and 4006 as permanent stops a token holder could cause, is answered by relay-auth key + * proof, not by retrying: tests/relay-key-proof.test.js); * R3-15 — the liveness timeout closed politely (ws.close), which waits for a closing handshake a * dead connection never sends; it terminates now; * R3-13 — stop() destroyed the relay before it closed the sessions, so a relay peer was never @@ -22,7 +21,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const { WebSocketServer } = require('ws'); -const { RelayConnection, IDENTITY_CLOSES } = require('../lib/relay'); +const { RelayConnection } = require('../lib/relay'); const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDirById } = require('../lib/config'); @@ -113,67 +112,6 @@ test('R3-10: a relay that takes the socket and then closes (1013) is redialled o } finally { c.stop(); await relay.close(); } }); -for (const code of [4004, 4006]) { - test(`R3-3: an identity close (${code}) is retried on the long backoff, said loudly; the member comes back once the claimant has gone`, async () => { - const relay = relayAnswering((ws, n) => { if (n === 1) ws.close(code, 'claimed'); else ws.send(peersFrame()); }); - const told = []; - const c = client(relay.url, { identityRetryMs: 300, onIdentityCollision: (i) => told.push(i) }); - try { - c.rc.connect(); - assert.ok(await until(() => c.rc.state().phase === (code === 4004 ? 'collision' : 'duplicate-rejected'), 3000)); - const s = c.rc.state(); - assert.ok(s.nextRetryAt > Date.now(), 'a retry is scheduled: no permanent stop'); - assert.equal(s.stopped.code, code); - assert.match(c.rc.describe(), /^IDENTITY CLOSE: .*Retrying in \d+s\.$/); - assert.ok(c.logs.some((l) => /^IDENTITY CLOSE: /.test(l)), 'said loudly'); - assert.ok(await until(() => c.rc.state().phase === 'connected', 5000), 'back on the relay after the retry'); - assert.equal(relay.dials.length, 2); - assert.equal(told.length, 1); - assert.equal(told[0].code, code); - assert.equal(told[0].pingPong, false, 'one close is no ping-pong'); - } finally { c.stop(); await relay.close(); } - }); -} - -test('R3-3: two processes replacing each other are detected as a ping-pong, told to the host, and retried only at the longest backoff', async () => { - // Every auth is taken and then replaced (4004) shortly after: what a second live process does. - const relay = relayAnswering((ws) => { ws.send(peersFrame()); setTimeout(() => ws.close(4004, 'Replaced by new connection'), 30); }); - const told = []; - const c = client(relay.url, { identityRetryMs: 100, onIdentityCollision: (i) => told.push(i) }); - try { - c.rc.connect(); - assert.ok(await until(() => c.rc.state().stopped && c.rc.state().stopped.pingPong, 5000), 'the ping-pong is detected'); - const s = c.rc.state(); - assert.equal(s.stopped.closes, 3); - assert.ok(s.nextRetryAt - Date.now() > 29 * 60_000, `retried only at the longest backoff (${Math.round((s.nextRetryAt - Date.now()) / 1000)} s)`); - assert.match(c.rc.describe(), /^PING-PONG: .*Stop one of them\. Retrying in \d+s\.$/); - assert.ok(told.some((t) => t.pingPong === true), 'the host is told it is a ping-pong'); - assert.ok(c.logs.some((l) => /PING-PONG/.test(l))); - const dials = relay.dials.length; - await wait(500); - assert.equal(relay.dials.length, dials, 'and not dialled again meanwhile'); - // At the longest backoff a real ping-pong's closes are an hour apart, past the window that found - // it: it holds anyway, until a connection outlasts the clear time. - c.rc._identityCloses = []; - c.rc._identityClosed(4004, IDENTITY_CLOSES[4004], 'Replaced by new connection'); - assert.equal(c.rc.state().stopped.pingPong, true, 'still a ping-pong an hour later'); - assert.ok(c.rc.state().nextRetryAt - Date.now() > 29 * 60_000); - } finally { c.stop(); await relay.close(); } -}); - -test('R3-3: identityRetry: false keeps MMP §4.4.7 as written (no reconnect after an identity close)', async () => { - const relay = relayAnswering((ws) => ws.close(4004, 'claimed')); - const c = client(relay.url, { identityRetry: false, identityRetryMs: 100 }); - try { - c.rc.connect(); - assert.ok(await until(() => c.rc.state().phase === 'collision', 3000)); - await wait(600); - assert.equal(relay.dials.length, 1); - assert.equal(c.rc.state().nextRetryAt, null); - assert.match(c.rc.describe(), /Not reconnecting \(identityRetry is off\)/); - } finally { c.stop(); await relay.close(); } -}); - test('R3-15: the liveness timeout terminates the connection (a dead one never answers a polite close)', async () => { const relay = relayAnswering((ws) => ws.send(peersFrame())); const c = client(relay.url); diff --git a/tests/relay-state.test.js b/tests/relay-state.test.js index 4000df9f..f7149f39 100644 --- a/tests/relay-state.test.js +++ b/tests/relay-state.test.js @@ -74,7 +74,9 @@ test('admitted: awaitOutcome resolves on relay-peers with phase connected and th assert.equal(s.attempts, 0, 'a successful auth resets the attempt count'); assert.equal(s.refused, null); assert.equal(s.peers, 0); - assert.match(c.rc.describe(), /^connected to ws:\/\/127\.0\.0\.1:\d+ for \d+s, 0 relay peer\(s\)$/); + // The relay's proof state is part of the line (sym 0.14.3): this fake relay never challenges. + assert.match(c.rc.describe(), /^connected to ws:\/\/127\.0\.0\.1:\d+ for \d+s, 0 relay peer\(s\); this relay does not verify keys/); + assert.equal(s.relayVerifiesKeys, false); } finally { c.stop(); await relay.close(); } }); @@ -120,21 +122,18 @@ test('refused: a retry DIAL inside the episode keeps the phase refused', async ( } finally { c.stop(); await relay.close(); } }); -// 0.14.3 (R3-3): an identity close is no longer a permanent stop: it is retried on a long backoff (1 min, -// doubling), said loudly, with a ping-pong detector (tests/relay-resilience.test.js has the retries). -test('identity collision: awaitOutcome resolves on 4004 with phase collision; describe says it retries on the long backoff', async () => { +test('identity collision: awaitOutcome resolves on 4004 with phase collision; describe says it will not reconnect, and why', async () => { const relay = fakeRelay((ws) => ws.close(4004, 'Duplicate identity')); const c = client(relay.url); try { c.rc.connect(); const s = await c.rc.awaitOutcome(5000); assert.equal(s.phase, 'collision'); - await wait(50); - const t = c.rc.state().nextRetryAt - Date.now(); - assert.ok(t > 55_000 && t <= 66_000, `the retry is a minute away (${t} ms)`); - assert.match(c.rc.describe(), /^IDENTITY CLOSE: .*\(4004\)\..*Retrying in \d+s\.$/); + assert.equal(s.nextRetryAt, null, 'nothing scheduled'); + // This client proves no key and the fake relay verifies none: the cause is said as that. + assert.match(c.rc.describe(), /^STOPPED: .*\(4004\): this relay does not verify keys, so any holder of the token can cause this; upgrade the relay\. Not reconnecting/); await wait(300); - assert.equal(relay.state.attempts, 1, 'no second knock before the long backoff'); + assert.equal(relay.state.attempts, 1, 'no second knock'); } finally { c.stop(); await relay.close(); } }); @@ -171,10 +170,11 @@ test('destroy() returns the state to idle, never leaves a stale connected/refuse } finally { c.stop(); await relay.close(); } }); -// MMP §4.4.7 / §4.4.9: 4006 (the existing holder is younger than 5 s) is an identity close like 4004: -// said once per episode, kept in state().stopped, and retried on the long backoff (0.14.3, R3-3). -for (const [code, phase, words] of [[4006, 'duplicate-rejected', /\(4006\).*Retrying in \d+s\.$/]]) { - test(`${code}: awaitOutcome resolves with phase ${phase}; retried only on the long backoff; said once; kept in state().stopped`, async () => { +// MMP §4.4.7 / §4.4.9: 4006 (a relay that does not verify keys found another live claim of this nodeId) +// is a hard stop like 4004: not retried, said once and kept in state(). (4007, draft PR #20, is an +// ordinary close: see the test below.) +for (const [code, phase, words] of [[4006, 'duplicate-rejected', /\(4006\): this relay does not verify keys.*Not reconnecting/]]) { + test(`${code}: awaitOutcome resolves with phase ${phase}; no reconnect; said once; kept in state().stopped`, async () => { const relay = fakeRelay((ws) => ws.close(code, 'Duplicate rejected')); const told = []; const c = client(relay.url, { onIdentityCollision: (info) => told.push(info) }); @@ -182,19 +182,20 @@ for (const [code, phase, words] of [[4006, 'duplicate-rejected', /\(4006\).*Retr c.rc.connect(); const s = await c.rc.awaitOutcome(5000); assert.equal(s.phase, phase); + assert.equal(s.nextRetryAt, null, 'nothing scheduled'); assert.equal(s.stopped.code, code); - await wait(50); - assert.ok(c.rc.state().nextRetryAt - Date.now() > 55_000, 'the retry is a minute away'); assert.match(c.rc.describe(), words); - await wait(1500); // past the ordinary first backoff (1 s) - assert.equal(relay.state.attempts, 1, 'no second knock before the long backoff'); + await wait(1500); // past the first backoff (1 s) + assert.equal(relay.state.attempts, 1, 'no second knock'); assert.equal(told.length, 1, 'the host is told once'); assert.equal(told[0].code, code); - assert.equal(told[0].pingPong, false); - assert.equal(c.logs.filter((l) => l.startsWith('IDENTITY CLOSE')).length, 1, 'said once, loudly'); + assert.equal(told[0].relayVerifiesKeys, false); + assert.equal(c.logs.filter((l) => l.startsWith('FATAL')).length, 1, 'said once, loudly'); + c.rc.connect(); + await wait(200); + assert.equal(relay.state.attempts, 1, 'a later connect() does not dial either'); c.rc.destroy(); - assert.equal(c.rc.state().phase, 'idle', 'destroy() leaves nothing pending'); - assert.equal(c.rc.state().stopped.code, code, 'and keeps the last identity close in state()'); + assert.equal(c.rc.state().phase, phase, 'destroy() does not hide the stop'); } finally { c.stop(); await relay.close(); } }); } diff --git a/tests/relay-url-setting.test.js b/tests/relay-url-setting.test.js new file mode 100644 index 00000000..935069c4 --- /dev/null +++ b/tests/relay-url-setting.test.js @@ -0,0 +1,133 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME before lib/config loads + +/** + * THE RELAY SETTING NEVER STOPS THE NODE (B1, re-review of release/0.14.3). R3-11 had a relay URL `ws` + * could not parse throw from start(), after the node had half started; the first fix threw EBADRELAY + * from the SymNode constructor and refused http:// and https:// (which sym 0.14.2 dialled, ws maps + * them), so mesh-channel exited at startup and the daemon crash-looped under KeepAlive for anyone + * whose SYM_RELAY_URL was https://. The relay is an optional transport: + * + * - http:// and https:// are the same relay as ws:// and wss://, and are spelled so wherever a relay + * URL comes in (SymNode, RelayConnection, parseInvite); + * - a setting that is still no relay URL disables the relay: the constructor and start() never fail + * for it, status() says `disabled` with the reason, one loud log line, and a 'relay-disabled' + * event at start(); the node runs on the LAN; + * - the daemon comes up with either setting. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const { WebSocketServer } = require('ws'); +const { RelayConnection, normalizeRelayUrl, relayUrlProblem } = require('../lib/relay'); +const { parseInvite } = require('../lib/invite'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { until } = require('./_core-secure'); + +const BIN = path.join(__dirname, '..', 'bin'); +const uniq = (s) => `${s}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; + +test('http:// and https:// are spelled ws:// and wss://; ws:// and wss:// are kept as written; the rest is no relay URL', () => { + assert.equal(normalizeRelayUrl('https://sym-relay.onrender.com'), 'wss://sym-relay.onrender.com'); + assert.equal(normalizeRelayUrl('HTTP://127.0.0.1:8787/x'), 'ws://127.0.0.1:8787/x'); + assert.equal(normalizeRelayUrl(' wss://relay.example.org '), 'wss://relay.example.org'); + assert.equal(normalizeRelayUrl('ws://127.0.0.1:8787'), 'ws://127.0.0.1:8787'); + for (const bad of ['relay.example.com', 'ftp://relay.example.com', 'https://', '', 'ws://', 42, null, 'w'.repeat(3000)]) { + assert.equal(normalizeRelayUrl(bad), null, String(bad).slice(0, 40)); + } + // The reason never repeats the setting: a URL can carry a secret. + const why = relayUrlProblem('ftp://user:s3cret@relay.example.com'); + assert.match(why, /ftp: URL/); + assert.ok(!why.includes('s3cret') && !why.includes('relay.example.com')); + assert.match(relayUrlProblem('relay.example.com'), /not a URL/); +}); + +test('RelayConnection: a setting that is no relay URL disables the relay; nothing throws and nothing dials', async () => { + const logs = []; + const rc = new RelayConnection({ relayUrl: 'relay.example.com', log: (l) => logs.push(l), getIdentity: () => ({ nodeId: '01900000-0000-7000-8000-0000000000f1' }), isRunning: () => true, nodeName: 'off' }); + const s = rc.state(); + assert.equal(s.phase, 'disabled'); + assert.equal(s.url, null); + assert.match(s.disabled.reason, /not a URL/); + assert.match(rc.describe(), /^DISABLED: .*runs on the LAN only\. Fix: set SYM_RELAY_URL/); + rc.connect(); + assert.equal(rc.ws, null, 'nothing dialled'); + assert.equal((await rc.awaitOutcome(10_000)).phase, 'disabled', 'an outcome at once'); + rc.destroy(); + assert.equal(rc.state().phase, 'disabled'); +}); + +test('SymNode: an invalid relay setting never fails the constructor or start(); status says disabled; one event at start', async () => { + const n = new SymNode({ name: uniq('relay-off'), silent: true, discovery: new NullDiscovery(), relay: 'not a relay url', relayToken: 'x'.repeat(40) }); + const told = []; + n.on('relay-disabled', (e) => told.push(e)); + try { + await n.start(); + const st = n.status(); + assert.equal(st.running, true); + assert.equal(st.relay, null); + assert.equal(st.relayState.phase, 'disabled'); + assert.match(st.relayState.disabled.reason, /not a URL/); + assert.match(st.relayStatus, /^DISABLED: /); + assert.equal(told.length, 1); + assert.match(told[0].reason, /not a URL/); + await n.stop(); + await n.start(); + assert.equal(told.length, 1, 'told once per node, not on every start'); + } finally { await n.stop(); } +}); + +test('SymNode: an http:// relay URL is dialled as ws:// and connects', async () => { + const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + await new Promise((r) => wss.once('listening', r)); + wss.on('connection', (ws) => ws.on('message', (d) => { if (JSON.parse(String(d)).type === 'relay-auth') ws.send(JSON.stringify({ type: 'relay-peers', peers: [] })); })); + const port = wss.address().port; + const n = new SymNode({ name: uniq('relay-http'), silent: true, discovery: new NullDiscovery(), relay: `http://127.0.0.1:${port}`, relayToken: 'x'.repeat(40) }); + try { + assert.equal(n.status().relay, `ws://127.0.0.1:${port}`); + await n.start(); + assert.ok(await until(() => n.status().relayConnected, 5000), 'connected over the ws:// spelling'); + } finally { await n.stop(); await new Promise((r) => { for (const c of wss.clients) c.terminate(); wss.close(() => r()); }); } +}); + +test('parseInvite takes http(s) relays as ws(s) and refuses one that is no URL, with a clear error', () => { + const ok = parseInvite(`sym://team/r?relay=${encodeURIComponent('https://sym-relay.onrender.com')}&token=t`); + assert.equal(ok.error, undefined); + assert.equal(ok.relayUrl, 'wss://sym-relay.onrender.com'); + const bad = parseInvite('sym://team/r?relay=sym-relay.onrender.com&token=t'); + assert.match(bad.error, /is not a ws:\/\/, wss:\/\/, http:\/\/ or https:\/\/ URL/); +}); + +for (const [label, url, expect] of [ + ['an https:// relay URL', 'https://127.0.0.1:9', /joining wss:\/\/127\.0\.0\.1:9/], + ['a relay setting that is no URL', 'relay.example.com', /RELAY DISABLED: /], +]) { + test(`the daemon comes up with ${label} and stays up`, async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-relayurl-')); + const socket = path.join(home, 'd.sock'); + const env = { PATH: process.env.PATH, HOME: home, USERPROFILE: home, SYM_STATE_DIR: path.join(home, '.sym'), SYM_SOCKET: socket, + SYM_SEMANTIC_ENCODER: 'off', SYM_NODE_NAME: uniq('relayurl'), SYM_RELAY_ONLY: '1', SYM_RELAY_URL: url, SYM_RELAY_TOKEN: 'x'.repeat(40) }; + const daemon = spawn(process.execPath, [path.join(BIN, 'sym-daemon.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + daemon.stdout.on('data', (b) => { out += b; }); + daemon.stderr.on('data', (b) => { out += b; }); + try { + assert.ok(await until(() => /IPC server listening/.test(out) || daemon.exitCode !== null, 15000), `the daemon started:\n${out.slice(-800)}`); + await new Promise((r) => setTimeout(r, 1500)); + assert.equal(daemon.exitCode, null, `the daemon is still running:\n${out.slice(-800)}`); + assert.match(out, /IPC server listening/); + assert.match(out, expect, out.slice(-800)); + assert.doesNotMatch(out, /EBADRELAY|Uncaught exception/); + } finally { + daemon.kill('SIGTERM'); + await new Promise((r) => { if (daemon.exitCode !== null) r(); else { daemon.once('exit', r); setTimeout(() => { try { daemon.kill('SIGKILL'); } catch { /* gone */ } r(); }, 3000); } }); + fs.rmSync(home, { recursive: true, force: true }); + } + }); +} From 341376b48f94d728d1f7849b2088e87c5046e686 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 18:34:00 +0100 Subject: [PATCH 2/7] CHANGELOG 0.14.3, SECURITY, README, wire notes: relay-auth key proof and the relay setting, replacing the identity-close retry --- CHANGELOG.md | 96 +++++++++++++++++++++++++++++++++------------ README.md | 10 +++-- SECURITY.md | 39 +++++++++++------- docs/WIRE-0.14.0.md | 25 +++++++++--- 4 files changed, 119 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c5b475..4161dacb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,9 @@ **An agent no longer loses its key to the 0.13 → by-id upgrade.** An XMesh review of `lib/config.js` 0.14.2 returned "do not ship" with 13 findings. Each was checked by running it in a sandbox state dir; 0.14.3 fixes the ones that lose or expose a key and the small ones beside them, by reading the state of an identity only from records committed in one -atomic step (see "Identity and on-disk state"). The wire is unchanged; the public surface gains `relocation.abortExport` -and `IdentityExportPendingError`. +atomic step (see "Identity and on-disk state"). The wire changes in one place, relay-auth, which now carries a key proof +to a relay that asks for one (see "Transport and node lifecycle"); a 0.14.3 node and a 0.14.2 node mesh as before, over +an old relay or a new one. The public surface gains `relocation.abortExport` and `IdentityExportPendingError`. ### What was wrong @@ -257,7 +258,7 @@ An independent security review (R2) of the 0.14 core reproduced each finding bel An independent review (R3) ran the transport and the node lifecycle against a local relay and found the defects below; each is fixed here with a test that fails on 0.14.2 and passes on 0.14.3. The wire -is unchanged except where a line says so. +is unchanged except where a line says so (relay-auth key proof is the one change). #### What was wrong @@ -279,9 +280,10 @@ is unchanged except where a line says so. per-message bound (MAX_FRAME_SIZE + 4096) was the connection's ws `maxPayload`, so one longer message (a payload sym-relay before 0.6.1 re-serialised longer than it arrived, or a roster padded with long names) closed the shared connection, and the sender could do it on every reconnect. -- **4004 and 4006 stopped the client for good (R3-3).** relay-auth carries no key proof, so any - holder of the channel token could claim a member's nodeId once, leave, and the member never came - back to the relay until it restarted. +- **Any holder of the channel token could switch a member off the relay (R3-3).** The relay keyed a + connection's slot by the nodeId relay-auth claimed, and relay-auth proved no key: claiming a member's + nodeId got the member closed (4004) or refused (4006), and the client stopped for good on either, as + MMP §4.4.7 has it. One claim, and the member never came back to the relay until it restarted. - **The backoff started over when the socket opened (R3-10)**, not when the relay took the auth, so a relay that accepted the socket and then closed it (1013, 4008, 1009) was dialled every second. - **Text split across two reads was corrupted, and the interior signed it (R3-7).** Every line reader @@ -315,9 +317,19 @@ is unchanged except where a line says so. takes the identity lock again (EIDENTITYLOCK if another process took it meanwhile) and the inbox, when no other node in this process owns it, read again from disk with the delivery ledger. `_running` is true only while running. -- **A relay URL is checked where it comes in.** `new SymNode({ relay })` throws `EBADRELAY` unless it - is an absolute ws:// or wss:// URL, and so does `new RelayConnection`; `parseInvite` returns an - error for an invite whose `relay` is not one. +- **A relay setting never stops the node; one that is no relay URL disables the relay.** The relay is + an optional transport. http:// and https:// are the same relay as ws:// and wss:// (sym 0.14.2 dialled + them; ws maps them) and are spelled so wherever a relay URL comes in: `new SymNode({ relay })`, + `new RelayConnection`, `parseInvite` and the daemon's `SYM_RELAY_URL`, so the node dials, shows and + signs its key proof's origin over one spelling. A setting that is still no relay URL (no scheme, an + ftp:// URL, no host) never throws from the constructor or fails start(): the relay is disabled, + `status().relayState.phase` is `disabled` with `disabled.reason` (worded without the setting, which + may carry a secret), `relayStatus` says the fix, one `RELAY DISABLED:` log line, and one + `relay-disabled` event at start(); the node runs on the LAN. `parseInvite` still refuses an invite + whose relay is no URL, with an error that says so. (A 0.14.3 candidate threw `EBADRELAY` from the + constructor and refused http(s), so mesh-channel 0.11.0 exited at startup and the daemon + crash-looped under KeepAlive for anyone whose `SYM_RELAY_URL` was https://: B1 of the release + re-review.) - **Records the pacer never wrote are held and offered again (R3-4).** When the relay connection closes, or a relay session ends while the connection stays up, the records the node dispatched that its pacer still holds come back to the node and are held for that peer, for the identity key they @@ -344,19 +356,43 @@ is unchanged except where a line says so. The current relay (sym-relay `origin/main`, package 0.7.0; the bound landed in 0.6.1) already builds every delivered frame and the roster within MAX_DELIVERED_BYTES, so this is defence in depth there; against 0.5.2 it is the fix. -- **4004 and 4006 are retried on a long backoff, loudly, with a ping-pong detector (R3-3).** The first - retry is after 1 min, doubling to 30 min. Each close is logged (`IDENTITY CLOSE: …`) and shown in - `relayStatus`. Three identity closes inside 30 min are a ping-pong — two live processes holding - this identity and replacing each other — said as that (`PING-PONG: … Stop one of them.`), told to - the host and retried only every 30 min, until a connection stays up 70 min (the other process has - gone). `identity-collision` fires at the first close in 30 min and when a ping-pong is found, now - with `pingPong` and `retryInMs`; a single close proves no second - process, so a host that exits on it should exit on `pingPong` instead. The metric is - `relay-identity-close` (was `relay-hard-stop`). `relayState.stopped` holds the last identity close - until an auth succeeds. **This departs from MMP §4.4.7** ("MUST NOT automatically reconnect" after - 4004, "SHOULD NOT" after 4006) and needs a spec ruling; `relayIdentityRetry: false` keeps §4.4.7 as - written. A relay-side key proof would end the impostor case; it needs a spec ruling and is not - implemented. +- **relay-auth proves the node's key (R3-3; wire; sym-relay 0.8.0 verifies it).** The fix for an + identity nobody verified is to verify it, not to retry around it. relay-auth carries + `keyProof: "mmp-relay-auth-v1"` when the node holds its identity key. A relay that knows it answers + `relay-challenge` with a fresh 32-byte nonce and registers nothing; the node sends relay-auth again + with `publicKey` and `proof`, an Ed25519 signature by the identity key over the length-prefixed, + domain-separated transcript `"mmp-relay-auth-v1"`, nonce, room, nodeId, and the origin of the URL the + node dialed (lib/core/relay-auth-proof.js; `tests/relay-auth-proof-v1.json` is the vector sym-relay + carries too). The token is never signed or logged. A relay that verifies keys keys the slot by the + key: a token holder that claims the nodeId under another key, or with no proof, holds a slot of its + own and can neither evict nor block the member. **Sequencing, by testing old peers:** sym 0.13.17 + and 0.14.2 ignore an unknown frame before relay-peers, and sym-relay 0.7.0 ignores unknown fields + in relay-auth but refuses a fan-out entry carrying one. So the node ASKS for the challenge inside a + whole relay-auth, which a relay before 0.8.0 authenticates as it is: the node connects at once + (no wait for a challenge that never comes), records `relayVerifiesKeys: false` in + `relayState` and `relayStatus`, and says once that any token holder can claim its nodeId there. + Old clients send no `keyProof` and are never challenged. +- **4004 and 4006 stop the client for good again (MMP §4.4.7), and say why.** A 0.14.3 candidate + retried them on a long backoff with a ping-pong detector (`relayIdentityRetry`, `pingPong`, + `retryInMs`, metric `relay-identity-close`): a patch over the unverified identity, which also let + two live processes holding one key flap forever. It is removed. On a connection whose key the + relay verified, a 4004 means another process proved this identity's key, the newest proof wins + once, and this one stops: `FATAL: … another process proved this identity's key on this relay; + this one stopped. Not reconnecting.` On a relay that does not verify keys, 4004 or 4006 stops + with `… this relay does not verify keys, so any holder of the token can cause this; upgrade the + relay`. `identity-collision` has its 0.14.2 shape, `{ nodeId, name, code, kind, reason }`, plus + `relayVerifiesKeys`, and fires once; the metric is `relay-hard-stop` again; `relayState.stopped` + keeps the stop (with `relayVerifiesKeys`) and outlives `destroy()`. +- **The relay's slots, as the client sees them.** A relay that lists `key-proof` gives each roster + and presence entry of a proven slot its `key` and `proven: true`, one entry per key. The slot + proven for the key this node binds to a nodeId is that peer; a legacy claim is a candidate only + while no slot of the bound key is listed; a slot proven for another key is never a candidate, + and its departure ends nothing (an impostor that leaves cannot take the member's sessions with + it). With no binding, the newest proven slot is the candidate. A directed frame names `toKey`, + the bound key, when the relay lists `key-proof` and a slot proven for that key, in a unicast or a + fan-out entry, so the relay hands it to that slot only; otherwise it goes to every slot of the + nodeId (sealed end to end, so a slot that cannot open it gains nothing), which is how it reaches + a bound peer on an older engine. At most 16 slots per nodeId are held (`relayState.slotsIgnored`). - **The backoff starts over when the relay takes the auth (R3-10)**, in the relay-peers handler, not when the socket opens: 1 s, 2 s, 4 s, … 30 s for a relay that keeps closing after the open. - **Lines are cut on bytes and decoded whole (R3-7)** (`lib/line-reader.js`): the interior socket and @@ -386,10 +422,18 @@ is unchanged except where a line says so. review D) unevaluated while the sender reports them dispatched. Of R3's 260 directed records sent at once, 55 were accepted in one run with no relay drop at all; R3's "227 lost after a drop" counts these too. The tests here stay under the budget. -- **mesh-channel exits on `identity-collision`** (server.js), so one spoofed 4004 still stops a channel - host until it exits on `info.pingPong` only. -- A token holder that keeps claiming a nodeId keeps that member off the relay most of the time; only a - relay-side key proof ends it. +- **mesh-channel exits on `identity-collision`** (server.js). On a relay that verifies keys only another + process holding the key causes one; on a relay that does not, a token holder still can, and + `info.relayVerifiesKeys` says which. +- **A delivered relay frame names its sender's nodeId, not the key that proved it.** A slot proven for + another key under a member's nodeId can send frames "from" the member. They cannot open or forge a + session (the handshake binds the key), but a flood of client-hellos from it at the relay's rate holds + the member's per-sender hello budget at a peer, and a member that restarts cannot re-handshake with + that peer while the flood lasts (measured on localhost: 25 a second kept it out for the 40 s + watched; 4 a second did not). A `fromKey` on frames from proven slots, and per-(nodeId, key) state + at the receiver, would end it; that is a wire addition for sym-relay and the spec, not made here. +- A relay setting that is no relay URL is said in status and the log; mesh-channel's own relay store + passes what it holds to SymNode unchanged, so it shows the setting as written. #### Tests diff --git a/README.md b/README.md index 24bd3e3a..19fede8e 100644 --- a/README.md +++ b/README.md @@ -166,10 +166,12 @@ What this does not solve, said plainly: - **Upgrading from 0.13 is a flag day for authority.** Old role grants confer nothing under §6.6; the anchor and each grantor re-issue what should stand (`node.legacyRoleGrants()` lists the old ones; [docs/AUTHORITY-OPERATOR.md](docs/AUTHORITY-OPERATOR.md) says how, and how to pin and re-pin). -- **Relay eviction.** `relay-auth` is not proven, and a relay token holder can make the relay - replace another node's connection (close 4004). A squatter gets no Core Secure session — it - cannot prove the key — and an evicted node retries on a long backoff and re-handshakes, but the - relay path can be interrupted (SECURITY.md has the detail). +- **Relay identity.** A node proves its key in `relay-auth` to a relay that asks (sym-relay 0.8.0), + and such a relay keys the node's slot by that key: a token holder claiming the nodeId cannot evict + or block it. A relay that does not verify keys still lets a token holder close a node's relay + connection (4004), and the node says so in status and stops. A slot proven under a member's + nodeId with another key can still send frames named as the member's, which cannot forge a + session but can delay a re-handshake (SECURITY.md has the detail). - **Key compromise.** There is no key rotation: a node whose private key leaks must be replaced, under a new nodeId. - **Your own machine.** Another process running as the same user can read the identity file. diff --git a/SECURITY.md b/SECURITY.md index c7e716bf..c16fed9e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -321,21 +321,30 @@ Every store a peer can feed has a fixed bound. Reaching one never stops the node authored the CMB naming it; a record carries no signed field saying which, so sym does not. It weighs the author's authority on each parent (the feedback flag) and leaves validation to the node's own `validateCMB`, gated on its own authority over that CMB. -- **Relay eviction.** `relay-auth` identity is not proven (MMP §4.4.1), and the relay replaces a - connection that re-authenticates under the same nodeId (close 4004, §4.4.7). A token holder - can therefore interrupt another node's relay path. It cannot impersonate it: it gets no - session without the key. Until 0.14.3 the client stopped for good on 4004 and 4006, so one - claim switched a member off until it restarted. Since 0.14.3 it retries on a long backoff (1 min, - doubling to 30 min), says each close loudly in its log and status, and detects a ping-pong (three - identity closes in 30 min: two live processes replacing each other), which it reports as that and - retries only every 30 min. A token holder that keeps claiming the nodeId still keeps the member - off the relay for most of the time, and a host that exits on `identity-collision` still exits on - the first claim: exit on `info.pingPong` instead. Only a relay-side key proof ends this; it needs - a spec ruling (MMP §4.4.7 says a client MUST NOT reconnect after 4004, which this departs from) - and is not implemented. `relayIdentityRetry: false` keeps §4.4.7 as written. Every other close - is reconnected with backoff, 4007 included: relay-auth key proof (MMP spec PR - meshcognition-website#20) is deferred past 0.14.0, and its review found that never reconnecting - after 4007 makes a lockout. +- **Relay identity: key proof (0.14.3, sym-relay 0.8.0).** Until 0.14.3 a relay keyed this node's + slot by a nodeId it only claimed, so any holder of the channel token could claim it and have the + node closed (4004) or refused (4006); the client stopped for good on either (MMP §4.4.7), so one + claim switched a member off until it restarted. Now the node proves its identity key to a relay + that asks (relay-auth `keyProof`, relay-challenge, an Ed25519 proof over the nonce, room, nodeId + and the origin it dialed; lib/core/relay-auth-proof.js), and a relay that verifies keys keys the + slot by the key: a token holder that claims the nodeId under another key, or with no proof, gets + a slot of its own and can neither evict nor block the member. A 4004 on a proven connection can + then only come from another process holding this node's key, and the client still stops for good, + saying so ("another process proved this identity's key on this relay; this one stopped"); the + newest proof of a key wins, once, so two processes holding one key never flap. + **What is left.** (1) A relay that does not verify keys (sym-relay before 0.8.0) takes the auth + without a proof; the client records `relayVerifiesKeys: false`, says once that any token holder + can claim its nodeId there, and on a 4004 or 4006 stops with "this relay does not verify keys, so + any holder of the token can cause this; upgrade the relay". (2) A delivered frame names its + sender's nodeId only, not the key that proved it, so a slot proven for another key under a + member's nodeId can still send frames "from" the member: they cannot open or forge a session + (the handshake binds the key), but a flood of client-hellos from such a slot, at the relay's rate, + holds the member's per-sender hello budget at a peer and keeps a member that restarts from + re-handshaking with it while the flood lasts (measured: 25 hellos a second kept a restarted + member out for the 40 s watched; 4 a second did not). (3) Whoever proves a key first under a + nodeId a peer has never bound is that peer's first contact, as on the LAN. (4) The relay's + origin binding is only as strong as its `SYM_RELAY_ORIGINS` setting, or, without it, its edge's + routing by Host. Every other close is reconnected with backoff, 4002 and 4007 included. - **No key rotation** (MMP §3.4). A compromised key means a new identity. - **A stopped identity's store is files.** `openStore` (`@sym-bot/sym/store`, 0.14.1) records a verdict in the store of an identity whose process has stopped, on the lifecycle authority its caller states diff --git a/docs/WIRE-0.14.0.md b/docs/WIRE-0.14.0.md index 76150fe0..874cc36c 100644 --- a/docs/WIRE-0.14.0.md +++ b/docs/WIRE-0.14.0.md @@ -170,14 +170,27 @@ of it; on the LAN the transport itself closes and nothing is sent. What sym choo case-insensitively, the first occurrence winning. The daemon also browses `_._tcp` where that is a valid RFC 6335 name (the §5.1 migration browse), and never advertises it. - The relay client reconnects with backoff after every close but 4004 and 4006 (4005, 4008, 1001, - 1009, 1011, 1013, and 4007). Since 0.14.3 the backoff starts over only when the relay has taken the - auth (relay-peers), not when the socket opens; 4004 and 4006 are retried too, on a long backoff - (1 min, doubling to 30 min, every 30 min after a ping-pong: three identity closes in 30 min), which - departs from §4.4.7's "MUST NOT automatically reconnect" (`relayIdentityRetry: false` keeps it); and - a message over the per-message bound (MAX_FRAME_SIZE + 4096) is dropped on its own instead of + 1009, 1011, 1013, 4002 and 4007), and never after those two (§4.4.7). Since 0.14.3 the backoff + starts over only when the relay has taken the auth (relay-peers), not when the socket opens; and a + message over the per-message bound (MAX_FRAME_SIZE + 4096) is dropped on its own instead of closing the connection (the ws ceiling is 8 MiB, and the roster is read up to it). It forgets a relay's `features` with its socket; and sends relay-auth's - nodeId lowercase, with `room` (when not default) and `engine`. It paces at 20 frames a second, + nodeId lowercase, with `room` (when not default) and `engine`. +- **Relay-auth key proof (0.14.3; sym-relay 0.8.0; not yet in MMP).** relay-auth carries + `keyProof: "mmp-relay-auth-v1"` when the node holds its identity key. A relay that knows it + answers `{ type: "relay-challenge", keyProof, nonce }` (32 bytes, base64url) and registers + nothing; the node sends relay-auth again with `publicKey` (32 bytes) and `proof` (64 bytes), both + canonical unpadded base64url: Ed25519 over `lp("mmp-relay-auth-v1") ‖ lp(nonce) ‖ lp(room) ‖ + lp(nodeId) ‖ lp(origin)` (lp = 4-byte big-endian length ‖ bytes; room `default` when none is + declared; origin = the WHATWG origin of the URL the node dialed). A relay that does not know it + takes the first frame as it is; the node records `relayVerifiesKeys: false` and says so once. + Roster and presence entries of a relay listing `key-proof` carry `key` and `proven: true` for a + proven slot; the slot proven for the key this node binds to a nodeId is that peer, and one proven + for another key is never a candidate. A directed frame names `toKey` (that key) when the relay + lists `key-proof` and a slot proven for it, in a unicast or a fan-out entry. tests/relay-auth-proof-v1.json + is the vector. http:// and https:// relay URLs are dialled as ws:// and wss://; a relay setting + that is no URL disables the relay (status phase `disabled`) and never stops the node. +- The relay client paces at 20 frames a second, burst 200, under the update's floors (§19.1: 25 a second, burst 300), counting relay-auth and relay-pong inside the same bucket, ahead of every other frame. From 9680030ad36aeb41a8222a7da0cd2ee22c42febe Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 18:59:11 +0100 Subject: [PATCH 3/7] CHANGELOG 0.14.3: the key-proof and relay-setting tests, before and after, and the cross-version runs --- CHANGELOG.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4161dacb..aacc9889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -437,16 +437,32 @@ is unchanged except where a line says so (relay-auth key proof is the one change #### Tests -- `tests/node-lifecycle.test.js` (8), `tests/relay-redelivery.test.js` (5), `tests/relay-resilience.test.js` (10), +- `tests/node-lifecycle.test.js` (8), `tests/relay-redelivery.test.js` (5), `tests/relay-resilience.test.js` (6), `tests/ipc-utf8.test.js` (3), `tests/daemon-long-socket-path.test.js` (1), `tests/frame-parser-linear.test.js` (2), - `tests/socket-identity.test.js` (2): 31 tests, of which 30 fail on 0.14.2 (run on a `git archive` of 632b95d) and - one pins parsing that did not change. A few of the 30 fail there only because what they check is new (the - held-record list, `identityRetry: false`, `stampSocket`, the line reader); the rest fail on the behaviour R3 - reported. They pass on macOS (Node 22) and on Linux in `node:22-slim` and `node:20-slim`. The relay runs R3 made - were repeated against sym-relay 0.5.2 and the current `origin/main` (0.7.0) on localhost: the oversized-envelope - and padded-roster attacks no longer close the victim's connection on 0.5.2 (the current relay already bounds - both), and a member evicted by a spoofed 4004 is back on either relay after its retry. -- `tests/relay-state.test.js`: the 4004 and 4006 cases now pin the long-backoff retry. + `tests/socket-identity.test.js` (2): 27 tests, of which 26 fail on 0.14.2 (run on a `git archive` of 632b95d) and + one pins parsing that did not change. A few of the 26 fail there only because what they check is new (the + held-record list, `stampSocket`, the line reader); the rest fail on the behaviour R3 reported. The R3-3 retry + tests are gone with the retry. The relay runs R3 made were repeated against sym-relay 0.5.2 and 0.7.0 on + localhost: the oversized-envelope and padded-roster attacks no longer close the victim's connection on 0.5.2 + (0.7.0 already bounds both). +- **Key proof and the relay setting:** `tests/relay-key-proof.test.js` (13) and `tests/relay-url-setting.test.js` (7). + Of the 20, 18 fail on the 0.14.3 candidate before this change (a `git archive` of 9f6847c, with + `lib/core/relay-auth-proof.js` copied in so the fake relays can verify) and on 0.14.2 (fe285a3). Two pass on + both: the shared transcript vector, which tests the copied module, and "no `toKey` to a relay that does not list + key-proof", which pins what neither candidate does. `tests/relay-state.test.js` pins 4004 and 4006 as hard stops + with their cause and the proof state in `relayStatus` (3 cases that fail on 9f6847c), and node-lifecycle's R3-11 + case now has a relay setting that is no URL disable the relay rather than throw (it fails on 9f6847c and + 0.14.2). Suite: 1,667 tests, 1,663 pass, 4 skipped; integration 6/6; the stock-nodes gate passes; `npm audit` + 0. The new and changed relay test files pass on Linux in `node:22-slim` and `node:20-slim` (68/68). +- **Across versions, with real processes on 127.0.0.1** (sym-relay `scripts/key-proof-matrix`, 46 checks, all + pass): sym-relay 0.8.0 with 0.14.3 nodes verifies both keys, an impostor with another key and a legacy claim of + the same nodeId get their own slots and evict or block nothing, A's directed records (`toKey`) reach only the + member, and a second process holding the member's key replaces the first, which stops for good with + `relayVerifiesKeys: true` and does not flap in the 15 s watched; 0.8.0 with 0.14.2 pairs and with 0.13.17 pairs + connects and exchanges records, challenging neither; sym-relay 0.7.0 (live) with 0.14.3 nodes connects at + once, reports `relayVerifiesKeys: false` and refuses no fan-out; a room mixing 0.14.3 and 0.14.2 nodes on 0.8.0 + exchanges records both ways, and rooms stay apart. mesh-channel 0.11.0's `server.js` with an https://, http:// + and a malformed `SYM_RELAY_URL` exits at once on 9f6847c and stays up on this branch. ## 0.14.2 — 2026-10-14 From fed8c6ffbe2fd77642845d7db604990db9651316 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 19:19:33 +0100 Subject: [PATCH 4/7] =?UTF-8?q?Relay:=20receiver=20uses=20fromKey=20?= =?UTF-8?q?=E2=80=94=20drop=20wrong-key=20frames,=20lane=20sessions=20and?= =?UTF-8?q?=20hello=20budgets=20by=20(nodeId,=20key);=20several=20proven?= =?UTF-8?q?=20keys=20for=20an=20unbound=20nodeId=20are=20an=20ambiguity,?= =?UTF-8?q?=20not=20TOFU?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A relay frame carries fromKey (the key the sender's slot proved, sym-relay 0.8.0). The node drops a frame whose fromKey is not the key it binds for that nodeId, before any session or bucket, and never charges the bound peer. Relay server sessions and the per-sender hello bucket are laned by (nodeId, fromKey), so an impostor proving its own key under a member's nodeId cannot supersede the member's handshake or spend its hello budget. Re-measured: an impostor flooding client-hellos at 25/s no longer keeps a restarted member (whose peer binds it) from re-handshaking. - A nodeId the relay presents under several proven keys, none bound here, is an identity ambiguity: the node starts no session for it and binds none (no trust-on-first-use by who answered first), reports it (identity-ambiguity event, relay-identity-ambiguous metric, status coreSecure identityAmbiguities and relayState.identityAmbiguous, a log line), and resolves it only via a pin, a certification, or when one proven key remains. A single proven key is first proven use as before. --- lib/node.js | 118 +++++++++++++++++++++++++++------- lib/relay.js | 53 +++++++++++++-- tests/relay-key-proof.test.js | 56 ++++++++++++++++ 3 files changed, 197 insertions(+), 30 deletions(-) diff --git a/lib/node.js b/lib/node.js index 865c4527..b6d1e92a 100644 --- a/lib/node.js +++ b/lib/node.js @@ -895,7 +895,9 @@ class SymNode extends EventEmitter { // (§4.4.1): the session manager below runs the handshake over each channel (design D2). onPeerPresent: (nodeId, name) => this._relayPeerPresent(nodeId, name), onPeerGone: (nodeId, name) => this._relayPeerGone(nodeId, name), - onEnvelope: (from, fromName, payload) => this._relayEnvelope(from, fromName, payload), + onEnvelope: (from, fromName, payload, fromKey) => this._relayEnvelope(from, fromName, payload, fromKey), + onIdentityAmbiguous: (nodeId, keys) => this._relayIdentityAmbiguous(nodeId, keys), + onIdentityResolved: (nodeId) => this._relayIdentityResolved(nodeId), onDisconnected: () => this._relayDisconnected(), // Every message off the relay socket, the relay's own frames included, is taken under the // node's inbound guard: refused and counted, never thrown (0.13.17). @@ -3915,6 +3917,9 @@ class SymNode extends EventEmitter { sessions: { confirmed: [...this._sessions].filter((x) => x.confirmed).length, authenticating: [...this._sessions].filter((x) => !x.confirmed && !x.closed).length }, stats: JSON.parse(JSON.stringify(this._sessionStats)), keyConflicts: this._roster.conflicts(), + // nodeIds the relay presents under several proven keys, none bound here (call #2, 0.8.0): an + // operator resolves each with a pin or a certification, or it clears when one key remains. + identityAmbiguities: this.identityAmbiguities(), keyBindings: this._roster.size(), keyBindingsExpired: this._roster.expiredCount(), authority: this.authorityStatus(), @@ -4124,7 +4129,7 @@ class SymNode extends EventEmitter { * @returns {PeerSession} * @private */ - _attachTransport(transport, { role, kind = 'bonjour', expectNodeId = null, relayFrom = null, remote = null } = {}) { + _attachTransport(transport, { role, kind = 'bonjour', expectNodeId = null, relayFrom = null, relayFromKey = null, remote = null } = {}) { const session = new PeerSession({ role, transport, kind, relayFrom, expectNodeId, local: { nodeId: this.nodeId, name: this.name, publicKey: this._identity.publicKey, privateKey: this._identity.privateKey }, @@ -4132,6 +4137,9 @@ class SymNode extends EventEmitter { timeoutMs: this._handshakeTimeoutMs, }); session.remote = remote; + // The lane a relay SERVER session answers (0.8.0): the key the relay stamped on the hello + // (`fromKey`), or '' for an unproven sender. Client sessions carry '' (they target one slot). + session.relayFromKey = relayFromKey || ''; this._sessions.add(session); if (kind !== 'relay' && transport && typeof transport.on === 'function') { transport.on('message', (f) => session.receiveWire(f)); @@ -4242,6 +4250,14 @@ class SymNode extends EventEmitter { // key it already expects for nodeId (a legacy claim becomes `proven`; a pin stays pinned). const expected = this._roster.durable(nodeId); // never the grant view (R2-1) if (expected !== undefined && expected !== session.identityKey) { this._refuseIdentityConflict(session); return; } + // Call #2 (0.8.0): a nodeId the relay presents under several proven keys, none bound here, is an + // identity ambiguity — first proven use would be trust-on-first-use. Admit none of the rivals as + // the peer until a pin, a certification or attrition leaves one (_relayIdentityAmbiguous). + if (expected === undefined && session.kind === 'relay' && this._relay && this._relay.ambiguous && this._relay.ambiguous.has(nodeId)) { + this._log(`Refused ${session.name} (${nodeId.slice(0, 8)}) into '${this._room}': its nodeId is present under several proven keys and none is bound here — resolve the ambiguity (sym keys) before it is a peer`); + session.close('identity-ambiguous', { notify: false }); + return; + } if (expected === session.identityKey) { const b = this._roster.bind(nodeId, session.identityKey, 'proven'); if (!b.bound && b.reason === 'conflict') { this._refuseIdentityConflict(session); return; } @@ -4273,7 +4289,7 @@ class SymNode extends EventEmitter { const st = this._relayState(session.relayFrom); st.confirmed.add(session); if (st.client === session) st.client = null; - if (st.server === session) st.server = null; + if (st.servers.get(session.relayFromKey || '') === session) st.servers.delete(session.relayFromKey || ''); } let peer = this._peers.get(nodeId); const isNew = !peer; @@ -4483,10 +4499,10 @@ class SymNode extends EventEmitter { if (st) { st.confirmed.delete(session); if (st.client === session) st.client = null; - if (st.server === session) st.server = null; + if (st.servers.get(session.relayFromKey || '') === session) st.servers.delete(session.relayFromKey || ''); // An entry lives only while a session for that relay `from` does: a flood of hellos from // ids that never confirm leaves nothing behind. - if (!st.client && !st.server && st.confirmed.size === 0) this._relaySessions.delete(session.relayFrom); + if (!st.client && st.servers.size === 0 && st.confirmed.size === 0) this._relaySessions.delete(session.relayFrom); } } const peer = session.nodeId ? this._peers.get(session.nodeId) : null; @@ -4636,7 +4652,7 @@ class SymNode extends EventEmitter { let n = 0; if (this._relaySessions) for (const st of this._relaySessions.values()) { if (st.client && st.client._unknownCandidate) n++; - if (st.server && st.server._unknownCandidate) n++; + for (const sv of this._relayServers(st)) if (sv._unknownCandidate) n++; } return n; } @@ -4756,7 +4772,7 @@ class SymNode extends EventEmitter { _evictOldestUnknownRelayHandshake() { let oldest = null; if (this._relaySessions) for (const st of this._relaySessions.values()) { - for (const x of [st.server, st.client]) { + for (const x of [st.client, ...this._relayServers(st)]) { if (x && !x.closed && !x.confirmed && x._unknownCandidate && (!oldest || x.startedAt < oldest.startedAt)) oldest = x; } } @@ -4768,7 +4784,7 @@ class SymNode extends EventEmitter { /** @private How many relay handshakes (client or server) are in flight. */ _relayHandshakesInFlight() { let n = 0; - if (this._relaySessions) for (const st of this._relaySessions.values()) n += (st.client ? 1 : 0) + (st.server ? 1 : 0); + if (this._relaySessions) for (const st of this._relaySessions.values()) n += (st.client ? 1 : 0) + st.servers.size; return n; } @@ -4776,10 +4792,15 @@ class SymNode extends EventEmitter { _relayState(nodeId) { if (!this._relaySessions) this._relaySessions = new Map(); let st = this._relaySessions.get(nodeId); - if (!st) { st = { client: null, server: null, confirmed: new Set() }; this._relaySessions.set(nodeId, st); } + // `servers` is LANED by the key the relay stamped on the hello's envelope (`fromKey`, 0.8.0), or + // '' for an unproven sender: a slot proven for one key cannot supersede a handshake with another. + if (!st) { st = { client: null, servers: new Map(), confirmed: new Set() }; this._relaySessions.set(nodeId, st); } return st; } + /** @private Every in-flight server handshake of `st` (laned by fromKey). */ + _relayServers(st) { return st && st.servers ? [...st.servers.values()] : []; } + /** * The relay names a peer present (relay-peers, relay-peer-joined). A candidate: the node with the * smaller nodeId sends client-hello to it; the other waits for one. A confirmed session already @@ -4847,6 +4868,35 @@ class SymNode extends EventEmitter { this._relayRetry.set(nodeId, r); } + /** + * The relay presents `nodeId` under more than one proven key and this node binds none of them + * (lib/relay.js). First proven use would be trust-on-first-use on the relay's say-so, so this node + * refuses to guess: it starts no session for `nodeId` (relay.js offers none), binds nothing, and + * records the ambiguity. Only a pin, an anchor certification, or every rival but one leaving + * resolves it (`sym keys` / `acceptInvite`), after which the lone key is first proven use as usual. + * @private + */ + _relayIdentityAmbiguous(nodeId, keys) { + if (!this._identityAmbiguous) this._identityAmbiguous = new Map(); + this._identityAmbiguous.set(nodeId, keys); + this._log(`[sym-security] relay identity ambiguity: ${nodeId.slice(0, 8)} is present under ${keys.length} proven keys and this node binds none — not guessing which is the peer. Resolve with a pin or a certification (sym keys), or wait until one remains.`); + this.emit('identity-ambiguity', { nodeId, keys: [...keys] }); + this.emit('metric', { type: 'relay-identity-ambiguous', nodeId, keys: keys.length }); + } + + /** @private An ambiguity on `nodeId` is gone (resolved to one proven key, or the nodeId left). */ + _relayIdentityResolved(nodeId) { + if (this._identityAmbiguous && this._identityAmbiguous.delete(nodeId)) { + this.emit('identity-ambiguity-resolved', { nodeId }); + this.emit('metric', { type: 'relay-identity-resolved', nodeId }); + } + } + + /** The nodeIds the relay presents under several proven keys, none bound here (an operator resolves). */ + identityAmbiguities() { + return this._identityAmbiguous ? [...this._identityAmbiguous].map(([nodeId, keys]) => ({ nodeId, keys: [...keys] })) : []; + } + /** @private The relay says a peer left: every session bound to that `from` ends (design D2). */ _relayPeerGone(nodeId) { if (this._legacy) this._legacy.relayGone(nodeId); @@ -4855,7 +4905,7 @@ class SymNode extends EventEmitter { if (r && r.timer) clearTimeout(r.timer); if (this._relayRetry) this._relayRetry.delete(nodeId); if (!st) return; - for (const s of [st.client, st.server, ...st.confirmed]) if (s && !s.closed) s.close('relay-peer-left', { notify: false }); + for (const s of [st.client, ...this._relayServers(st), ...st.confirmed]) if (s && !s.closed) s.close('relay-peer-left', { notify: false }); this._relaySessions.delete(nodeId); } @@ -4864,7 +4914,7 @@ class SymNode extends EventEmitter { if (!this._relaySessions) return; for (const [nodeId] of [...this._relaySessions]) { const st = this._relaySessions.get(nodeId); - for (const s of [st.client, st.server, ...st.confirmed]) if (s && !s.closed) s.close('relay-disconnected', { notify: false }); + for (const s of [st.client, ...this._relayServers(st), ...st.confirmed]) if (s && !s.closed) s.close('relay-disconnected', { notify: false }); } this._relaySessions.clear(); if (this._relayRetry) { for (const r of this._relayRetry.values()) if (r.timer) clearTimeout(r.timer); this._relayRetry.clear(); } @@ -4877,22 +4927,40 @@ class SymNode extends EventEmitter { * confirmed session whose sessionId it carries; anything else is a Legacy Import route's or refused. * @private */ - _relayEnvelope(from, fromName, payload) { + _relayEnvelope(from, fromName, payload, fromKey = null) { if (from === this.nodeId) return; + // THE SENDER'S PROVEN KEY (0.8.0). A relay that verifies keys stamps `fromKey` on every frame it + // delivers from a proven slot (lib/relay.js). When this node binds a key for `from`, a frame + // whose `fromKey` is another key is from a different slot claiming the nodeId: it is dropped here, + // before any session, bucket or slot sees it, so an impostor that proved its own key under a + // member's nodeId cannot touch the member's lanes. The bound peer is never charged for it. A + // frame with no `fromKey` (a legacy slot, or a relay that does not stamp) is laned under ''. + const lane = fromKey || ''; + if (fromKey) { + const bound = this._identityKey(from); + if (bound !== undefined && bound !== fromKey) { + this._wrongKeyFrames = (this._wrongKeyFrames || 0) + 1; + this._sayOncePerMinute(`wk|${from}`, `[sym-security] dropped a relay frame for ${String(from).slice(0, 8)} stamped with a key other than the one bound to it (an impostor on the relay); the bound peer is unaffected`); + this.emit('metric', { type: 'relay-frame-wrong-key', nodeId: from }); + return; + } + } const st = this._relaySessions && this._relaySessions.get(from); const refuse = (reason) => this._noteSessionRefusal({ relayFrom: from, name: fromName }, payload.type, reason); switch (payload.type) { case 'client-hello': { // The smaller nodeId is the client (design D2): a hello from a larger one is not taken. if (!(from < this.nodeId)) { refuse('client-hello-from-larger-id'); return; } - // A relay `from`'s hellos are rate-limited (a hello costs a key agreement and a signature). - if (!takeToken(this._relayHelloBuckets || (this._relayHelloBuckets = new Map()), from, RELAY_HELLO_PER_SECOND, RELAY_HELLO_BURST, Date.now(), 4096)) { refuse('hello-rate'); return; } - // One authenticating slot per (relay, from): a new hello retires the one in flight. At most - // RELAY_HANDSHAKES_MAX in flight in all, at most RELAY_UNKNOWN_MAX for unknown froms; past - // a share, the oldest unknown handshake in flight goes, so a flood of hellos that never - // finish holds slots only until newer hellos arrive (security review D, relay-slots). + // A relay `from`'s hellos are rate-limited (a hello costs a key agreement and a signature), + // in the LANE of the sender's proven key (0.8.0): a slot proving one key cannot spend another + // slot's budget, so an impostor under a member's nodeId cannot starve the member's hellos. + if (!takeToken(this._relayHelloBuckets || (this._relayHelloBuckets = new Map()), `${from}\n${lane}`, RELAY_HELLO_PER_SECOND, RELAY_HELLO_BURST, Date.now(), 4096)) { refuse('hello-rate'); return; } + // One authenticating slot per (relay, from, fromKey): a new hello retires only the one in + // flight for THAT key. At most RELAY_HANDSHAKES_MAX in flight in all, at most + // RELAY_UNKNOWN_MAX for unknown froms; past a share, the oldest unknown handshake in flight + // goes, so a flood of hellos that never finish holds slots only until newer ones arrive. const unknown = !this._isKnownPeer(from); - const prev = st && st.server && !st.server.closed && !st.server.confirmed ? st.server : null; + const prev = st && st.servers.get(lane) && !st.servers.get(lane).closed && !st.servers.get(lane).confirmed ? st.servers.get(lane) : null; if (!prev) { const full = this._relayHandshakesInFlight() >= RELAY_HANDSHAKES_MAX; const unknownFull = unknown && this._relayUnknownInFlight() >= RELAY_UNKNOWN_MAX; @@ -4903,9 +4971,9 @@ class SymNode extends EventEmitter { const s = this._relayState(from); // The new session is attached before the old one closes, so the relay state entry (which lives // only while a session for this `from` does) is never dropped from under it (review item 11). - const next = this._attachTransport(this._relay.transportFor(from), { role: 'server', kind: 'relay', relayFrom: from, expectNodeId: from }); + const next = this._attachTransport(this._relay.transportFor(from), { role: 'server', kind: 'relay', relayFrom: from, relayFromKey: lane, expectNodeId: from }); next._unknownCandidate = unknown; - s.server = next; + s.servers.set(lane, next); if (prev) prev.close('superseded-hello', { notify: false }); next.receiveWire(payload); return; @@ -4914,10 +4982,12 @@ class SymNode extends EventEmitter { if (st && st.client && !st.client.closed && !st.client.confirmed) st.client.receiveWire(payload); else refuse('no-handshake-in-flight'); return; - case 'client-finish': - if (st && st.server && !st.server.closed && !st.server.confirmed) st.server.receiveWire(payload); + case 'client-finish': { + const sv = st && st.servers.get(lane); + if (sv && !sv.closed && !sv.confirmed) sv.receiveWire(payload); else refuse('no-handshake-in-flight'); return; + } case 'cmb-encrypted': case 'control-encrypted': { // Any CONFIRMED session for this `from` that carries the sessionId: an admitted one, or one @@ -4926,7 +4996,7 @@ class SymNode extends EventEmitter { // sessions only, it never reached the door, and a gated room admitted nobody over a relay. // The door still decides (_onSessionFrame takes only room-join before admission). const s = st && ([...st.confirmed].find((x) => x.sessionId === payload.sessionId && !x.closed) - || [st.server, st.client].find((x) => x && x.confirmed && !x.closed && x.sessionId === payload.sessionId)); + || [st.client, ...this._relayServers(st)].find((x) => x && x.confirmed && !x.closed && x.sessionId === payload.sessionId)); if (s) { s.receiveWire(payload); return; } refuse('other-session'); // A sealed frame for a session this node does not hold — its peer still holds one from before diff --git a/lib/relay.js b/lib/relay.js index bb4dbf52..66b8f08e 100644 --- a/lib/relay.js +++ b/lib/relay.js @@ -302,6 +302,10 @@ class RelayConnection { * `nodeId` (base64url), if any. A relay that verifies keys lists every key that * proved a nodeId; the one bound here is preferred, and directed frames name it * (`toKey`) so the relay hands them to that slot only. + * @param {function} [opts.onIdentityAmbiguous] — (nodeId, keys[]) => void — the relay presents + * `nodeId` under more than one proven key and none is bound here; no session is + * started for it until it resolves (a pin, a certification, or the rivals leaving). + * @param {function} [opts.onIdentityResolved] — (nodeId) => void — an ambiguity above is gone. * @param {function} [opts.onUnflushed] — (items) => void — frames the pacer still held when the * connection closed, each `{ to, bytes, meta }`, for those sent with a `meta` * (sendTo's third argument); the node re-offers them to the next session. @@ -332,6 +336,10 @@ class RelayConnection { // the node runs the §5.2 handshake over the channel and a peer exists only once it confirms. this._onPeerPresent = opts.onPeerPresent || (() => {}); this._onPeerGone = opts.onPeerGone || (() => {}); + // A nodeId the relay presents under several proven keys, none bound here: reported, never guessed. + this._onIdentityAmbiguous = opts.onIdentityAmbiguous || (() => {}); + this._onIdentityResolved = opts.onIdentityResolved || (() => {}); + this._ambiguous = new Map(); this._onEnvelope = opts.onEnvelope || (() => {}); this._onDisconnected = opts.onDisconnected || (() => {}); // (frame, take) => boolean: runs `take` for one message off the relay socket so that nothing it @@ -421,6 +429,7 @@ class RelayConnection { disabled: this._disabled, relayVerifiesKeys: this._relayVerifiesKeys, slotsIgnored: this._slotsIgnored, + identityAmbiguous: this._ambiguous ? this._ambiguous.size : 0, since: this._phaseSince, attempts: this._attempts, nextRetryAt: this._nextRetryAt, @@ -743,6 +752,7 @@ class RelayConnection { // the node, which holds it for the peer's next session (R3-4); the rest is dropped. this._present.clear(); this._slots.clear(); + if (this._ambiguous) this._ambiguous.clear(); this._fanout = null; this._keyProofFeature = false; const unflushed = this._takeUnflushed(); @@ -880,8 +890,11 @@ class RelayConnection { && typeof msg.payload.type === 'string') { // The whole message was bounded above (MAX_FRAME_SIZE + the envelope allowance, and its // nesting); the payload is a part of it. A relay `from` is unproven (§4.4.1): the node takes - // the payload only as bytes for the session bound to that `from`. - this._onEnvelope(msg.from, wireName(msg.fromName), msg.payload, connection); + // the payload only as bytes for the session bound to that `from`. `fromKey`, when the relay + // stamped one (0.8.0), is the key the sender's slot proved: the node lanes by (from, fromKey) + // and drops a frame whose key is not the one it binds for that nodeId. + const fromKey = typeof msg.fromKey === 'string' && CANONICAL_KEY_B64URL.test(msg.fromKey) ? msg.fromKey : null; + this._onEnvelope(msg.from, wireName(msg.fromName), msg.payload, fromKey, connection); } } @@ -900,11 +913,38 @@ class RelayConnection { let bound; try { bound = this._keyOf(nodeId); } catch { bound = undefined; } if (typeof bound === 'string' && bound) return slots.get(bound) || slots.get('') || null; - let newest = null; - for (const slot of slots.values()) if (slot.proven) newest = slot; - return newest || slots.get('') || null; + // No binding. The node never picks one proven key over another by who announced first (that would + // be TOFU on the relay's say-so): one proven key is first proven use, several is an identity + // ambiguity (_noteAmbiguity) that only a pin, an anchor certification, or every rival leaving + // resolves. A lone legacy claim (no proven key at all) is still a candidate, as before 0.8.0. + const provenKeys = []; + let proven = null, legacy = null; + for (const [k, slot] of slots) { if (slot.proven) { provenKeys.push(k); proven = slot; } else legacy = slot; } + if (provenKeys.length >= 2) { this._noteAmbiguity(nodeId, provenKeys); return null; } + this._clearAmbiguity(nodeId); + return proven || legacy || null; } + /** A nodeId with no binding is proven under more than one key: reported once, until it resolves. @private */ + _noteAmbiguity(nodeId, keys) { + if (!this._ambiguous) this._ambiguous = new Map(); + const sorted = [...keys].sort(); + const prev = this._ambiguous.get(nodeId); + if (prev && prev.length === sorted.length && prev.every((k, i) => k === sorted[i])) return; + this._ambiguous.set(nodeId, sorted); + try { this._onIdentityAmbiguous(nodeId, sorted); } catch (err) { this._log(`Relay identity-ambiguity handling failed: ${err && err.message}`); } + } + + /** @private The ambiguity on `nodeId` is gone (it resolved, or the nodeId left). */ + _clearAmbiguity(nodeId) { + if (this._ambiguous && this._ambiguous.delete(nodeId)) { + try { this._onIdentityResolved(nodeId); } catch (err) { this._log(`Relay identity-ambiguity handling failed: ${err && err.message}`); } + } + } + + /** The nodeIds the relay presents under more than one proven key, none of them bound here. */ + get ambiguous() { return this._ambiguous ? new Map([...this._ambiguous].map(([k, v]) => [k, [...v]])) : new Map(); } + /** * The key to name in `toKey` for a directed frame to `nodeId`, or null: the key this node binds to * it, when the relay understands `toKey` and lists a slot proven for exactly that key. Otherwise the @@ -959,7 +999,7 @@ class RelayConnection { const before = slots ? this._preferredSlot(peerId) : null; if (slots) { slots.delete(slotKey); - if (!slots.size) this._slots.delete(peerId); + if (!slots.size) { this._slots.delete(peerId); this._clearAmbiguity(peerId); } } const after = this._slots.has(peerId) ? this._preferredSlot(peerId) : null; // The peer this node took for the nodeId is still there: another slot left (an impostor's key, a @@ -1173,6 +1213,7 @@ class RelayConnection { this._present.clear(); this._slots.clear(); + if (this._ambiguous) this._ambiguous.clear(); const unflushed = this._takeUnflushed(); this._control.length = 0; diff --git a/tests/relay-key-proof.test.js b/tests/relay-key-proof.test.js index 4c0b1bbb..3ad9481a 100644 --- a/tests/relay-key-proof.test.js +++ b/tests/relay-key-proof.test.js @@ -358,3 +358,59 @@ test('a SymNode proves its key, reports it in status(), and its identity-collisi assert.match(n.status().relayStatus, /another process proved this identity's key on this relay; this one stopped/); } finally { await n.stop(); await r.close(); } }); + +test('fromKey (receiver): a frame stamped with a key other than the one bound to its nodeId is dropped before any session, and the bound peer is unaffected', async () => { + const bound = identity('fk-bound'); + let sock = null; + const r = relay({ after: (ws) => { sock = ws; } }); + await r.ready; + const id = identity('fk-recv'); + const node = new SymNode({ name: `fk-${process.pid}`, silent: true, discovery: new NullDiscovery(), relay: r.url(), relayToken: TOKEN }); + const metrics = []; + node.on('metric', (m) => { if (m.type === 'relay-frame-wrong-key') metrics.push(m); }); + try { + await node.start(); + await until(() => node.status().relayState.phase === 'connected', 5000); + // Bind bound.publicKey for a peer nodeId, as a confirmed session would. + const peer = '01900000-0000-7000-8000-0000000000f7'; + node._roster.bind(peer, bound.publicKey, 'pinned'); + const impostor = identity('fk-imp'); + // The impostor's slot (its own key) sends a client-hello "from" the bound peer. + sock.send(JSON.stringify({ from: peer, fromName: 'imp', fromKey: impostor.publicKey, payload: { type: 'client-hello', junk: 1 } })); + await wait(300); + assert.equal(metrics.length, 1, 'the wrong-key frame was dropped and counted'); + // A frame genuinely from the bound key is not dropped (it reaches a session, which refuses the junk — but not as wrong-key). + const before = metrics.length; + sock.send(JSON.stringify({ from: peer, fromName: 'peer', fromKey: bound.publicKey, payload: { type: 'client-hello', junk: 1 } })); + await wait(200); + assert.equal(metrics.length, before, 'a frame with the bound key is not dropped as wrong-key'); + } finally { await node.stop(); await r.close(); } +}); + +test('ambiguity (receiver): a nodeId present under two proven keys, none bound, is reported and no session is started for it', async () => { + const P = '01900000-0000-7000-8000-0000000000f8'; + const k1 = identity('amb1').publicKey, k2 = identity('amb2').publicKey; + let sock = null; + const r = relay({ after: (ws) => { sock = ws; } }); + await r.ready; + const node = new SymNode({ name: `amb-${process.pid}`, silent: true, discovery: new NullDiscovery(), relay: r.url(), relayToken: TOKEN }); + const amb = [], resolved = []; + node.on('identity-ambiguity', (e) => amb.push(e)); + node.on('identity-ambiguity-resolved', (e) => resolved.push(e)); + try { + await node.start(); + await until(() => node.status().relayState.phase === 'connected', 5000); + const a = (name, key) => sock.send(JSON.stringify({ type: 'relay-peer-joined', nodeId: P, name, key, proven: true })); + a('one', k1); + await wait(100); + a('two', k2); + assert.ok(await until(() => amb.length === 1, 2000), 'the ambiguity is reported'); + assert.deepEqual(amb[0].keys.slice().sort(), [k1, k2].sort()); + assert.equal(node.status().coreSecure.identityAmbiguities.length, 1, 'and shown in status'); + assert.equal(node.status().relayState.identityAmbiguous, 1); + // One rival leaves: the ambiguity resolves to the remaining proven key, first proven use as usual. + sock.send(JSON.stringify({ type: 'relay-peer-left', nodeId: P, name: 'two', key: k2, proven: true })); + assert.ok(await until(() => resolved.length === 1, 2000), 'resolved when one key remains'); + assert.equal(node.status().coreSecure.identityAmbiguities.length, 0); + } finally { await node.stop(); await r.close(); } +}); From c438425397db1e82a99b00b93d4f47dec21571d1 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 19:20:11 +0100 Subject: [PATCH 5/7] CHANGELOG/SECURITY/README: fromKey closes the impostor-flood gap; several proven keys for an unbound nodeId are an ambiguity, not TOFU --- CHANGELOG.md | 22 +++++++++++++++------- README.md | 11 ++++++----- SECURITY.md | 31 ++++++++++++++++++------------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aacc9889..e6c31171 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -425,13 +425,21 @@ is unchanged except where a line says so (relay-auth key proof is the one change - **mesh-channel exits on `identity-collision`** (server.js). On a relay that verifies keys only another process holding the key causes one; on a relay that does not, a token holder still can, and `info.relayVerifiesKeys` says which. -- **A delivered relay frame names its sender's nodeId, not the key that proved it.** A slot proven for - another key under a member's nodeId can send frames "from" the member. They cannot open or forge a - session (the handshake binds the key), but a flood of client-hellos from it at the relay's rate holds - the member's per-sender hello budget at a peer, and a member that restarts cannot re-handshake with - that peer while the flood lasts (measured on localhost: 25 a second kept it out for the 40 s - watched; 4 a second did not). A `fromKey` on frames from proven slots, and per-(nodeId, key) state - at the receiver, would end it; that is a wire addition for sym-relay and the spec, not made here. +- **`fromKey`: a receiver tells the slots of one nodeId apart.** A relay that verifies keys stamps + `fromKey` (the key the sender's slot proved, sym-relay 0.8.0) on every frame it delivers from a + proven slot. The node drops a relay frame whose `fromKey` is not the key it binds for that nodeId, + before any session or bucket, never charging the bound peer (`relay-frame-wrong-key`); and it lanes + relay server sessions and the per-sender hello budget by (nodeId, `fromKey`). So a slot proven for + another key under a member's nodeId can neither supersede the member's handshake nor spend its hello + budget. Re-measured on localhost: an impostor flooding client-hellos at 25 a second no longer keeps + a restarted member (whose peer binds it) from re-handshaking (it did before `fromKey`). +- **Several proven keys for an unbound nodeId are an identity ambiguity, not first-come.** When a relay + presents a nodeId under more than one proven key and this node binds none of them, the node starts no + session for it and binds none — it does not trust-on-first-use by who announced first. It reports the + ambiguity (`identity-ambiguity` event, `relay-identity-ambiguous` metric, + `status().coreSecure.identityAmbiguities`, `relayState.identityAmbiguous`, a log line) and resolves it + only by a pin, an anchor certification, or one proven key remaining. A single proven key is first + proven use as before. - A relay setting that is no relay URL is said in status and the log; mesh-channel's own relay store passes what it holds to SymNode unchanged, so it shows the setting as written. diff --git a/README.md b/README.md index 19fede8e..8685d5b7 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,12 @@ What this does not solve, said plainly: the anchor and each grantor re-issue what should stand (`node.legacyRoleGrants()` lists the old ones; [docs/AUTHORITY-OPERATOR.md](docs/AUTHORITY-OPERATOR.md) says how, and how to pin and re-pin). - **Relay identity.** A node proves its key in `relay-auth` to a relay that asks (sym-relay 0.8.0), - and such a relay keys the node's slot by that key: a token holder claiming the nodeId cannot evict - or block it. A relay that does not verify keys still lets a token holder close a node's relay - connection (4004), and the node says so in status and stops. A slot proven under a member's - nodeId with another key can still send frames named as the member's, which cannot forge a - session but can delay a re-handshake (SECURITY.md has the detail). + and such a relay keys the node's slot by that key and stamps the proving key on every frame it + delivers: a token holder claiming the nodeId cannot evict, block, or impersonate it, and a node + drops relay frames stamped with any key but the one it binds for that nodeId. A relay that does not + verify keys still lets a token holder close a node's relay connection (4004), and the node says so + in status and stops. Several proven keys for a nodeId no peer has bound are an ambiguity an operator + resolves, never guessed (SECURITY.md has the detail). - **Key compromise.** There is no key rotation: a node whose private key leaks must be replaced, under a new nodeId. - **Your own machine.** Another process running as the same user can read the identity file. diff --git a/SECURITY.md b/SECURITY.md index c16fed9e..f8adc567 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -332,19 +332,24 @@ Every store a peer can feed has a fixed bound. Reaching one never stops the node then only come from another process holding this node's key, and the client still stops for good, saying so ("another process proved this identity's key on this relay; this one stopped"); the newest proof of a key wins, once, so two processes holding one key never flap. - **What is left.** (1) A relay that does not verify keys (sym-relay before 0.8.0) takes the auth - without a proof; the client records `relayVerifiesKeys: false`, says once that any token holder - can claim its nodeId there, and on a 4004 or 4006 stops with "this relay does not verify keys, so - any holder of the token can cause this; upgrade the relay". (2) A delivered frame names its - sender's nodeId only, not the key that proved it, so a slot proven for another key under a - member's nodeId can still send frames "from" the member: they cannot open or forge a session - (the handshake binds the key), but a flood of client-hellos from such a slot, at the relay's rate, - holds the member's per-sender hello budget at a peer and keeps a member that restarts from - re-handshaking with it while the flood lasts (measured: 25 hellos a second kept a restarted - member out for the 40 s watched; 4 a second did not). (3) Whoever proves a key first under a - nodeId a peer has never bound is that peer's first contact, as on the LAN. (4) The relay's - origin binding is only as strong as its `SYM_RELAY_ORIGINS` setting, or, without it, its edge's - routing by Host. Every other close is reconnected with backoff, 4002 and 4007 included. + **What this covers, and its edges.** (1) A relay that does not verify keys (sym-relay before 0.8.0) + takes the auth without a proof; the client records `relayVerifiesKeys: false`, says once that any + token holder can claim its nodeId there, and on a 4004 or 4006 stops with "this relay does not + verify keys … upgrade the relay". (2) A relay that verifies keys stamps `fromKey` on every frame it + delivers from a proven slot (the slot's key). When this node binds a key for a nodeId, it drops a + relay frame stamped with a different key before any session or bucket sees it (counted, + `relay-frame-wrong-key`), and it lanes relay sessions and the per-sender hello budget by + (nodeId, fromKey), so a slot proven for another key under a member's nodeId can neither open a + session the node takes for the member's nor spend the member's hello budget. Re-measured: an + impostor flooding client-hellos at 25 a second no longer keeps a restarted member (whose peer binds + it) from re-handshaking. (3) A nodeId a relay presents under several proven keys, none bound here, + is an identity ambiguity: the node binds none and starts no session for it (no trust-on-first-use by + who answered first), reports it (`identity-ambiguity`, `status().coreSecure.identityAmbiguities`), + and resolves it only by a pin, an anchor certification, or one proven key remaining; a lone proven + key is first proven use as before. (4) The relay's origin binding is only as strong as its + `SYM_RELAY_ORIGINS` setting, or, without it, its edge's routing by Host; an origin mismatch is a + non-retryable refusal here, never a downgrade to unproven auth. Every other close is reconnected + with backoff, 4002 and 4007 included. - **No key rotation** (MMP §3.4). A compromised key means a new identity. - **A stopped identity's store is files.** `openStore` (`@sym-bot/sym/store`, 0.14.1) records a verdict in the store of an identity whose process has stopped, on the lifecycle authority its caller states From 372128ca45d6a253fa225964d4c2b86d9a4657b2 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 19:30:09 +0100 Subject: [PATCH 6/7] Relay: a refused key proof (origin-mismatch, invalid) is a non-retryable auth refusal, never a downgrade A key proof refused with relay-error kind key-proof (origin-mismatch, invalid, no-challenge) and 4002 is held for the close and treated like 4003: phase refused (refused.kind key-proof, refused.origin the origin this node signed for), one loud FATAL naming it, the slow cadence, and never a fall-back to authenticating without a proof on a relay that challenged. reason rate comes as 4008 and is retried with the usual backoff. --- CHANGELOG.md | 7 ++++- lib/relay.js | 40 +++++++++++++++++++++++++ tests/relay-key-proof.test.js | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6c31171..c60d4366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -371,7 +371,12 @@ is unchanged except where a line says so (relay-auth key proof is the one change whole relay-auth, which a relay before 0.8.0 authenticates as it is: the node connects at once (no wait for a challenge that never comes), records `relayVerifiesKeys: false` in `relayState` and `relayStatus`, and says once that any token holder can claim its nodeId there. - Old clients send no `keyProof` and are never challenged. + Old clients send no `keyProof` and are never challenged. A key proof the relay refuses for a reason a + retry cannot fix (`relay-error` `kind: key-proof`, `reason` `origin-mismatch`, `invalid` or + `no-challenge`, then 4002) is a non-retryable auth refusal: phase `refused` (`refused.kind` + `key-proof`, `refused.origin` the origin this node signed for), one loud `FATAL` naming that origin, + and the slow cadence (10 min), never a fall-back to unproven auth on a relay that challenged (a + downgrade). `reason: rate` comes as 4008 and is retried with the usual backoff. - **4004 and 4006 stop the client for good again (MMP §4.4.7), and say why.** A 0.14.3 candidate retried them on a long backoff with a ping-pong detector (`relayIdentityRetry`, `pingPong`, `retryInMs`, metric `relay-identity-close`): a patch over the unverified identity, which also let diff --git a/lib/relay.js b/lib/relay.js index 66b8f08e..feded5b7 100644 --- a/lib/relay.js +++ b/lib/relay.js @@ -464,6 +464,11 @@ class RelayConnection { return `connected to ${s.url} for ${ago(s.since)}, ${s.peers} relay peer(s); ` + (s.relayVerifiesKeys ? 'the relay verified this node\'s key' : 'this relay does not verify keys (any holder of its token can claim this node\'s nodeId there; upgrade the relay)'); case 'refused': + if (s.refused.kind === 'key-proof') { + return `REFUSED by ${s.url}: its key proof was not accepted (${s.refused.reason}). This node signed for ${s.refused.origin}; ` + + `if the relay answers to another origin, dial that URL or have its operator add this one (SYM_RELAY_ORIGINS). ` + + `Not falling back to unproven auth. Retrying every ${Math.round(this._authRefusedRetryMs / 60000)} min meanwhile.`; + } return `REFUSED by ${s.url} (${s.refused.code}: ${s.refused.reason}) since ${ago(s.refused.at)} ago — ` + `the token this session presents is not accepted by that relay. Fix: mint a token with sym_invite_create ` + `or get the team's invite, then sym_join_room with it (or fix SYM_RELAY_TOKEN and restart). ` + @@ -658,6 +663,7 @@ class RelayConnection { this._relayWs = ws; this._proof = { requested: false, nonce: null, proven: false }; // per connection this._authFrameSent = null; + this._proofRefusal = null; const connection = this._connections = (this._connections || 0) + 1; // this connection, for lanes this._attempts++; this._nextRetryAt = null; @@ -780,6 +786,34 @@ class RelayConnection { // production open-mode incident began with a channel table being edited live), so: // say it once, loudly, with the fix; tell the host; keep knocking at a cadence a log // can bear. + // A key proof the relay refused for a reason a retry cannot fix (origin-mismatch, invalid, + // no-challenge): a non-retryable auth refusal, like 4003 — said once, loudly, with the ORIGIN + // this node signed for, and retried only at the slow cadence. Never answered by authenticating + // without a proof: a relay that challenged is a relay that verifies, and dropping the proof + // there would be a downgrade. + const proofRefusal = this._proofRefusal; + this._proofRefusal = null; + if (code === 4002 && proofRefusal) { + const origin = keyProof.originOf(this._relayUrl); + const why = `key proof refused (${proofRefusal.reason}): ${proofRefusal.message}`; + if (!this._refused) this._refused = { code, kind: 'key-proof', reason: why, origin, at: Date.now() }; + this._setPhase('refused'); + if (!this._authRefused) { + this._authRefused = true; + this._log(`FATAL: relay ${this._relayUrl} refused ${this._nodeName}'s key proof (${proofRefusal.reason}). ` + + `This node signed for the origin ${origin}` + + (proofRefusal.reason === 'origin-mismatch' ? ', which the relay does not answer to: dial the relay at a URL its operator lists (SYM_RELAY_ORIGINS), or have the operator add this one' : '') + + `. Not falling back to authenticating without a proof. Retrying every ${Math.round(this._authRefusedRetryMs / 60000)} min, not sooner.`); + if (this._onAuthRefused) { + try { this._onAuthRefused({ relayUrl: this._relayUrl, name: this._nodeName, code, reason: why, kind: 'key-proof', proofReason: proofRefusal.reason, origin }); } catch (err) { + this._log(`onAuthRefused callback threw: ${err.message}`); + } + } + } + this._scheduleReconnect(this._authRefusedRetryMs); + return; + } + if (code === 4003) { const why = reasonStr || 'Invalid token'; if (!this._refused) this._refused = { code, reason: why, at: Date.now() }; @@ -886,6 +920,12 @@ class RelayConnection { const code = Number.isInteger(msg.code) ? ` ${msg.code}` : ''; const text = typeof msg.message === 'string' ? msg.message.slice(0, 500) : '(no message)'; this._log(`Relay error${kind}${code}: ${text}`); + // A refused key proof (sym-relay 0.8.0, kind `key-proof`) is held for the close that follows, so + // a refusal retrying cannot fix (an origin the relay does not answer to, a proof that does not + // verify) is not looped on. `rate` is the one a retry can fix: it comes as 4008, an ordinary close. + if (msg.kind === 'key-proof' && typeof msg.reason === 'string' && msg.reason !== 'rate') { + this._proofRefusal = { reason: msg.reason.slice(0, 40), message: text }; + } } else if (wireNodeId(msg.from) && msg.payload && typeof msg.payload === 'object' && typeof msg.payload.type === 'string') { // The whole message was bounded above (MAX_FRAME_SIZE + the envelope allowance, and its diff --git a/tests/relay-key-proof.test.js b/tests/relay-key-proof.test.js index 3ad9481a..9cb0b0be 100644 --- a/tests/relay-key-proof.test.js +++ b/tests/relay-key-proof.test.js @@ -414,3 +414,58 @@ test('ambiguity (receiver): a nodeId present under two proven keys, none bound, assert.equal(node.status().coreSecure.identityAmbiguities.length, 0); } finally { await node.stop(); await r.close(); } }); + +/** A relay that challenges, then refuses the proof with relay-error kind key-proof (`reason`) and closes `code`. */ +function refusingRelay(reason, code) { + const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }); + const st = { dials: 0, auths: [] }; + wss.on('connection', (ws) => { + st.dials++; + ws.on('message', (d) => { + const m = JSON.parse(String(d)); + if (m.type !== 'relay-auth') return; + st.auths.push(m); + if (!m.proof) { ws.send(JSON.stringify({ type: 'relay-challenge', keyProof: proof.KEY_PROOF, nonce: crypto.randomBytes(32).toString('base64url') })); return; } + ws.send(JSON.stringify({ type: 'relay-error', kind: 'key-proof', code, reason, message: `Invalid auth: key proof was made for ws://somewhere, and this relay answers to wss://elsewhere` })); + ws.close(code, 'Key proof for another origin'); + }); + }); + return { st, ready: new Promise((r) => wss.once('listening', r)), url: () => `ws://127.0.0.1:${wss.address().port}`, + close: () => new Promise((r) => { for (const c of wss.clients) c.terminate(); wss.close(() => r()); }) }; +} + +test('an origin-mismatch refusal is not looped on: refused, the origin named, the slow cadence, never a downgrade to unproven auth', async () => { + const r = refusingRelay('origin-mismatch', 4002); + await r.ready; + const refused = []; + const c = client(r.url(), identity('om-client'), { onAuthRefused: (i) => refused.push(i), authRefusedRetryMs: 60_000 }); + try { + c.rc.connect(); + assert.ok(await until(() => c.rc.state().phase === 'refused', 5000), 'refused, not reconnecting'); + const s = c.rc.state(); + assert.equal(s.refused.kind, 'key-proof'); + assert.equal(s.refused.origin, r.url(), 'the origin this node signed for'); + assert.ok(s.nextRetryAt - Date.now() > 50_000, `the slow cadence (${Math.round((s.nextRetryAt - Date.now()) / 1000)} s), not the 1-30 s backoff`); + assert.match(c.rc.describe(), /key proof was not accepted .*signed for ws:\/\/127\.0\.0\.1:\d+.*Not falling back to unproven auth/); + assert.equal(c.logs.filter((l) => /^FATAL: .*key proof \(origin-mismatch\)/.test(l)).length, 1, 'said once, loudly'); + assert.match(c.logs.find((l) => /^FATAL/.test(l)), /signed for the origin ws:\/\/127\.0\.0\.1:\d+, which the relay does not answer to/); + await wait(1500); + assert.equal(r.st.dials, 1, 'no fast redial'); + assert.equal(refused.length, 1); + assert.equal(refused[0].kind, 'key-proof'); + assert.equal(refused[0].proofReason, 'origin-mismatch'); + // Every relay-auth it sent asked for the proof: none was a plain unproven auth. + assert.ok(r.st.auths.every((a) => a.keyProof === proof.KEY_PROOF), 'never downgraded to unproven auth'); + } finally { c.stop(); await r.close(); } +}); + +test('a rate refusal of a key proof (4008) is an ordinary close, retried with the usual backoff', async () => { + const r = refusingRelay('rate', 4008); + await r.ready; + const c = client(r.url(), identity('rate-client')); + try { + c.rc.connect(); + assert.ok(await until(() => r.st.dials >= 2, 5000), 'redialled soon: a rate limit is a retry'); + assert.notEqual(c.rc.state().phase, 'refused'); + } finally { c.stop(); await r.close(); } +}); From 82b9de972cb9a8eff9655749a93e83313daa653a Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 21:05:30 +0100 Subject: [PATCH 7/7] Relay: relay-slot-left removes one slot of a nodeId; the nodeId stays present while its preferred slot does --- CHANGELOG.md | 2 ++ lib/relay.js | 5 ++++- tests/relay-key-proof.test.js | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c60d4366..64580d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -398,6 +398,8 @@ is unchanged except where a line says so (relay-auth key proof is the one change fan-out entry, so the relay hands it to that slot only; otherwise it goes to every slot of the nodeId (sealed end to end, so a slot that cannot open it gains nothing), which is how it reaches a bound peer on an older engine. At most 16 slots per nodeId are held (`relayState.slotsIgnored`). + `relay-slot-left` (sym-relay 0.8.0: one slot left while the nodeId holds another in the room) removes + that slot only; a client before 0.8.0 ignores it, as it must, since it keys presence by nodeId. - **The backoff starts over when the relay takes the auth (R3-10)**, in the relay-peers handler, not when the socket opens: 1 s, 2 s, 4 s, … 30 s for a relay that keeps closing after the open. - **Lines are cut on bytes and decoded whole (R3-7)** (`lib/line-reader.js`): the interior socket and diff --git a/lib/relay.js b/lib/relay.js index feded5b7..db9a32ea 100644 --- a/lib/relay.js +++ b/lib/relay.js @@ -898,7 +898,10 @@ class RelayConnection { if (msg.type === 'relay-peer-joined') { const id = wireNodeId(msg.nodeId); if (id) this._handleRelayPeerJoined(id, wireName(msg.name), slotKeyOf(msg)); - } else if (msg.type === 'relay-peer-left') { + } else if (msg.type === 'relay-peer-left' || msg.type === 'relay-slot-left') { + // `relay-slot-left` (sym-relay 0.8.0): one slot of a nodeId that still holds another in the + // room left. A client before 0.8.0 ignores it (it keys presence by nodeId); this one removes that + // slot only, and the nodeId stays present while its preferred slot does. const id = wireNodeId(msg.nodeId); if (id) this._handleRelayPeerLeft(id, wireName(msg.name), slotKeyOf(msg)); } else if (msg.type === 'relay-peers') { diff --git a/tests/relay-key-proof.test.js b/tests/relay-key-proof.test.js index 9cb0b0be..525ee069 100644 --- a/tests/relay-key-proof.test.js +++ b/tests/relay-key-proof.test.js @@ -469,3 +469,25 @@ test('a rate refusal of a key proof (4008) is an ordinary close, retried with th assert.notEqual(c.rc.state().phase, 'refused'); } finally { c.stop(); await r.close(); } }); + +test('relay-slot-left removes one slot only: the nodeId stays present while its preferred slot does', async () => { + const P = '01900000-0000-7000-8000-0000000000f9'; + const k = identity('sl').publicKey; + let sock = null; + const r = relay({ after: (ws) => { sock = ws; } }); + await r.ready; + const c = client(r.url(), identity('sl-w'), { keyOf: (n) => (n === P ? k : undefined) }); + try { + c.rc.connect(); + await c.rc.awaitOutcome(5000); + sock.send(JSON.stringify({ type: 'relay-peer-joined', nodeId: P, name: 'member', key: k, proven: true })); + sock.send(JSON.stringify({ type: 'relay-peer-joined', nodeId: P, name: 'claim' })); + assert.ok(await until(() => c.rc.present.get(P) === 'member', 2000)); + sock.send(JSON.stringify({ type: 'relay-slot-left', nodeId: P, name: 'claim' })); + await wait(100); + assert.equal(c.gone.length, 0, 'the legacy slot leaving ends nothing'); + assert.equal(c.rc.present.get(P), 'member'); + sock.send(JSON.stringify({ type: 'relay-peer-left', nodeId: P, name: 'member', key: k, proven: true })); + assert.ok(await until(() => c.gone.length === 1, 2000)); + } finally { c.stop(); await r.close(); } +});