Skip to content

0.14.3: relay-auth key proof replaces the identity-close retry; a relay setting never stops the node (B1) - #60

Open
sym-bot wants to merge 7 commits into
release/0.14.3from
fix/0.14.3-relay-proof
Open

sym-bot wants to merge 7 commits into
release/0.14.3from
fix/0.14.3-relay-proof

Conversation

@sym-bot

@sym-bot sym-bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Into release/0.14.3. Pairs with sym-bot/sym-relay feat/0.8.0-relay-key-proof (draft PR, relay 0.8.0). The MMP spec text follows only after both are tested end to end.

Why

The relay keyed this node's slot by a nodeId the node only claimed. Any holder of the channel token could therefore close a member (4004) or refuse it (4006). The 0.14.3 transport fix answered with a retry on a long backoff and a ping-pong detector. That was a patch over an identity nobody verified. It also let two live processes that hold one key flap forever. This branch removes the patch and fixes the cause: the node proves its key.

Key proof (client)

  • relay-auth carries keyProof: "mmp-relay-auth-v1" when the node holds its identity key.
  • On relay-challenge (32-byte nonce), the node sends relay-auth again with publicKey and proof. The proof is Ed25519 over lp("mmp-relay-auth-v1") ‖ lp(nonce) ‖ lp(room) ‖ lp(nodeId) ‖ lp(origin), where origin is the origin of the URL the node dialed. See lib/core/relay-auth-proof.js. tests/relay-auth-proof-v1.json holds the vector shared with sym-relay.
  • How the sequence was chosen, by testing old peers:
    • sym 0.13.17 and 0.14.2 ignore an unknown pre-auth frame.
    • sym-relay 0.7.0 ignores unknown relay-auth fields, but refuses a fan-out entry that carries one.
    • So the node asks for the challenge inside a whole relay-auth. An old relay takes that frame as it is. The node connects at once (no timed wait), records relayVerifiesKeys: false, and says so once.
  • 4004 and 4006 are hard stops again (MMP §4.4.7), and the stop says its cause:
    • "another process proved this identity's key on this relay; this one stopped", or
    • "this relay does not verify keys, so any holder of the token can cause this; upgrade the relay".
  • identity-collision is { nodeId, name, code, kind, reason, relayVerifiesKeys }. The metric is relay-hard-stop.
  • Removed: the 4004/4006 retry, the backoff, the ping-pong detector, relayIdentityRetry(Ms), pingPong and retryInMs, and their tests. Every other transport fix stays: lifecycle, held records, size bounds, the backoff reset after auth, and the line readers.
  • Slots.
    • Entries with key and proven: true are tracked per nodeId.
    • The slot of the bound key is the peer. A slot with another key is never a candidate, and its departure ends nothing.
    • A directed frame carries toKey (unicast and fan-out) when the relay lists key-proof and a slot proven for the bound key.
  • Status: relayState.relayVerifiesKeys, and relayStatus says whether the relay verified the key.

B1 (re-review): a relay setting never stops the node

  • http:// and https:// are spelled ws:// and wss:// wherever a relay URL comes in: SymNode, RelayConnection, parseInvite and the daemon.
  • A setting that is no relay URL disables the relay instead of failing the node:
    • status phase disabled with the reason;
    • one RELAY DISABLED: line in the log;
    • a relay-disabled event at start().
  • The constructor never throws for it and start() never fails for it. The daemon and mesh-channel 0.11.0 stay up.

