Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d7fa80a
Identity migration: an agent no longer loses its key to the 0.13 -> b…
sym-bot Oct 14, 2026
0e31e38
0.14.3
sym-bot Oct 14, 2026
f0e6905
Identity halt: say that the link has to go too
sym-bot Oct 14, 2026
6bd36ec
Identity migration: one migrator at a time (a stalled four-process ru…
sym-bot Oct 14, 2026
4f12407
Test: the waiting start is judged by when it returned, not by how lon…
sym-bot Oct 14, 2026
051f44b
CHANGELOG 0.14.3: the fuzzer's numbers as measured
sym-bot Oct 14, 2026
632b95d
A start pinned to a nodeId migrates the 0.13 directory too
sym-bot Oct 14, 2026
85ae65c
R2-1: only an unscoped anchor/admin-chain grant binds a key; a grant …
sym-bot Oct 14, 2026
84559b2
R2-2: a record whose signed bytes the AEAD did not authenticate is dr…
sym-bot Oct 14, 2026
e5b6d31
R2-3, R2-6, R2-22: size-charged budgets, bounded encoder/context/cach…
sym-bot Oct 14, 2026
34f94e2
R2-9: earned key bindings age out, and new ones hold a quarter of the…
sym-bot Oct 14, 2026
f843fac
Security R2-5/11/18/20: LAN hello rate, gated rooms over a relay, pee…
sym-bot Oct 14, 2026
7d93044
Security 0.14.3: sym-attest-v1 findings R2-4, R2-7, R2-8, R2-15, R2-1…
sym-bot Oct 14, 2026
b1e6cd3
Invite: a parse error never repeats the URL, which can carry a relay …
sym-bot Oct 14, 2026
8b03a4d
Public verifyCMB checks the record's own bytes; one string, one key, …
sym-bot Oct 14, 2026
e94ccf1
CHANGELOG 0.14.3 Security; SECURITY.md: the narrowed grant source, no…
sym-bot Oct 14, 2026
262509a
acceptInvite: an invite pins a nodeId a grant view names (the view bi…
sym-bot Oct 14, 2026
3c6172b
R2-3 (inbox): the inbox is bounded by size, 32 M characters; read ite…
sym-bot Oct 14, 2026
3731692
CHANGELOG: the inbox size bound, and the store's disk growth as it st…
sym-bot Oct 14, 2026
ed3dba6
Node lifecycle on one chain; relay records held and re-offered; relay…
sym-bot Oct 14, 2026
6e570a0
Line readers decode whole lines; long IPC paths mapped; socket identi…
sym-bot Oct 14, 2026
2d0a9f0
CHANGELOG 0.14.3: transport and node lifecycle; SECURITY and the wire…
sym-bot Oct 14, 2026
edc54a4
Test: roster-fill holds the new-binding share (R2-9), a quarter of th…
sym-bot Oct 14, 2026
04e9dde
CHANGELOG 0.14.3: the receiver's record budget, as measured
sym-bot Oct 14, 2026
eb76263
Identity state from committed records: staged copy + one-rename commi…
sym-bot Oct 14, 2026
b5bb5e1
Identity: a start beside another start's migration loads what that st…
sym-bot Oct 14, 2026
92cfb0f
relocation: an import never takes this host's own state files (lock, …
sym-bot Oct 14, 2026
4a845e0
Identity: a start pinned to a nodeId never takes a name from another …
sym-bot Oct 14, 2026
788f75e
CHANGELOG 0.14.3: identity and on-disk state, the design as built (nu…
sym-bot Oct 14, 2026
fa6af2a
Review fixes: re-offers wait for pacer room and re-arm; a notified cl…
sym-bot Oct 14, 2026
69e5a36
CHANGELOG 0.14.3: the re-offer's pacer room, the notified close and t…
sym-bot Oct 14, 2026
1ac9934
CHANGELOG 0.14.3: the transport tests, before and after
sym-bot Oct 14, 2026
ad03aed
Identity: a husk is looked at again right before it is set aside (fou…
sym-bot Oct 14, 2026
a0e9663
Test: a husk replaced by another start's commit mid-look is never set…
sym-bot Oct 14, 2026
379d75d
CHANGELOG 0.14.3: the measured numbers (tests, mutation, multi-proces…
sym-bot Oct 14, 2026
ee14a79
Merge remote-tracking branch 'origin/fix/0.14.3-security' into releas…
sym-bot Oct 14, 2026
9f6847c
Merge remote-tracking branch 'origin/fix/0.14.3-transport' into relea…
sym-bot Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
404 changes: 404 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ What this does not solve, said plainly:
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 re-handshakes, but the relay path can be interrupted.
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).
- **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 Expand Up @@ -242,13 +243,22 @@ subpaths it uses.
are comparable only under one kernel id, so a cache keys on `kernelId()` and checks it again after the await.
- `categoryKeyV1(category, text)` is the 64 hex characters of one category's content address (MMP §7.4); the
category name is part of what is hashed and the text is NFC-normalised first.
- `verifyCMB(record, publicKey)` returns `{ signed, valid, error? }`: whether the record's Ed25519 signature
(the one strict rule of MMP §18.3.2) verifies against `publicKey` (raw, base64url) and the signed address is
the address of the categories it carries. It does not decide whose key that is, and it is not the whole of
MMP §8.8.5: a node's session also checks the schema, the bounds, the audience and the author's key before it
delivers a record. Resolve the key from a binding you trust for the record's `createdByNodeId`, never from
the record. `cannot-verify...` errors (no readable container, a container still encrypted) mean "could not
check", which is not "forged".
- `verifyCMB(record, publicKey, { coreSecure })` returns `{ signed, valid, error?, detail?, suite, coreSecure,
assertionId?, record? }`. For a v2.0 record (`suite: 'mmp-sig-v2.0'`) it runs MMP §8.8.5's checks on the
record's own bytes, in order: the closed schema and the §8.8.6 bounds (a member the signature does not cover
makes it invalid, `unsigned-member`; only mood `valence` and `arousal` and `lineage.method`, carried unsigned
by §8.8.4, and a top-level `payload` equal to the JSON of the signed application section, are allowed), the
`assertionId` recomputed (`assertion-id-mismatch`), the Ed25519 signature under the one strict rule of MMP
§18.3.2 against `publicKey` (raw, canonical base64url; `bad-signature`), and the content address
(`content-mismatch`). A valid one has `coreSecure: true`, its `assertionId`, and `record`, the frozen signed
projection: read the record from there and you read only what was signed. A record under sym 0.13's suite
(`suite: 'mmp-sig-v2'`) is `coreSecure: false`: its signature covers neither a node id nor an application
section, so one that carries either, or anything else a 0.13 node did not mint, is invalid; a genuine one
stays valid unless you pass `{ coreSecure: true }`, which refuses every record that is not v2.0
(`not-core-secure`). A host that acts on a record passes `{ coreSecure: true }`. It does not decide whose key
that is or check the audience: resolve the key from a binding you trust for the record's `createdByNodeId`,
never from the record. `cannot-verify...` errors (no readable container, a container still encrypted) mean
"could not check", which is not "forged".

**`@sym-bot/sym/decision-log`** exports `DecisionLog`: `new DecisionLog(dir, { cap = 2000, enabled = true })`
keeps an append-only, capped log of SVAF decisions in `<dir>/log.jsonl`; `record(entry)` appends one plain
Expand Down
55 changes: 39 additions & 16 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,17 @@ Report a vulnerability privately to info@sym.bot (the address in package.json).
binding is earned — an admitted verified record, a verified record its author signed to this
node and this node accepted for delivery (directed exchange is a relationship, so a squatter
cannot take over a conversation once its peer's sessions end), an out-of-band pin (invite, Legacy
Import route), or an in-force grant (MMP §6.6.9: a view over the in-force set, never stored, binding
only an unbound nodeId, and ending with the grant) — and never replaces it: a different key, from any source or
while a session holds the nodeId, is a recorded conflict an operator resolves (a session
proving it is closed with error 1009 `IDENTITY_CONFLICT`, sealed). A handshake, or a second one,
earns nothing; nothing is evicted before it expires; a `proven` binding that verified nothing
expires after 30 days unseen. This node's own nodeId is always its own key. Keys pinned before
Import route), or an in-force grant (MMP §6.6.9 as narrowed in 0.14.3: only an unscoped grant whose
chain is the anchor and `admin` grants; a view over the in-force set, never stored, binding only a
nodeId nothing else binds, and ending with the grant: a session proving the key it names stores
nothing, and a session proving another key is admitted on its own proof with the conflict recorded,
never refused) — and never replaces it: a different key, from any source or while a session holds
the nodeId, is a recorded conflict an operator resolves (a session proving it is closed with error
1009 `IDENTITY_CONFLICT`, sealed). A handshake, or a second one, earns nothing; nothing is evicted
before it expires; a `proven` binding that is new (it verified nothing, or only within one day)
expires after 30 days unseen, and new bindings hold at most a quarter of the registry; one that
verified records on occasions a day or more apart expires after 180 days unseen. A scoped grant
binds no key: it confers its role inside its scope only. This node's own nodeId is always its own key. Keys pinned before
0.14 — from unproven hellos, or from 0.13 grants never checked against their grantors — become
`legacy-claim`: expected, never trusted to verify, so a squatter racing the upgrade cannot take
an honest node's id.
Expand Down Expand Up @@ -118,9 +123,16 @@ Report a vulnerability privately to info@sym.bot (the address in package.json).
must be of prime order too. Tested with the published vectors (ed25519-strict-v2.json) and, at each
of those sites, with two signatures Node's `crypto.verify` accepts and the rule rejects (the
identity key's universal forgery, and R = identity with S = k·a).
- **A forgery in a session's own name ends the session.** A statement that names the session's own
peer as its signer and does not verify under the key that session proved closes it, and its
nodeId is not admitted again for 60 s. A statement the session relays for another signer is
- **A forgery in a session's own name ends the session — when the whole statement travelled sealed.**
An attestation, checkpoint or witness (sealed whole in `control-encrypted`) that names the session's
own peer as its signer and does not verify under the key that session proved closes it, and its
nodeId is not admitted again for 60 s. A RECORD is never charged this way (0.14.3): in `cmb-encrypted`
its signature, `createdBy`, `createdTimestamp`, lineage and application descriptor travel in clear,
outside the AEAD, so a relay or an on-path writer could break them on an honest record. The
receiver checks the AAD-bound assertionId first (§8.8.5) and drops and counts a record whose
assertionId or signature fails (`cmb-clear-metadata-altered`, `cmb-signature-unattributable`). A
verification lane also spends a record's size (1 MiB/s, burst 4 MiB), and the SVAF lane its size too
(128 KiB/s, burst 1.5 MiB). A statement the session relays for another signer is
verified under this node's binding for that signer, which may differ from the relayer's (a local
view); one that fails is dropped and counted (`relayed-signature-unverified`), and the relayer is
not charged. Not charging it does not make forgeries free: each session's records spend a
Expand Down Expand Up @@ -215,10 +227,14 @@ Every store a peer can feed has a fixed bound. Reaching one never stops the node
is never dropped for budget and never spends more than the lane holds. Resolving, which is linear
in the held set and not charged to any lane, is paced instead: a node resolves when it settles, at
most once per twice the time its last resolution took, and a pull's end is decided at the next
settle, never by a resolution forced for it. A ceiling of 4,000
verified statements a second in all (burst 20,000) is spent only after the check; checkpoints at most
4 a second per attester (burst 128); at most 1,024 attesters, 32 checkpoints each, 256 witnesses
per position.
settle, never by a resolution forced for it. One global budget of 4,000 checks a second in all
(burst 20,000) is spent before the check too, each peer through its fair share of it (4,000 / n a
second, n the peers that sent gossip in the last 10 s), so the checks made are bounded whatever the
number of sessions and no peer spends another's share (0.14.3); checkpoints at most 4 a second per
attester (burst 128); at most 1,024 attesters, 32 checkpoints each, 256 witnesses per position. An
attester this node holds equivocation evidence against is never evicted (when every place holds
one, a new attester is refused), and what this node witnessed is never forgotten (past 65,536
attesters, a new one is not witnessed).
- **Records** (MMP §8.8.6, MMP 2.0 update 1): a category's text at most 256 KiB after NFC, the seven
at most 512 KiB, the two-section record's RFC 8785 length at most 720 KiB, so every record fits
one sealed frame. This node mints nothing larger, and refuses a received record over any limit
Expand Down Expand Up @@ -308,9 +324,16 @@ Every store a peer can feed has a fixed bound. Reaching one never stops the node
- **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. The client stops for good on 4004 and 4006 (the existing holder is the
legitimate one), and says so in status, so an eviction is visible rather than a reconnect loop.
Every other close is reconnected with backoff, 4007 included: relay-auth key proof (MMP spec PR
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.
- **No key rotation** (MMP §3.4). A compromised key means a new identity.
Expand Down
44 changes: 27 additions & 17 deletions bin/sym-daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ require('../lib/core/state-root').assertTestSandbox();
// ~/.sym. Building it from the home dir sent a rooted daemon's room, tasks and relay.env to the
// user's real ~/.sym.
const SYM_DIR = require('../lib/core/state-root').SYM_STATE_DIR;
const { getSocketPath, getLogDir, listenExclusive, socketAnswers, socketIdentity, socketIsStill } = require('../lib/platform');
const { getSocketPath, getLogDir, listenExclusive, socketAnswers, socketIdentity, socketIsStill, stampSocket, isMappedIpcEndpoint, checkIpcEndpoint, unixSocketPathMax } = require('../lib/platform');
const { LineReader } = require('../lib/line-reader');
const SOCKET_PATH = getSocketPath();
// Stable name: use SYM_NODE_NAME env, or platform-scoped default
// (not hostname — macOS appends random suffixes to hostname on WiFi,
Expand Down Expand Up @@ -244,25 +245,23 @@ let listeningServer = null;
/** One IPC connection: newline-delimited JSON in, results and events out. */
function onIPCConnection(socket) {
const socketId = nextSocketId++;
let buffer = '';

socket.on('data', (data) => {
buffer += data.toString();
// One line is at most IPC_MAX_LINE: a client that never sends a newline cannot grow the buffer.
if (buffer.length > IPC_MAX_LINE && buffer.indexOf('\n') === -1) {
log(`IPC client ${socketId} sent ${buffer.length} bytes without a newline; closing it`);
buffer = '';
// Lines are cut on bytes and decoded whole (lib/line-reader.js, R3-7): decoding each chunk on its own
// stored a task title split across two reads with U+FFFD in it. One line is at most IPC_MAX_LINE
// BYTES: a client that never sends a newline cannot grow the buffer. A line that is not UTF-8 is
// refused like one that is not JSON.
const reader = new LineReader({
maxLineBytes: IPC_MAX_LINE,
strict: true,
onOverflow: (n) => {
log(`IPC client ${socketId} sent a line of more than ${IPC_MAX_LINE} bytes (${n} so far); closing it`);
socket.destroy();
return;
}
let idx;
while ((idx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
if (line.trim()) takeIPCLine(socketId, socket, line);
}
},
onInvalid: () => sendIPC(socket, { type: 'result', action: null, error: 'not UTF-8' }),
onLine: (line) => { if (line.trim()) takeIPCLine(socketId, socket, line); },
});

socket.on('data', (data) => reader.push(data));

socket.on('close', () => {
listeners.delete(socketId);
});
Expand All @@ -279,9 +278,15 @@ function onListening() {
// chmod not applicable on Windows named pipes
if (process.platform !== 'win32') {
try { fs.chmodSync(SOCKET_PATH, 0o700); } catch {}
// A token in the socket file's times, so a file made later at the same inode is never taken for
// this one (lib/platform.js socketIdentity, R3-14).
stampSocket(SOCKET_PATH);
ownSocket = socketIdentity(SOCKET_PATH);
}
log(`IPC server listening: ${SOCKET_PATH}`);
if (isMappedIpcEndpoint(SOCKET_PATH)) {
log(`(the configured IPC path is longer than the ${unixSocketPathMax()} bytes a Unix socket path holds: mapped to ${SOCKET_PATH}, which every sym client computes the same way)`);
}
}

/** A server that only logs its errors once it is listening: an accept error never ends the daemon. */
Expand All @@ -296,6 +301,9 @@ async function startIPCServer() {
if (!fs.existsSync(SYM_DIR)) {
fs.mkdirSync(SYM_DIR, { recursive: true });
}
// A configured path longer than a Unix socket path can be is mapped (lib/platform.js unixEndpoint,
// R3-6) to a private directory of this user's under /tmp, made here and checked by every client.
checkIpcEndpoint(SOCKET_PATH, { create: true });
// Bind first, and probe only a path that is taken: a socket file is removed only when nothing
// answers on it and it is still the file probed. Probing first and then removing whatever was
// there let two daemons starting together each take the path from the other.
Expand Down Expand Up @@ -786,11 +794,13 @@ function showStatus() {
return;
}

checkIpcEndpoint(SOCKET_PATH); // a mapped endpoint only in this user's private directory (R3-6)
const client = net.createConnection(SOCKET_PATH, () => {
client.write(JSON.stringify({ type: 'status' }) + '\n');
});

let data = '';
client.setEncoding('utf8'); // whole characters across reads (R3-7)
client.on('data', (chunk) => {
data += chunk;
if (data.includes('\n')) {
Expand Down
Loading
Loading