Skip to content
145 changes: 110 additions & 35 deletions CHANGELOG.md

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,13 @@ 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 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.
Expand Down
44 changes: 29 additions & 15 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,21 +321,35 @@ 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 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
Expand Down
14 changes: 10 additions & 4 deletions bin/sym-daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 19 additions & 6 deletions docs/WIRE-0.14.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `_<room>._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.

Expand Down
97 changes: 97 additions & 0 deletions lib/core/relay-auth-proof.js
Original file line number Diff line number Diff line change
@@ -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 };
15 changes: 9 additions & 6 deletions lib/invite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
};
}

Expand Down
Loading
Loading