Results

  • Tests on this branch. tests/relay-key-proof.test.js has 13 tests and tests/relay-url-setting.test.js has 7.
    • Of these 20, 18 fail on 9f6847c (the candidate before this change) and on 0.14.2. Two pass on both: the shared transcript vector and the "no toKey to an old relay" pin.
    • The restored relay-state hard-stop cases (3) and the rewritten R3-11 lifecycle case fail on 9f6847c.
    • The 4 retry/ping-pong tests are removed.
  • Suites and gates.
    • Full suite (--test-concurrency=2): 1,667 tests, 1,663 pass, 0 fail, 4 skipped.
    • Integration: 6/6. Stock-nodes gate: passed. npm audit: 0.
    • Linux (node:22-slim, node:20-slim): the new and changed test files run 68/68.
    • CI on this PR: green on Node 20 and 22.
  • Cross-version runs (sym-relay scripts/key-proof-matrix, real processes on 127.0.0.1): 46/46. The table is in sym-bot/sym-relay#10.
    • New relay with new nodes: keys proven. An impostor cannot evict or block. toKey reaches only the proven slot. With two processes on one key, the older stops for good with no flap.
    • New relay with 0.14.2 and 0.13.17 nodes: they connect and exchange records.
    • Live relay (0.7.0) with new nodes: they connect at once with relayVerifiesKeys: false.
    • Mixed rooms: records flow both ways.
  • B1.
    • mesh-channel 0.11.0's server.js with SYM_RELAY_URL set to https://, http:// or relay.example.com exits at once on 9f6847c (EBADRELAY) and stays up on this branch.
    • The daemon stays up with an https:// URL and with a malformed URL (tests/relay-url-setting.test.js).

Known, not fixed here

  • A delivered relay frame names the sender's nodeId, not its key. An impostor slot can therefore flood client-hellos "from" a member. Measured: 25/s keeps a restarted member from re-handshaking with a peer. A fromKey on delivered frames, plus per-(nodeId, key) receiver state, would end it. That is a wire addition for review.

Round 2 (coordinator's calls)

  • fromKey. The relay stamps the proven key on every frame it delivers from a proven slot. A client-written fromKey is refused.
    • The receiver (sym) drops a frame whose fromKey isn't the key it binds for that nodeId, before any session or bucket, without charging the bound peer.
    • Relay server sessions and hello budgets are laned by (nodeId, fromKey).
    • Re-measured: an impostor flooding client-hellos at 25/s no longer delays a restarted member whose peer binds it. It re-links immediately (A dropped the impostor's wrong-key frames). Before fromKey, the member was kept out for 40 s or more.
  • Ambiguity, not trust-on-first-use. Several proven keys for an unbound nodeId: the node binds none and starts no session. It reports identity-ambiguity, a metric, a status entry and a log line. It resolves only through a pin, a certification, or one key remaining. A single proven key is first proven use.
  • Proof limits. A token bucket per address (10/s, burst 40) and a global one (200/s, burst 400) are charged before verification. At most 64 unauthenticated connections per address. Over a limit, the relay refuses with 4008 without verifying.
    • 8 s benchmark, 16 client loops, one address: limits off verified 7,260 proofs for 7.56 CPU-s. Limits on verified 119 and refused 24,134 unverified, for 3.57 CPU-s.
    • One spoofed address per connection: limits off 7.64 CPU-s. Limits on verified 1,794 (the global cap) for 2.98 CPU-s.
  • Origin. render.yaml sets SYM_RELAY_ORIGINS=wss://sym-relay.onrender.com. An origin mismatch is refused as kind: key-proof, reason: origin-mismatch, naming both origins. It is not retried, and the client never downgrades to unproven auth.
  • relay-reauth re-registers on the same connection, with the proof.
  • The missing 0.7.0 CHANGELOG entry is added.
  • Results, run under the heavy lock:
    • sym suite: 1,665 pass, 0 fail, 4 skipped. Integration: 6/6. Gate: passed.
    • Relay suite: 205/205.
    • Matrix: 55/55, including the 25/s flood and the ambiguity scenario.
    • Linux (node:22-slim and node:20-slim): sym 70/70, relay 75/75.
    • npm audit: 0 in both repos.

…n; 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().
…and the relay setting, replacing the identity-close retry
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T17:43:13.105356Z 341376b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 341376b48f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/relay.js
Comment on lines +937 to +940
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the bound slot when enforcing the per-node cap

When a token holder registers 16 distinct proven keys under a known member's nodeId before that member appears, this unconditional cap rejects the later slot whose key matches _keyOf(peerId). _preferredSlot() then finds neither the bound key nor a legacy slot, so the legitimate member is never announced as present and cannot be targeted with toKey; the impostor slots have therefore recreated a denial of service against the identity this change is intended to protect. Always admit the bound key, evicting an unbound slot if necessary.

Useful? React with 👍 / 👎.

…nd hello budgets by (nodeId, key); several proven keys for an unbound nodeId are an ambiguity, not TOFU

- 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.
…eral proven keys for an unbound nodeId are an ambiguity, not TOFU
…ble 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant