From d7fa80a8053980bb6f92a07b11b56efb6cd020d9 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 09:54:33 +0100 Subject: [PATCH 01/20] Identity migration: 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 reproduced in a sandbox state dir before anything was changed; the data-loss one was also reported from a developer's machine. What lost the key: migrateNodeDir refused to move a 0.13 node when nodes/by-id// existed, even empty or holding only lock.pid, and left the name unindexed. loadIdentity then read "refused" as "no such agent", made a new identity, renamed the 0.13 directory onto the new node's path and wrote the new identity.json over the old one. A pinned start was bricked instead. The guard asked whether a directory existed; it must ask whether an identity does. Root causes, changed rather than patched: - A second claim on a nodeId is a different identity (or content that cannot merge) already filed under it. An empty directory or a stale lock claims nothing: the node is merged into it. The same identity on both sides is an interrupted move and is finished. A real duplicate is still refused and nothing is overwritten. - loadIdentity treated every answer it did not recognise as "mint". It now mints only when nothing on the host is known under the name: a 0.13 identity still under the name, or a name indexing a node whose identity is missing, halts (IdentityHaltError). - The name is indexed before anything moves and the identity moves last, so a second starter finds the agent and an interrupted move is re-entered by the identity's absence. A step that fails because another process just did it is re-run; a path that became an alias of its destination is never merged onto itself. - Key files: rename keeps the mode 0.13 wrote (0644). identity.json and e2e-keypair.json now end at 0600 and their directories at 0700 after a migration; loading narrows an existing key file, which heals nodes already migrated by 0.14.0-0.14.2. A rewrite of identity.json is atomic. - A move across file systems copies, verifies byte for byte, then removes the source. - Smaller: an identity with a public key and no private key halts; the live-lock fallback checks the tombstone; a symlink that is not a by-id link no longer reads as already indexed; SYM_IDENTITY_DIR switched on for a node whose identity is in its node dir halts with instructions instead of making a new identity. Not fixed, listed in the CHANGELOG: migrateIdentities still discards per-node errors; loadIdentity({nodeId, name}) re-points a name that indexes another node; SYM_IDENTITY_DIR first-time does not move the key for the operator; an explicit nodeId unknown to the index is ignored when a new identity is made. tests/identity-migration-by-id.test.js: 36 tests, 30 of which fail on 0.14.2. Four real processes starting one 0.13 agent at once, 320 starters: every one loaded the original agent. Seventeen mutants of lib/config.js, each caught. --- CHANGELOG.md | 88 ++++ lib/config.js | 285 +++++++++- tests/identity-migration-by-id.test.js | 685 +++++++++++++++++++++++++ 3 files changed, 1031 insertions(+), 27 deletions(-) create mode 100644 tests/identity-migration-by-id.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e766575..8876107d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,93 @@ # Changelog +## 0.14.3 — 2026-10-14 + +**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. The wire and the public surface are unchanged. + +### What was wrong + +- **A new identity could overwrite the old key.** Moving a 0.13 node (`nodes//`) to `nodes/by-id//` was + refused when that directory already existed, even if it was empty or held only a `lock.pid` (something else made it: + a lock, an interrupted run). The refusal left the node unindexed, and the next start by name did not stop there: + it took "refused" for "no such agent", made a NEW identity, moved the old directory onto the new node's path and + wrote the new `identity.json` over the old one. The agent came back under the same name with a different nodeId and + key, and its peers read what it signed as forged. A start pinned by `nodeId` was stuck instead, refusing to start + every time. +- **A 0.13 key file stayed world-readable.** 0.13 wrote `identity.json` and `e2e-keypair.json` without a mode (0644 + under the usual umask). The migration only renames, so they kept it, in a directory at 0755. A new identity in 0.14 is + written 0600; a migrated one never was. +- **A name whose identity file was missing made a replacement agent.** If the name index named a node and its + `identity.json` was not there (the `SYM_IDENTITY_DIR` volume not mounted at boot, the variable set by one embedder and + not another), a start by name made a new nodeId and key and re-pointed the name to it. +- Smaller, same family: a stray symlink at `nodes/` was reported as already indexed when nothing was indexed; + switching `SYM_IDENTITY_DIR` on for a 0.13 node whose identity was in its node directory reported "not a node" and + went on to make a new identity (the old key survived, orphaned in the new node's directory); an identity with a public key + and no private key loaded, and failed later at its first signature; a cosmetic rewrite of `identity.json` (a bare + Windows hostname) truncated the live key file in place; a start in the moment between the directory move and the + index write found no agent and made one; the live-lock fallback skipped the tombstone check; and nothing moved + across file systems (`EXDEV`), so a node whose directories sit on different volumes could not be migrated. + +### Who is exposed + +- Anyone who upgraded a node from 0.13 to 0.14.0, 0.14.1 or 0.14.2 and whose state root had a `nodes/by-id//` + directory for that node without an `identity.json` in it at the time the node was first started by name. Their key + was destroyed on that start; the symptom is a changed nodeId under the same name and peers rejecting its blocks. + 0.14.3 cannot bring a destroyed key back. If you have the old `identity.json` (a backup, a durable + `SYM_IDENTITY_DIR` volume, a copy of `~/.sym`), put it back at `nodes/by-id//identity.json` and set the + nodeId in the name's file `nodes/by-name/.json` to the old one. +- Anyone who switched `SYM_IDENTITY_DIR` on for an existing 0.13 node: the old key is probably still on disk, in + `nodes/by-id//identity.json` (the node that was made in its place). +- Every node migrated from 0.13 holds a private key readable by other local users until it is next loaded with 0.14.3. + +### Changed + +- **The duplicate check looks at the identity, not the directory.** A second claim on a nodeId is a DIFFERENT + `identity.json` (or other content that cannot be merged) already filed under it. A directory that is empty, or holds a + stale lock, claims nothing: the node is MERGED into it. The same identity on both sides is an interrupted move, and + the next start finishes it. A real duplicate is still refused: nothing is moved and nothing is overwritten. +- **The name is indexed before anything moves, and the identity moves last.** A second starter that arrives in the + middle finds the agent; an interrupted move is re-entered on the next start by the identity's absence. A step that + fails because another process just did it is re-run (up to three times) against what that process left, and a path + that has become an alias of its destination through the other process's compat link is never "merged" onto itself. + Measured with real processes: four starters at once, 320 starters in all (with and without a by-id directory already + in place), every one loaded the original agent and the original key was intact every time. The same run on 0.14.2 + made a new identity in 14 of 100 starters, and destroyed the key in every round that had a by-id directory. +- **A start never makes an identity where one exists.** Before it makes one it checks that no 0.13 `identity.json` + sits under the name (in `nodes//` or `SYM_IDENTITY_DIR//`); if one does and could not be migrated it + halts (`IdentityHaltError`) and says why. A name that indexes a node whose identity is missing halts too. + `create: false` still throws `IdentityAbsentError`. Behaviour change: a node whose `identity.json` was deleted by hand + to "start over" now halts instead of silently becoming a new agent; to start the name again as a different agent, + remove the file the message names (`nodes/by-name/.json`). +- **Key files are private.** After a migration `identity.json` and `e2e-keypair.json` are 0600 and the directories that + hold them 0700; loading an identity narrows an existing key file (and the E2E keypair beside it), so nodes already + migrated by 0.14.0 to 0.14.2 are healed on their next start. A new identity's directory is 0700. Best effort on a + file this process does not own, and a no-op on Windows. +- **A rewrite of `identity.json` is atomic** (a private temp file, synced, renamed over it). +- **A move across file systems copies, verifies byte for byte, then removes the source.** A failed copy is removed and + the source left as it was. +- An identity with a public key and no private key halts. A live 0.13 directory's identity is checked against the + tombstone. A symlink at `nodes/` that is not a by-id link is reported `not-a-node`. +- `SYM_IDENTITY_DIR` switched on for a 0.13 node whose identity is still in `nodes//` halts and tells the + operator to copy it to `SYM_IDENTITY_DIR//identity.json`. It does not move it for them (see below). + +### Known, not fixed here + +- `migrateIdentities()` still counts and discards any error from a single node (including the halts above); only the + counters it returns. The node's own start (`loadIdentity`) raises them, so a start is loud. Whether the bulk call + should report them is open. +- `loadIdentity({ nodeId, name })` still re-points a name that already indexes a different node. +- `SYM_IDENTITY_DIR` switched on for an existing node does not move its key for you (it halts and says how). +- `loadIdentity({ nodeId, name, create: true })` with a `nodeId` the name index does not hold and no identity file still + makes a new identity under a new nodeId (it does not use the one given). + +### Tests + +- `tests/identity-migration-by-id.test.js`: 36 tests (35 run on macOS; one real cross-filesystem test runs on Linux). + 30 of them fail on 0.14.2; five pin behaviour that did not change, and one runs only on Linux. The code is mutation-tested: seventeen + mutants of `lib/config.js` (each guard and each ordering removed in turn), each caught by at least one test. + ## 0.14.2 — 2026-10-14 **The daemon's start lock, hardened after review.** 0.14.1 replaced an unsound inode-number guard with a start lock diff --git a/lib/config.js b/lib/config.js index dad1ebf1..f7a224b6 100644 --- a/lib/config.js +++ b/lib/config.js @@ -267,13 +267,18 @@ function nodeIdForName(name) { } catch { return null; } } -/** Write the name index entry (atomic) and the 0.13-compatible `nodes/` symlink (one release). */ -function indexName(name, nodeId) { +/** Write the name index entry, atomically. The index is authoritative for what a name means. */ +function writeNameIndex(name, nodeId) { ensureDir(path.join(NODES_DIR, 'by-name')); const file = nameIndexPath(name); const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, JSON.stringify({ name, nodeId, at: Date.now() })); fs.renameSync(tmp, file); +} + +/** Write the name index entry (atomic) and the 0.13-compatible `nodes/` symlink (one release). */ +function indexName(name, nodeId) { + writeNameIndex(name, nodeId); linkCompat(path.join(NODES_DIR, name), path.join('by-id', nodeId)); const idRoot = process.env.SYM_IDENTITY_DIR; if (idRoot && fs.existsSync(path.join(idRoot, 'by-id', nodeId))) linkCompat(path.join(idRoot, name), path.join('by-id', nodeId)); @@ -298,10 +303,165 @@ function readTombstone(nodeId) { try { return JSON.parse(fs.readFileSync(path.join(nodeDirById(nodeId), TOMBSTONE), 'utf8')); } catch { return null; } } +const lstatOrNull = (p) => { try { return fs.lstatSync(p); } catch { return null; } }; + +/** The files in a node's directory that hold a PRIVATE KEY: its signing identity and the 0.13 E2E keypair. */ +const PRIVATE_KEY_FILES = ['identity.json', 'e2e-keypair.json']; + +/** + * Narrow a node's private key files to their owner (0600), and with `dirs` the directories that hold + * them (0700). `rename` keeps a file's mode, so a key file a 0.13 node wrote at the umask default + * (0644 — world-readable) stayed world-readable through every move; this is what ends that. + * + * Best effort on purpose: a file this process does not own, or a filesystem without modes (Windows), + * must not stop a node from starting. Nothing here changes what a file holds. + * @param {Array} dirs + */ +function securePrivateFiles(dirs, { narrowDirs = false } = {}) { + const narrow = (p, mode) => { try { if ((fs.statSync(p).mode & 0o077) !== 0) fs.chmodSync(p, mode); } catch { /* absent, or not ours */ } }; + for (const d of new Set(dirs.filter(Boolean))) { + for (const f of PRIVATE_KEY_FILES) narrow(path.join(d, f), 0o600); + if (narrowDirs) narrow(d, 0o700); + } +} + +/** Write a file the way a key file must be written: a private temp file, synced, renamed over the target. */ +function writeFileAtomic0600(file, text) { + const tmp = `${file}.${process.pid}.tmp`; + let fd; + try { + fd = fs.openSync(tmp, 'w', 0o600); + fs.writeSync(fd, text); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + fs.renameSync(tmp, file); + } catch (e) { + if (fd !== undefined) try { fs.closeSync(fd); } catch { /* */ } + try { fs.unlinkSync(tmp); } catch { /* */ } + throw e; + } +} + +/** True when two trees are the same: same names, same bytes, same link targets. */ +function sameTree(a, b) { + const sa = lstatOrNull(a); const sb = lstatOrNull(b); + if (!sa || !sb) return false; + if (sa.isSymbolicLink() || sb.isSymbolicLink()) return sa.isSymbolicLink() && sb.isSymbolicLink() && fs.readlinkSync(a) === fs.readlinkSync(b); + if (sa.isDirectory() || sb.isDirectory()) { + if (!(sa.isDirectory() && sb.isDirectory())) return false; + const ea = fs.readdirSync(a).sort(); const eb = fs.readdirSync(b).sort(); + return ea.length === eb.length && ea.every((n, i) => n === eb[i] && sameTree(path.join(a, n), path.join(b, n))); + } + if (sa.size !== sb.size) return false; + try { return Buffer.compare(fs.readFileSync(a), fs.readFileSync(b)) === 0; } catch { return false; } // unreadable (a socket): not provably the same +} + +/** Copy a tree to a path that does not exist yet (fails with EEXIST, creating nothing, if it does). */ +function copyTree(src, dst) { + const st = fs.lstatSync(src); + if (st.isSymbolicLink()) { fs.symlinkSync(fs.readlinkSync(src), dst); return; } + if (st.isDirectory()) { + fs.mkdirSync(dst, { mode: 0o700 }); + for (const e of fs.readdirSync(src)) copyTree(path.join(src, e), path.join(dst, e)); + fs.chmodSync(dst, st.mode & 0o777); + return; + } + fs.copyFileSync(src, dst, fs.constants.COPYFILE_EXCL); + fs.chmodSync(dst, st.mode & 0o777); +} + +/** + * Move a file or directory to a path that does not exist. `rename` cannot cross a filesystem + * (EXDEV) — and SYM_IDENTITY_DIR exists to put the identity on ANOTHER volume — so then: copy, check + * the copy is the same as the source, and only then remove the source. A failed copy is removed and + * the source is left untouched; nothing is ever deleted that has not been verified elsewhere. + */ +function moveTree(src, dst) { + try { fs.renameSync(src, dst); return; } catch (e) { if (e.code !== 'EXDEV') throw e; } + try { + copyTree(src, dst); + if (!sameTree(src, dst)) throw new Error(`the copy of ${src} at ${dst} does not match its source; the source is untouched`); + } catch (e) { + if (e.code !== 'EEXIST') fs.rmSync(dst, { recursive: true, force: true }); + throw e; + } + fs.rmSync(src, { recursive: true, force: true }); +} + +/** + * The first path under `dst` that `src` could not be merged onto without replacing something that is + * not the same, or null. A stale `lock.pid` never conflicts (its holder was checked; the one already in + * place stands). Two copies of the same file do not conflict; two DIFFERENT files do — and an + * `identity.json` that differs is exactly a second claim on a nodeId. + */ +function mergeConflict(src, dst) { + const s = lstatOrNull(src); const d = lstatOrNull(dst); + if (!s || !d) return null; + if (s.isDirectory() && d.isDirectory()) { + for (const e of fs.readdirSync(src)) { + const c = mergeConflict(path.join(src, e), path.join(dst, e)); + if (c) return c; + } + return null; + } + if (path.basename(src) === 'lock.pid') return null; + return sameTree(src, dst) ? null : dst; +} + +/** True when both paths lead to one file or directory (through a symlink, say). */ +function sameFile(a, b) { + try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return false; } +} + +/** + * Move `src` onto `dst`, MERGING when `dst` already exists (check `mergeConflict` first): what is not + * in `dst` moves in, what is already there stays. A directory that merely exists is not a reason to + * leave a node where it was. `identity.json` moves LAST, so "the identity is in place" means "everything + * else is too" — an interrupted move is re-entered by the identity's absence. An entry already moved by + * another process (ENOENT) is skipped: the move is safe to run from several processes. + */ +function mergeMove(src, dst) { + const s = lstatOrNull(src); + if (!s) return; + const d = lstatOrNull(dst); + // Already the same file: the other process finished and replaced the old directory with the compat link, so + // this path now leads to `dst`. Moving it onto itself is a no-op at best; deleting it would delete `dst`. + if (d && sameFile(src, dst)) return; + if (!d) { + try { moveTree(src, dst); } catch (e) { if (e.code === 'ENOENT' && !lstatOrNull(src)) return; throw e; } + return; + } + if (s.isDirectory() && d.isDirectory()) { + const names = fs.readdirSync(src).sort((a, b) => (a === 'identity.json') - (b === 'identity.json')); + for (const n of names) mergeMove(path.join(src, n), path.join(dst, n)); + try { fs.rmdirSync(src); } catch (e) { if (e.code !== 'ENOENT') throw e; } + return; + } + // The same file on both sides (or a stale lock): the one in place stands. Checked again here, because + // this deletes: another process may have changed either side since mergeConflict ran. + if (path.basename(src) !== 'lock.pid' && !sameTree(src, dst)) throw new Error(`cannot merge ${src} onto ${dst}: they differ`); + fs.rmSync(src, { recursive: true, force: true }); +} + +/** + * Where a 0.13 identity for `name` still sits (SYM_IDENTITY_DIR/, else nodes/), or null. + * `existsSync` follows a symlink, so a link that leads to an identity counts. + */ +function legacyIdentityPath(name) { + const idRoot = process.env.SYM_IDENTITY_DIR; + for (const p of [idRoot && path.join(idRoot, name, 'identity.json'), path.join(NODES_DIR, name, 'identity.json')]) { + if (p && fs.existsSync(p)) return p; + } + return null; +} + /** * Read and validate an identity.json. Never regenerates a key: a file that exists and cannot be read - * halts (IdentityHaltError). A pre-v0.3.7 identity with no keypair gets its first key; a private key - * with no public key gets the public key derived from it. + * halts (IdentityHaltError), and so does one with a public key and no private key. A pre-v0.3.7 + * identity with no keypair at all gets its first key; a private key with no public key gets the public + * key derived from it. A key file left readable by others (0.13 wrote it at the umask default) is + * narrowed to its owner. * @private */ function readIdentityFile(idPath, name) { @@ -322,6 +482,11 @@ function readIdentityFile(idPath, name) { if (!identity || typeof identity !== 'object' || !identity.nodeId) { throw new IdentityHaltError(name, idPath, 'identity.json has no nodeId — the file is not an identity'); } + // A PUBLIC KEY BUT NO PRIVATE KEY is not "no keypair": the key existed and is gone (a truncated write, + // a scrubbed export). Neither mint one nor sign with `undefined`: a new key is a different agent. + if (identity.publicKey && !identity.privateKey) { + throw new IdentityHaltError(name, idPath, 'identity.json has a public key but no private key — the private key is gone, not missing, and a new one would be a different agent'); + } let mutated = false; // NO KEYPAIR AT ALL (pre-v0.3.7 nodes): nothing to replace, so minting one gives an agent its first // key; the nodeId is preserved. A PRIVATE KEY BUT NO PUBLIC KEY: derive it, never generate one. @@ -346,7 +511,10 @@ function readIdentityFile(idPath, name) { identity.hostname = normalized; mutated = true; } - if (mutated) fs.writeFileSync(idPath, JSON.stringify(identity, null, 2)); + // Atomic and private: a truncate-in-place that is interrupted leaves a key file with no key in it, and a + // mode passed to writeFileSync is ignored for a file that already exists. + if (mutated) writeFileAtomic0600(idPath, JSON.stringify(identity, null, 2)); + securePrivateFiles([path.dirname(idPath)]); return identity; } @@ -360,25 +528,56 @@ function readIdentityFile(idPath, name) { * A 0.13 identity file is read with the same reader a load uses, so one that exists and cannot be * established (unreadable, not JSON, no nodeId) HALTS (IdentityHaltError) instead of reading as * "no node here" — which would let the caller mint a replacement under the agent's name. + * + * What makes a second claim on a nodeId is a DIFFERENT identity (or content that cannot merge) already + * filed under it — not a directory that merely exists. `nodes/by-id//` is also made by a lock, or + * by an earlier interrupted move, and an empty one used to leave the node where it was, unindexed, for + * good. Now the node is MERGED into it. The same identity on both sides is an interrupted move, resumed. + * The name is indexed BEFORE anything moves, so a second process starting meanwhile finds the agent + * rather than concluding there is none; the identity moves last, so an interrupted move is re-entered + * by the identity's absence (`loadIdentity` does it). + * + * Key files end at 0600 (their directory at 0700): `rename` keeps the mode 0.13 wrote them with. + * A move across filesystems (EXDEV) is copied, verified and only then removed. + * + * A 0.13 identity in `nodes//` while SYM_IDENTITY_DIR is set (the volume switched on after the + * agent was made) HALTS: it is neither where the identity dir looks nor safe to leave for a mint. * @returns {'moved'|'indexed'|'skipped-live'|'skipped-duplicate'|'not-a-node'|'already'} */ function migrateNodeDir(name) { + // Safe from several processes: a step that fails because another process just did it (an entry gone, a + // directory already replaced by the compat link) is re-run against the state that process left, which + // reads as moved. What persists is thrown, the last one seen. + for (let attempt = 1; ; attempt++) { + try { return migrateNodeDirOnce(name); } catch (e) { if (attempt >= 3) throw e; } + } +} + +function migrateNodeDirOnce(name) { const legacy = path.join(NODES_DIR, name); const idRoot = process.env.SYM_IDENTITY_DIR; const legacyIdDir = idRoot ? path.join(idRoot, name) : legacy; - const lst = (p) => { try { return fs.lstatSync(p); } catch { return null; } }; const indexFromLink = (link) => { const m = /by-id[\\/]([^\\/]+)$/.exec(fs.readlinkSync(link)); if (m && isNodeIdText(m[1])) { indexName(name, m[1]); return 'indexed'; } - return 'already'; + return 'not-a-node'; // a link to somewhere else: nothing was indexed, so it must not read as 'already' }; - const st = lst(legacy); + const st = lstatOrNull(legacy); if (st && st.isSymbolicLink()) return nodeIdForName(name) ? 'already' : indexFromLink(legacy); if (st && !st.isDirectory()) return 'not-a-node'; - const idSt = idRoot ? lst(legacyIdDir) : st; + const idSt = idRoot ? lstatOrNull(legacyIdDir) : st; if (idRoot && idSt && idSt.isSymbolicLink()) return nodeIdForName(name) ? 'already' : indexFromLink(legacyIdDir); const idFile = path.join(legacyIdDir, 'identity.json'); - if (!fs.existsSync(idFile)) return 'not-a-node'; + if (!fs.existsSync(idFile)) { + const inNodeDir = path.join(legacy, 'identity.json'); + if (idRoot && st && fs.existsSync(inNodeDir)) { + throw new IdentityHaltError(name, inNodeDir, + `SYM_IDENTITY_DIR (${idRoot}) is set, but this agent's identity is still in its node directory and not in ${legacyIdDir}. ` + + `It was not moved, and a new one will not be made in its place. Copy it to ${idFile} (keep the original until the agent has started), ` + + 'or unset SYM_IDENTITY_DIR.'); + } + return 'not-a-node'; + } const identity = readIdentityFile(idFile, name); // halts on an identity it cannot establish const nodeId = identity.nodeId; if (!isNodeIdText(nodeId)) throw new IdentityHaltError(name, idFile, `the nodeId ${JSON.stringify(String(nodeId).slice(0, 40))} cannot name a directory under nodes/by-id`); @@ -386,17 +585,24 @@ function migrateNodeDir(name) { const lock = readLockFile(path.join(legacy, 'lock.pid')); if (lock && lock.pid !== process.pid && lockIsHeldByLiveProcess(lock)) return 'skipped-live'; } + // The index gives this name to another node: two identities claim one name, and neither is moved over the other. + const held = nodeIdForName(name); + if (held && held !== nodeId) return 'skipped-duplicate'; const dest = nodeDirById(nodeId); const idDest = idRoot && idSt && idSt.isDirectory() ? path.join(idRoot, 'by-id', nodeId) : null; - // Two dirs claim one nodeId: the first one moved stays, and nothing of this one is moved. - if ((st && fs.existsSync(dest)) || (idDest && fs.existsSync(idDest))) return 'skipped-duplicate'; + // Two dirs claim one nodeId only when what is filed under it differs: the first one moved stays, and + // nothing of this one is moved. An empty directory, or one holding a stale lock, claims nothing. + if ((st && mergeConflict(legacy, dest)) || (idDest && mergeConflict(legacyIdDir, idDest))) return 'skipped-duplicate'; + securePrivateFiles([legacy, legacyIdDir]); // before the move: a copy across volumes must not start from a world-readable file + writeNameIndex(name, nodeId); ensureDir(path.dirname(dest)); - if (st) fs.renameSync(legacy, dest); + if (st) mergeMove(legacy, dest); else ensureDir(dest); if (idDest) { ensureDir(path.dirname(idDest)); - fs.renameSync(legacyIdDir, idDest); + mergeMove(legacyIdDir, idDest); } + securePrivateFiles([dest, idDest], { narrowDirs: true }); indexName(name, nodeId); return 'moved'; } @@ -430,8 +636,10 @@ function migrateIdentities() { * - `create: false` throws IdentityAbsentError when the identity is absent: a host restoring a * known agent can never mint a replacement silently; * - `create: true` (the default, for a brand-new agent) is the ONLY path that mints, and it mints - * only when the name is unknown (a name is an index; renaming changes the index, never the - * identity); + * only when nothing on this host is known under the name: a name that indexes a node whose + * identity is missing HALTS (IdentityHaltError), and so does a 0.13 identity still sitting under + * the name that could not be moved. Minting there would replace a key (a name is an index; + * renaming changes the index, never the identity); * - a tombstoned identity (exported to another host) refuses to load: IdentityTombstonedError. * * @param {{ nodeId?: string, name?: string, create?: boolean }} o @@ -441,17 +649,21 @@ function loadIdentity({ nodeId, name, create = true } = {}) { if (name !== undefined) validateName(name); if (!nodeId && !name) throw new Error('loadIdentity needs a nodeId or a name'); if (nodeId && !isNodeIdText(nodeId)) throw new Error(`not a nodeId: ${String(nodeId).slice(0, 40)}`); + let migrated = null; if (!nodeId && name) { nodeId = nodeIdForName(name); - if (!nodeId) { - const r = migrateNodeDir(name); - if (r === 'moved' || r === 'already' || r === 'indexed') nodeId = nodeIdForName(name); - if (r === 'skipped-live') { + // Not indexed, or indexed but the identity is not in place: the 0.13 layout may still hold it (a move + // another process is making, or one that was interrupted). Migrating is idempotent. + if (!nodeId || !fs.existsSync(path.join(identityDirById(nodeId), 'identity.json'))) { + migrated = migrateNodeDir(name); + if (migrated === 'skipped-live') { // A 0.13 process of this name holds its old directory: load it in place, unmoved. - const idRoot = process.env.SYM_IDENTITY_DIR; - const idPath = idRoot ? path.join(idRoot, name, 'identity.json') : path.join(NODES_DIR, name, 'identity.json'); - return readIdentityFile(idPath, name); + const live = readIdentityFile(legacyIdentityPath(name), name); + const liveTomb = readTombstone(live.nodeId); + if (liveTomb) throw new IdentityTombstonedError(live.nodeId, liveTomb); + return live; } + if (!nodeId) nodeId = nodeIdForName(name); // the index, whichever process wrote it, says who this name is } } if (nodeId) { @@ -461,13 +673,31 @@ function loadIdentity({ nodeId, name, create = true } = {}) { if (fs.existsSync(idPath)) { const identity = readIdentityFile(idPath, name || nodeId); if (identity.nodeId !== nodeId) throw new IdentityHaltError(name || nodeId, idPath, `identity.json names ${identity.nodeId}, not ${nodeId}`); + securePrivateFiles([nodeDirById(nodeId)]); // the 0.13 E2E keypair beside it, when the identity lives elsewhere if (name && nodeIdForName(name) !== nodeId) indexName(name, nodeId); return identity; } if (!create || !name) throw new IdentityAbsentError(name ? `"${name}" (${nodeId})` : nodeId); + // The name indexes a node that exists, and its identity file is not where it should be (a volume not + // mounted, SYM_IDENTITY_DIR set by one embedder and not another, a file removed). That is a known agent + // that cannot be loaded, never a new one. + if (nodeIdForName(name) === nodeId) { + throw new IdentityHaltError(name, idPath, + `the name "${name}" indexes node ${nodeId}, but its identity.json is not there. If SYM_IDENTITY_DIR changed or its volume is not mounted, ` + + `restore that. If this agent is gone for good, remove ${nameIndexPath(name)} to let the name start again as a different agent.`); + } } if (!create) throw new IdentityAbsentError(`"${name}"`); - // No identity under this name: a genuinely new agent. This is the ONLY path that mints one. + // No identity under this name: a genuinely new agent. This is the ONLY path that mints one — and only + // once it is established that no 0.13 identity sits under the name waiting to be moved. A directory with + // an identity.json in it is not "a directory with no identity"; renaming it onto the new node's path would + // bury a live key (and write the new identity over it). + const stranded = legacyIdentityPath(name); + if (stranded) { + throw new IdentityHaltError(name, stranded, + `a 0.13 identity exists under this name and was not migrated (migration said: ${migrated || 'nothing'}). ` + + 'Making a new identity here would orphan or overwrite its key.'); + } const kp = generateSigningKeyPair(); const identity = { nodeId: uuidv7(), @@ -480,15 +710,16 @@ function loadIdentity({ nodeId, name, create = true } = {}) { // A 0.13-style `nodes//` dir with no identity (made by hand, or by a test) is the new node's // dir: it moves with the identity rather than being left behind. const legacy = path.join(NODES_DIR, name); - let legacyDir = false; - try { legacyDir = fs.lstatSync(legacy).isDirectory(); } catch { /* absent */ } + const legacySt = lstatOrNull(legacy); + const legacyDir = Boolean(legacySt && legacySt.isDirectory()); const dir = nodeDirById(identity.nodeId); ensureDir(path.dirname(dir)); - if (legacyDir) fs.renameSync(legacy, dir); + if (legacyDir) moveTree(legacy, dir); ensureDir(dir); const idDir = identityDirById(identity.nodeId); ensureDir(idDir); fs.writeFileSync(path.join(idDir, 'identity.json'), JSON.stringify(identity, null, 2), { mode: 0o600 }); + securePrivateFiles([dir, idDir], { narrowDirs: true }); indexName(name, identity.nodeId); return identity; } diff --git a/tests/identity-migration-by-id.test.js b/tests/identity-migration-by-id.test.js new file mode 100644 index 00000000..7438d0cc --- /dev/null +++ b/tests/identity-migration-by-id.test.js @@ -0,0 +1,685 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME to a temp sandbox before lib/config loads + +/** + * The 0.13 -> by-id identity migration must never cost an agent its key. + * + * 0.14.0 - 0.14.2 had two ways to destroy one. (1) The duplicate guard asked "does nodes/by-id// + * EXIST?" instead of "does an IDENTITY already sit there?", so a by-id directory made by anything else (a + * lock, an interrupted move) left the 0.13 node where it was, unindexed, for good. (2) `loadIdentity` read + * every answer it did not recognise as "no such agent" and MINTED: it renamed the 0.13 directory (the old + * identity.json still in it) onto the new node's path and wrote the new identity over the old key. + * + * The tests below hold the line the founder drew ("the system must refuse to start rather than silently + * create a replacement identity"): a name that has an identity on this host never gets a new one. + * Every test uses its own agent name; the state root is shared by the file, as in config.test.js. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const { spawn } = require('child_process'); +const cfg = require('../lib/config'); + +const { NODES_DIR, IdentityHaltError, IdentityTombstonedError } = cfg; +const posix = process.platform !== 'win32'; // modes are not a thing on Windows +const modeTest = posix ? it : it.skip; + +let seq = 0; +const uniq = (b) => `${b}-${process.pid}-${++seq}-${Math.random().toString(36).slice(2, 6)}`; +const modeOf = (p) => fs.statSync(p).mode & 0o777; +const read = (p) => fs.readFileSync(p, 'utf8'); +const exists = (p) => fs.existsSync(p); +const isLink = (p) => { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } }; + +/** A real Ed25519 identity, in the shape lib/config writes. */ +function makeIdentity(name, extra = {}) { + const kp = crypto.generateKeyPairSync('ed25519'); + return { + nodeId: cfg.uuidv7(), name, hostname: 'host.local', createdAt: 1, + publicKey: kp.publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('base64url'), + privateKey: kp.privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(16).toString('base64url'), + ...extra, + }; +} + +function put(file, text, mode = 0o644) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + fs.chmodSync(file, mode); // explicit: the umask must not decide what a test means +} + +/** What 0.13 left on disk: nodes// with identity.json (and e2e-keypair.json) at the umask default. */ +function legacyNode(name, { e2e = false, identity = makeIdentity(name), where = path.join(NODES_DIR, name), withIdentity = true } = {}) { + if (withIdentity) put(path.join(where, 'identity.json'), JSON.stringify(identity, null, 2), 0o644); + if (e2e) put(path.join(where, 'e2e-keypair.json'), JSON.stringify({ publicKey: 'cHVi', privateKey: 'cHJpdg==' }, null, 2), 0o644); + put(path.join(where, 'cmbs', 'x.json'), '{"x":1}'); + return identity; +} + +/** A nodes/by-id// that something else made: a lock, an interrupted run. No identity in it. */ +function husk(nodeId, { lock = true } = {}) { + const d = cfg.nodeDirById(nodeId); + fs.mkdirSync(d, { recursive: true }); + if (lock) put(path.join(d, 'lock.pid'), '999999\n'); + return d; +} + +/** Every identity.json under nodes/by-id and SYM_IDENTITY_DIR/by-id whose `name` field is `name`. */ +function identitiesNamed(name) { + const found = []; + const roots = [path.join(NODES_DIR, 'by-id'), process.env.SYM_IDENTITY_DIR && path.join(process.env.SYM_IDENTITY_DIR, 'by-id')].filter(Boolean); + for (const root of roots) { + if (!exists(root)) continue; + for (const d of fs.readdirSync(root)) { + const f = path.join(root, d, 'identity.json'); + try { if (JSON.parse(read(f)).name === name) found.push(f); } catch { /* */ } + } + } + return found; +} + +function withIdentityDir(fn) { + const prev = process.env.SYM_IDENTITY_DIR; + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-idroot-')); + process.env.SYM_IDENTITY_DIR = root; + try { return fn(root); } finally { + if (prev === undefined) delete process.env.SYM_IDENTITY_DIR; else process.env.SYM_IDENTITY_DIR = prev; + fs.rmSync(root, { recursive: true, force: true }); + } +} + +/** Replace fs. for the duration of `body`; `wrap(original)` returns the replacement. */ +function patchFs(fn, wrap, body) { + const original = fs[fn]; + fs[fn] = wrap(original); + try { return body(); } finally { fs[fn] = original; } +} +const exdev = (msg) => Object.assign(new Error(`EXDEV: cross-device link not permitted (${msg})`), { code: 'EXDEV' }); + +/** + * T9: every case asserts it measured something. A run over nothing returns moved: 0 and would "pass" a + * test that only checked for the absence of damage. + */ +function assertMoved(result, atLeast = 1) { + assert.ok(result && result.moved >= atLeast, `the migration moved ${result && result.moved}, expected at least ${atLeast}: the test measured nothing`); +} + +describe('T1/T2: the migration leaves key files private', () => { + modeTest('identity.json written 0644 by 0.13 ends at 0600, and its directory at 0700', () => { + const name = uniq('t1'); + const id = legacyNode(name); + assert.strictEqual(modeOf(path.join(NODES_DIR, name, 'identity.json')), 0o644, 'setup: a 0.13 key file, world-readable'); + assert.strictEqual(cfg.migrateNodeDir(name), 'moved'); + const dir = cfg.nodeDirById(id.nodeId); + assert.strictEqual(modeOf(path.join(dir, 'identity.json')), 0o600); + assert.strictEqual(modeOf(dir), 0o700); + assert.strictEqual(JSON.parse(read(path.join(dir, 'identity.json'))).privateKey, id.privateKey, 'the key is the same key'); + }); + + modeTest('e2e-keypair.json ends at 0600 with its bytes unchanged', () => { + const name = uniq('t2'); + const id = legacyNode(name, { e2e: true }); + const before = read(path.join(NODES_DIR, name, 'e2e-keypair.json')); + assert.strictEqual(cfg.migrateNodeDir(name), 'moved'); + const f = path.join(cfg.nodeDirById(id.nodeId), 'e2e-keypair.json'); + assert.strictEqual(modeOf(f), 0o600); + assert.strictEqual(read(f), before); + }); + + modeTest('with SYM_IDENTITY_DIR: the identity file is 0600 and both directories 0700 (T8)', () => { + withIdentityDir((idRoot) => { + const name = uniq('t8'); + const id = makeIdentity(name); + put(path.join(idRoot, name, 'identity.json'), JSON.stringify(id, null, 2), 0o644); + put(path.join(NODES_DIR, name, 'cmbs', 'x.json'), '{}'); + put(path.join(NODES_DIR, name, 'e2e-keypair.json'), '{}', 0o644); + assert.strictEqual(cfg.migrateNodeDir(name), 'moved'); + const idDir = path.join(idRoot, 'by-id', id.nodeId); + const nodeDir = cfg.nodeDirById(id.nodeId); + assert.strictEqual(modeOf(path.join(idDir, 'identity.json')), 0o600); + assert.strictEqual(modeOf(idDir), 0o700); + assert.strictEqual(modeOf(nodeDir), 0o700); + assert.strictEqual(modeOf(path.join(nodeDir, 'e2e-keypair.json')), 0o600); + assert.ok(exists(path.join(nodeDir, 'cmbs', 'x.json')), 'the node dir moved with its store'); + assert.strictEqual(cfg.nodeIdForName(name), id.nodeId, 'the index resolves'); + assert.strictEqual(cfg.loadIdentity({ name, create: false }).privateKey, id.privateKey); + }); + }); + + modeTest('loading narrows a key file an earlier release already migrated at 0644 (and the E2E keypair beside it)', () => { + const name = uniq('heal'); + const id = legacyNode(name, { e2e: true }); + cfg.migrateNodeDir(name); + const dir = cfg.nodeDirById(id.nodeId); + fs.chmodSync(path.join(dir, 'identity.json'), 0o644); fs.chmodSync(path.join(dir, 'e2e-keypair.json'), 0o644); + assert.strictEqual(cfg.loadIdentity({ name, create: false }).nodeId, id.nodeId); + assert.strictEqual(modeOf(path.join(dir, 'identity.json')), 0o600); + assert.strictEqual(modeOf(path.join(dir, 'e2e-keypair.json')), 0o600); + }); + + modeTest('a rewrite of identity.json (bare Windows hostname) lands at 0600, not at the old file mode', () => { + const name = uniq('rewrite'); + const id = legacyNode(name, { identity: makeIdentity(name, { hostname: 'bare-host' }) }); + cfg.migrateNodeDir(name); + const f = path.join(cfg.nodeDirById(id.nodeId), 'identity.json'); + const onDisk = JSON.parse(read(f)); onDisk.hostname = 'bare-host'; + put(f, JSON.stringify(onDisk, null, 2), 0o644); + const loaded = cfg.loadIdentity({ name, create: false }); + assert.strictEqual(loaded.hostname, 'bare-host.local'); + assert.strictEqual(JSON.parse(read(f)).hostname, 'bare-host.local', 'the rewrite happened'); + assert.strictEqual(modeOf(f), 0o600); + }); + + modeTest('a newly made agent: identity 0600, its directory 0700', () => { + const name = uniq('fresh'); + const id = cfg.loadIdentity({ name }); + const dir = cfg.nodeDirById(id.nodeId); + assert.strictEqual(modeOf(path.join(dir, 'identity.json')), 0o600); + assert.strictEqual(modeOf(dir), 0o700); + }); +}); + +describe('T3-T5: a by-id directory that merely exists is not a second claim on the nodeId', () => { + it('T3: the node is merged into an existing by-id dir that holds only lock.pid', () => { + const name = uniq('t3'); + const id = legacyNode(name); + const dest = husk(id.nodeId); + assert.strictEqual(cfg.migrateNodeDir(name), 'moved'); + assert.strictEqual(JSON.parse(read(path.join(dest, 'identity.json'))).privateKey, id.privateKey, 'the identity is under the nodeId, key unchanged'); + assert.ok(exists(path.join(dest, 'lock.pid')), 'what was already there stays'); + assert.ok(exists(path.join(dest, 'cmbs', 'x.json')), 'the store came with it'); + assert.strictEqual(cfg.nodeIdForName(name), id.nodeId); + assert.ok(isLink(path.join(NODES_DIR, name)), 'nodes/ is the compat link'); + assert.strictEqual(fs.realpathSync(path.join(NODES_DIR, name)), fs.realpathSync(dest)); + }); + + it('T3: an EMPTY by-id dir is merged into too', () => { + const name = uniq('t3e'); + const id = legacyNode(name); + husk(id.nodeId, { lock: false }); + assert.strictEqual(cfg.migrateNodeDir(name), 'moved'); + assert.ok(exists(path.join(cfg.nodeDirById(id.nodeId), 'identity.json'))); + }); + + it('T4: a pinned start recovers once the node is migrated', () => { + const name = uniq('t4'); + const id = legacyNode(name); + husk(id.nodeId); + assertMoved(cfg.migrateIdentities()); + const loaded = cfg.loadIdentity({ nodeId: id.nodeId, create: false }); + assert.strictEqual(loaded.nodeId, id.nodeId); + assert.strictEqual(loaded.privateKey, id.privateKey); + }); + + it('T5: an unpinned start never mints over a husk: same nodeId, same key, and no second identity exists', () => { + const name = uniq('t5'); + const id = legacyNode(name); + husk(id.nodeId); + const loaded = cfg.loadIdentity({ name }); + assert.strictEqual(loaded.nodeId, id.nodeId); + assert.strictEqual(loaded.privateKey, id.privateKey); + const files = identitiesNamed(name); + assert.strictEqual(files.length, 1, `exactly one identity answers to ${name}: ${files.join(', ')}`); + assert.strictEqual(JSON.parse(read(files[0])).privateKey, id.privateKey, 'and it still holds the original private key'); + }); + + it('the reported repro: a 0.13 dir plus a pre-existing EMPTY by-id// keeps the old key (0.14.2 minted and overwrote it)', () => { + const name = uniq('repro'); + const old = legacyNode(name); + fs.mkdirSync(path.join(NODES_DIR, 'by-id', old.nodeId), { recursive: true }); + const loaded = cfg.loadIdentity({ name }); + assert.strictEqual(loaded.nodeId, old.nodeId, 'not a new nodeId'); + assert.strictEqual(loaded.privateKey, old.privateKey); + assert.strictEqual(identitiesNamed(name).length, 1); + assert.strictEqual(JSON.parse(read(path.join(cfg.nodeDirById(old.nodeId), 'identity.json'))).privateKey, old.privateKey, 'the old private key is still on disk'); + assert.strictEqual(cfg.nodeIdForName(name), old.nodeId); + }); + + it('the same identity on both sides is an interrupted move, resumed, not a duplicate', () => { + const name = uniq('resume-same'); + const id = legacyNode(name); + const dest = cfg.nodeDirById(id.nodeId); + put(path.join(dest, 'identity.json'), read(path.join(NODES_DIR, name, 'identity.json')), 0o600); + assert.strictEqual(cfg.migrateNodeDir(name), 'moved'); + assert.ok(exists(path.join(dest, 'cmbs', 'x.json'))); + assert.ok(isLink(path.join(NODES_DIR, name))); + assert.strictEqual(cfg.loadIdentity({ name, create: false }).privateKey, id.privateKey); + }); +}); + +describe('T6/T7: a real duplicate is refused, and refusing is a halt, never a mint', () => { + function duplicate(name) { + const mine = legacyNode(name); + const other = makeIdentity(name, { nodeId: mine.nodeId }); // another key, filed under the same nodeId + const dest = cfg.nodeDirById(mine.nodeId); + put(path.join(dest, 'identity.json'), JSON.stringify(other, null, 2), 0o600); + return { mine, other, dest }; + } + + it('T6: a DIFFERENT identity.json already under the nodeId: skipped-duplicate, and nothing is overwritten', () => { + const name = uniq('t6'); + const { mine, dest } = duplicate(name); + const legacyBefore = read(path.join(NODES_DIR, name, 'identity.json')); + const destBefore = read(path.join(dest, 'identity.json')); + assert.strictEqual(cfg.migrateNodeDir(name), 'skipped-duplicate'); + assert.strictEqual(read(path.join(NODES_DIR, name, 'identity.json')), legacyBefore, 'the 0.13 identity is byte-unchanged'); + assert.strictEqual(read(path.join(dest, 'identity.json')), destBefore, 'the one already filed is byte-unchanged'); + assert.strictEqual(cfg.nodeIdForName(name), null, 'nothing was indexed'); + assert.ok(!exists(path.join(dest, 'cmbs')), 'and nothing was merged in beside it'); + assert.ok(mine.privateKey); + }); + + it('T7: loading that name halts (IdentityHaltError) and does not touch the 0.13 file', () => { + const name = uniq('t7'); + const { dest } = duplicate(name); + const legacyBefore = read(path.join(NODES_DIR, name, 'identity.json')); + const destBefore = read(path.join(dest, 'identity.json')); + const dirsBefore = fs.readdirSync(path.join(NODES_DIR, 'by-id')).length; + assert.throws(() => cfg.loadIdentity({ name }), (e) => e instanceof IdentityHaltError && /0\.13 identity/.test(e.reason)); + assert.strictEqual(read(path.join(NODES_DIR, name, 'identity.json')), legacyBefore); + assert.strictEqual(read(path.join(dest, 'identity.json')), destBefore); + assert.strictEqual(fs.readdirSync(path.join(NODES_DIR, 'by-id')).length, dirsBefore, 'no new node directory was made'); + assert.strictEqual(cfg.nodeIdForName(name), null); + }); + + it('a differing store file in an existing by-id dir is a conflict too: nothing moves', () => { + const name = uniq('conflict'); + const id = legacyNode(name); + const dest = husk(id.nodeId, { lock: false }); + put(path.join(dest, 'cmbs', 'x.json'), '{"x":2}'); + assert.strictEqual(cfg.migrateNodeDir(name), 'skipped-duplicate'); + assert.ok(exists(path.join(NODES_DIR, name, 'identity.json')), 'the 0.13 node is where it was'); + assert.strictEqual(read(path.join(dest, 'cmbs', 'x.json')), '{"x":2}'); + assert.throws(() => cfg.loadIdentity({ name }), IdentityHaltError); + }); + + it('a name the index already gives to ANOTHER node is not re-pointed by a stray 0.13 directory', () => { + const name = uniq('held'); + const held = cfg.loadIdentity({ name }); + const stray = makeIdentity(name); + fs.unlinkSync(path.join(NODES_DIR, name)); // a real directory where the index says there is a link + legacyNode(name, { identity: stray }); + assert.strictEqual(cfg.migrateNodeDir(name), 'skipped-duplicate'); + assert.strictEqual(cfg.nodeIdForName(name), held.nodeId, 'the index is untouched'); + assert.strictEqual(cfg.loadIdentity({ name }).nodeId, held.nodeId, 'and the indexed agent still loads'); + assert.strictEqual(JSON.parse(read(path.join(NODES_DIR, name, 'identity.json'))).privateKey, stray.privateKey, 'the stray key is not destroyed'); + }); +}); + +describe('F4: a name that indexes a node whose identity is missing halts', () => { + it('create (the default) halts and neither mints nor re-points the index', () => { + const name = uniq('f4'); + const first = cfg.loadIdentity({ name }); + fs.rmSync(path.join(cfg.nodeDirById(first.nodeId), 'identity.json')); + const dirsBefore = fs.readdirSync(path.join(NODES_DIR, 'by-id')).length; + assert.throws(() => cfg.loadIdentity({ name }), (e) => e instanceof IdentityHaltError && e.reason.includes(first.nodeId)); + assert.strictEqual(cfg.nodeIdForName(name), first.nodeId, 'the index still names the agent'); + assert.strictEqual(fs.readdirSync(path.join(NODES_DIR, 'by-id')).length, dirsBefore, 'nothing was minted'); + }); + + it('create: false is still IdentityAbsentError (EIDENTITYABSENT)', () => { + const name = uniq('f4b'); + const first = cfg.loadIdentity({ name }); + fs.rmSync(path.join(cfg.nodeDirById(first.nodeId), 'identity.json')); + assert.throws(() => cfg.loadIdentity({ name, create: false }), (e) => e.code === 'EIDENTITYABSENT'); + assert.throws(() => cfg.loadIdentity({ nodeId: first.nodeId, create: false }), (e) => e.code === 'EIDENTITYABSENT'); + }); + + it('an identity volume that is not mounted at boot halts instead of making a replacement agent', () => { + withIdentityDir((idRoot) => { + const name = uniq('f4v'); + const first = cfg.loadIdentity({ name }); + assert.ok(exists(path.join(idRoot, 'by-id', first.nodeId, 'identity.json')), 'setup: the key is on the identity volume'); + const away = `${idRoot}.away`; + fs.renameSync(idRoot, away); + try { + assert.throws(() => cfg.loadIdentity({ name }), IdentityHaltError); + assert.strictEqual(cfg.nodeIdForName(name), first.nodeId); + } finally { fs.rmSync(idRoot, { recursive: true, force: true }); fs.renameSync(away, idRoot); } + assert.strictEqual(cfg.loadIdentity({ name }).privateKey, first.privateKey, 'mounted again: the same agent'); + }); + }); +}); + +describe('the safety net: the mint path never buries a 0.13 identity', () => { + it('F5: SYM_IDENTITY_DIR switched on for a 0.13 node whose identity is in its node dir halts, and moves nothing', () => { + withIdentityDir(() => { + const name = uniq('f5'); + const id = legacyNode(name); + const before = read(path.join(NODES_DIR, name, 'identity.json')); + assert.throws(() => cfg.migrateNodeDir(name), (e) => e instanceof IdentityHaltError && /SYM_IDENTITY_DIR/.test(e.reason)); + assert.throws(() => cfg.loadIdentity({ name }), IdentityHaltError); + assert.strictEqual(read(path.join(NODES_DIR, name, 'identity.json')), before); + assert.ok(!isLink(path.join(NODES_DIR, name)) && exists(path.join(NODES_DIR, name, 'cmbs')), 'the node dir was not moved'); + assert.strictEqual(identitiesNamed(name).length, 0, 'and no identity was made'); + assert.ok(id.privateKey); + }); + }); + + it('F7: a stray symlink at nodes/ that leads to an identity but is not a by-id link does not read as indexed, and halts', () => { + const name = uniq('f7'); + const elsewhere = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sym-stray-')), 'agent'); + const id = legacyNode(name, { where: elsewhere }); + fs.mkdirSync(NODES_DIR, { recursive: true }); + fs.symlinkSync(elsewhere, path.join(NODES_DIR, name), 'dir'); + assert.strictEqual(cfg.migrateNodeDir(name), 'not-a-node', 'nothing was indexed, so not "already"'); + assert.strictEqual(cfg.nodeIdForName(name), null); + assert.throws(() => cfg.loadIdentity({ name }), IdentityHaltError); + assert.strictEqual(fs.readlinkSync(path.join(NODES_DIR, name)), elsewhere, 'the link is left alone'); + assert.strictEqual(JSON.parse(read(path.join(elsewhere, 'identity.json'))).privateKey, id.privateKey); + }); + + it('a hand-made nodes// with NO identity still becomes the new agent\'s directory (documented, unchanged)', () => { + const name = uniq('nodir-id'); + put(path.join(NODES_DIR, name, 'cmbs', 'kept.json'), '{"k":1}'); + const id = cfg.loadIdentity({ name }); + assert.ok(exists(path.join(cfg.nodeDirById(id.nodeId), 'cmbs', 'kept.json')), 'the store moved with the new identity'); + assert.ok(isLink(path.join(NODES_DIR, name))); + }); + + it('a brand-new name still mints exactly one identity', () => { + const name = uniq('brand-new'); + const a = cfg.loadIdentity({ name }); + const b = cfg.loadIdentity({ name }); + assert.strictEqual(a.nodeId, b.nodeId); + assert.strictEqual(identitiesNamed(name).length, 1); + }); +}); + +describe('F10: a public key without a private key halts', () => { + it('is not loaded, not repaired, and the file is untouched', () => { + const name = uniq('f10'); + const id = makeIdentity(name); delete id.privateKey; + legacyNode(name, { identity: id }); + const f = path.join(NODES_DIR, name, 'identity.json'); + const before = read(f); + assert.throws(() => cfg.loadIdentity({ name }), (e) => e instanceof IdentityHaltError && /no private key/.test(e.reason)); + assert.strictEqual(read(f), before); + }); +}); + +describe('F6: rewriting identity.json is atomic', () => { + it('a failure while writing leaves the original key file intact, and no temp file behind', () => { + const name = uniq('f6'); + const id = legacyNode(name, { identity: makeIdentity(name, { hostname: 'bare-host' }) }); + const f = path.join(NODES_DIR, name, 'identity.json'); + const before = read(f); + let injected = 0; + patchFs('writeSync', (orig) => function (fd, ...rest) { + injected++; + throw Object.assign(new Error('ENOSPC: no space left on device (injected)'), { code: 'ENOSPC' }); + }, () => { + assert.throws(() => cfg.loadIdentity({ name }), /ENOSPC/); + }); + assert.ok(injected >= 1, 'the failure was reached'); + assert.strictEqual(read(f), before, 'the key file is byte-identical: it was never truncated'); + assert.deepStrictEqual(fs.readdirSync(path.join(NODES_DIR, name)).filter((n) => n.endsWith('.tmp')), []); + assert.strictEqual(cfg.loadIdentity({ name }).privateKey, id.privateKey, 'and the next start succeeds with the same key'); + }); +}); + +describe('F9: a move across filesystems copies, verifies, then removes', () => { + it('EXDEV on the identity dir: the identity arrives intact at 0600 and the source is gone', () => { + withIdentityDir((idRoot) => { + const name = uniq('f9'); + const id = makeIdentity(name); + put(path.join(idRoot, name, 'identity.json'), JSON.stringify(id, null, 2), 0o644); + put(path.join(idRoot, name, 'sub', 'a.txt'), 'a'); + put(path.join(NODES_DIR, name, 'cmbs', 'x.json'), '{}'); + const result = patchFs('renameSync', (orig) => function (a, b) { + if (String(a).startsWith(idRoot) && String(b).startsWith(idRoot)) throw exdev('simulated'); + return orig.call(fs, a, b); + }, () => cfg.migrateNodeDir(name)); + assert.strictEqual(result, 'moved'); + const dest = path.join(idRoot, 'by-id', id.nodeId); + assert.strictEqual(JSON.parse(read(path.join(dest, 'identity.json'))).privateKey, id.privateKey); + assert.strictEqual(read(path.join(dest, 'sub', 'a.txt')), 'a'); + if (posix) assert.strictEqual(modeOf(path.join(dest, 'identity.json')), 0o600); + assert.ok(!exists(path.join(idRoot, name, 'identity.json')) || isLink(path.join(idRoot, name)), 'the source is gone (only the compat link is left)'); + assert.strictEqual(cfg.loadIdentity({ name, create: false }).privateKey, id.privateKey); + }); + }); + + it('a copy that fails leaves the source untouched and no half-copy at the destination', () => { + withIdentityDir((idRoot) => { + const name = uniq('f9b'); + const id = makeIdentity(name); + put(path.join(idRoot, name, 'identity.json'), JSON.stringify(id, null, 2), 0o600); + put(path.join(NODES_DIR, name, 'cmbs', 'x.json'), '{}'); + const src = read(path.join(idRoot, name, 'identity.json')); + assert.throws(() => patchFs('renameSync', (orig) => function (a, b) { + if (String(a).startsWith(idRoot) && String(b).startsWith(idRoot)) throw exdev('simulated'); + return orig.call(fs, a, b); + }, () => patchFs('copyFileSync', () => function () { throw Object.assign(new Error('EIO: injected'), { code: 'EIO' }); }, + () => cfg.migrateNodeDir(name))), /EIO/); + assert.strictEqual(read(path.join(idRoot, name, 'identity.json')), src, 'the identity is where it was'); + assert.ok(!exists(path.join(idRoot, 'by-id', id.nodeId)), 'no half-copy'); + // the fault clears: the next run completes the move + assert.strictEqual(cfg.loadIdentity({ name }).privateKey, id.privateKey); + assert.ok(exists(path.join(idRoot, 'by-id', id.nodeId, 'identity.json'))); + }); + }); +}); + +describe('F9 on a real second filesystem (Linux: nodes/by-id lives on /dev/shm, a tmpfs)', () => { + const shm = '/dev/shm'; + const second = process.platform === 'linux' && exists(shm) && fs.statSync(shm).dev !== fs.statSync(os.tmpdir()).dev; + (second ? it : it.skip)('rename fails with EXDEV for real, and the node still arrives whole, private, and unchanged', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-exdev-')); + const far = fs.mkdtempSync(path.join(shm, 'sym-exdev-')); + try { + const state = path.join(root, 'state'); + fs.mkdirSync(path.join(state, 'nodes'), { recursive: true }); + fs.symlinkSync(far, path.join(state, 'nodes', 'by-id'), 'dir'); // everything filed by nodeId lands on the other filesystem + const script = ` + const fs = require('fs'), path = require('path'); + const c = require(${JSON.stringify(path.resolve(__dirname, '../lib/config.js'))}); + const name = 'exdev-agent'; + const id = JSON.parse(process.env.LEGACY_IDENTITY); + const dir = path.join(c.NODES_DIR, name); + fs.mkdirSync(path.join(dir, 'cmbs'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'identity.json'), JSON.stringify(id, null, 2), { mode: 0o644 }); fs.chmodSync(path.join(dir, 'identity.json'), 0o644); + fs.writeFileSync(path.join(dir, 'e2e-keypair.json'), '{}'); fs.chmodSync(path.join(dir, 'e2e-keypair.json'), 0o644); + fs.writeFileSync(path.join(dir, 'cmbs', 'x.json'), '{"x":1}'); + let sawExdev = false; + const orig = fs.renameSync; + fs.renameSync = function (a, b) { try { return orig.call(fs, a, b); } catch (e) { if (e.code === 'EXDEV') sawExdev = true; throw e; } }; + const loaded = c.loadIdentity({ name }); + const dest = c.nodeDirById(id.nodeId); + console.log(JSON.stringify({ + sawExdev, same: loaded.nodeId === id.nodeId && loaded.privateKey === id.privateKey, + sourceGone: !fs.existsSync(path.join(dir, 'cmbs')) || fs.lstatSync(dir).isSymbolicLink(), + store: fs.readFileSync(path.join(dest, 'cmbs', 'x.json'), 'utf8'), + idMode: fs.statSync(path.join(dest, 'identity.json')).mode & 0o777, e2eMode: fs.statSync(path.join(dest, 'e2e-keypair.json')).mode & 0o777, + dirMode: fs.statSync(dest).mode & 0o777, onOtherFs: fs.realpathSync(dest).startsWith(fs.realpathSync(${JSON.stringify(far)})), + }));`; + const id = makeIdentity('exdev-agent'); + const out = await new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['-e', script], { + env: { ...process.env, SYM_STATE_DIR: state, LEGACY_IDENTITY: JSON.stringify(id) }, stdio: ['ignore', 'pipe', 'pipe'], + }); + let so = ''; let se = ''; + child.stdout.on('data', (d) => { so += d; }); child.stderr.on('data', (d) => { se += d; }); + child.on('exit', (code) => (code === 0 ? resolve(so) : reject(new Error(`child exited ${code}: ${se}`)))); + }); + const r = JSON.parse(out); + assert.strictEqual(r.onOtherFs, true, 'setup: the node lives on the other filesystem'); + assert.strictEqual(r.sawExdev, true, 'the kernel really refused the rename'); + assert.strictEqual(r.same, true); + assert.strictEqual(r.sourceGone, true); + assert.strictEqual(r.store, '{"x":1}'); + assert.strictEqual(r.idMode, 0o600); + assert.strictEqual(r.e2eMode, 0o600); + assert.strictEqual(r.dirMode, 0o700); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(far, { recursive: true, force: true }); + } + }); +}); + +describe('F11: the window between the move and the index is not a mint race', () => { + it('a second starter that arrives mid-move finds the agent instead of making a new one', () => { + const name = uniq('f11'); + const id = legacyNode(name); + let racer = null; + patchFs('renameSync', (orig) => function (a, b) { + const r = orig.call(fs, a, b); + if (racer === null && a === path.join(NODES_DIR, name)) { // the node dir has just moved; the index is not final yet + racer = 'running'; + try { racer = cfg.loadIdentity({ name }); } catch (e) { racer = e; } + } + return r; + }, () => { assert.strictEqual(cfg.loadIdentity({ name }).nodeId, id.nodeId); }); + assert.ok(racer && racer.nodeId, `the racing starter loaded an identity: ${racer && racer.message}`); + assert.strictEqual(racer.nodeId, id.nodeId, 'the same agent'); + assert.strictEqual(identitiesNamed(name).length, 1); + }); + + it('an interrupted move is resumed by the next start: the identity moves last, and its absence re-enters the move', () => { + const name = uniq('f11r'); + const id = legacyNode(name); + put(path.join(NODES_DIR, name, 'aaa.json'), '{}'); put(path.join(NODES_DIR, name, 'zzz.json'), '{}'); // names on both sides of identity.json + husk(id.nodeId); // forces a merge, entry by entry + patchFs('renameSync', (orig) => function (a, b) { + if (path.basename(String(a)) === 'identity.json' && String(a).startsWith(NODES_DIR)) throw Object.assign(new Error('EIO: injected'), { code: 'EIO' }); + return orig.call(fs, a, b); + }, () => { assert.throws(() => cfg.migrateNodeDir(name), /EIO/); }); + assert.ok(exists(path.join(NODES_DIR, name, 'identity.json')), 'the identity had not moved yet'); + for (const f of ['aaa.json', 'zzz.json', path.join('cmbs', 'x.json')]) assert.ok(exists(path.join(cfg.nodeDirById(id.nodeId), f)), `but the rest had: ${f}`); + assert.strictEqual(cfg.nodeIdForName(name), id.nodeId, 'the name was indexed first'); + const loaded = cfg.loadIdentity({ name }); + assert.strictEqual(loaded.nodeId, id.nodeId); + assert.strictEqual(loaded.privateKey, id.privateKey); + assert.ok(isLink(path.join(NODES_DIR, name)), 'finished: nodes/ is the compat link'); + assert.strictEqual(identitiesNamed(name).length, 1); + }); +}); + +describe('F11: another process finishing the move underneath this one', () => { + it('a step that fails because the other process just did it is re-run against what it left, and reads as moved', () => { + const name = uniq('f11-other'); + const id = legacyNode(name); + const legacy = path.join(NODES_DIR, name); + const dest = cfg.nodeDirById(id.nodeId); + let helped = false; + const result = patchFs('renameSync', (orig) => function (a, b) { + if (!helped && a === legacy) { // the other process gets there first: it moves the directory and leaves the link + helped = true; + orig.call(fs, a, b); + fs.symlinkSync(path.join('by-id', id.nodeId), legacy, 'dir'); + throw Object.assign(new Error(`ENOENT: no such file or directory, rename '${a}' -> '${b}'`), { code: 'ENOENT' }); + } + return orig.call(fs, a, b); + }, () => cfg.migrateNodeDir(name)); + assert.ok(helped, 'the other process ran'); + assert.strictEqual(result, 'already'); + assert.strictEqual(JSON.parse(read(path.join(dest, 'identity.json'))).privateKey, id.privateKey); + assert.strictEqual(cfg.loadIdentity({ name, create: false }).privateKey, id.privateKey); + }); + + it('a path that now leads through the other process\'s link to the destination is never "merged" onto itself (that would delete it)', () => { + const name = uniq('f11-alias'); + const id = legacyNode(name); + put(path.join(NODES_DIR, name, 'aaa.json'), '{"a":1}'); + const legacy = path.join(NODES_DIR, name); + const dest = husk(id.nodeId); // forces an entry-by-entry merge + let helped = false; let listings = 0; + const result = patchFs('readdirSync', (orig) => function (p, ...rest) { + const names = orig.call(fs, p, ...rest); + if (!helped && p === legacy && ++listings === 2) { // the 2nd listing is the merge's own, after the conflict check's + helped = true; // the other process finishes the whole move while this one holds the list + for (const n of names) fs.renameSync(path.join(legacy, n), path.join(dest, n)); + fs.rmdirSync(legacy); + fs.symlinkSync(path.join('by-id', id.nodeId), legacy, 'dir'); + } + return names; + }, () => cfg.migrateNodeDir(name)); + assert.ok(helped, 'the other process ran'); + assert.strictEqual(result, 'already'); + for (const f of ['identity.json', 'aaa.json', path.join('cmbs', 'x.json')]) assert.ok(exists(path.join(dest, f)), `${f} survived`); + assert.strictEqual(JSON.parse(read(path.join(dest, 'identity.json'))).privateKey, id.privateKey); + }); +}); + +describe('F11 with real processes: several starters at once never make a second identity', () => { + /** Four processes call loadIdentity({ name }) at the same moment on a 0.13 node, in a state root of their own. */ + async function race({ withHusk }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-race-')); + try { + const state = path.join(root, 'state'); + const nodes = path.join(state, 'nodes'); + const name = 'racing-agent'; + const id = makeIdentity(name); + fs.mkdirSync(path.join(nodes, name, 'cmbs'), { recursive: true }); + for (let i = 0; i < 30; i++) fs.writeFileSync(path.join(nodes, name, 'cmbs', `c${i}.json`), '{}'); + fs.writeFileSync(path.join(nodes, name, 'identity.json'), JSON.stringify(id, null, 2)); + if (withHusk) { fs.mkdirSync(path.join(nodes, 'by-id', id.nodeId), { recursive: true }); fs.writeFileSync(path.join(nodes, 'by-id', id.nodeId, 'lock.pid'), '999999\n'); } + const code = ` + const c = require(${JSON.stringify(path.resolve(__dirname, '../lib/config.js'))}); + process.stdin.once('data', () => { + try { const i = c.loadIdentity({ name: ${JSON.stringify(name)} }); console.log(JSON.stringify({ id: i.nodeId, key: i.privateKey })); } + catch (e) { console.log(JSON.stringify({ error: e.name + ': ' + String(e.message).split('\\n')[0].slice(0, 200) })); } + process.exit(0); + }); + console.log('ready');`; + const kids = Array.from({ length: 4 }, () => spawn(process.execPath, ['-e', code], { env: { ...process.env, SYM_STATE_DIR: state }, stdio: ['pipe', 'pipe', 'inherit'] })); + await Promise.all(kids.map((k) => new Promise((resolve) => k.stdout.once('data', resolve)))); + const done = kids.map((k) => new Promise((resolve) => { let b = ''; k.stdout.on('data', (d) => { b += d; }); k.on('exit', () => resolve(JSON.parse(b.trim().split('\n').pop()))); })); + kids.forEach((k) => k.stdin.write('go\n')); + const outcomes = await Promise.all(done); + const kept = JSON.parse(fs.readFileSync(path.join(nodes, 'by-id', id.nodeId, 'identity.json'), 'utf8')); + return { id, outcomes, kept }; + } finally { fs.rmSync(root, { recursive: true, force: true }); } + } + + for (const withHusk of [false, true]) { + it(`${withHusk ? 'with a by-id dir that already exists' : 'plain'}: every starter that loads gets the original agent, and the key survives`, async () => { + let loaded = 0; + for (let round = 0; round < 5; round++) { + const { id, outcomes, kept } = await race({ withHusk }); + assert.strictEqual(kept.privateKey, id.privateKey, 'the original private key is still the one on disk'); + for (const o of outcomes) { + if (o.error) continue; // a starter may be told to start again; it must not become somebody else + loaded++; + assert.strictEqual(o.id, id.nodeId, 'the same nodeId'); + assert.strictEqual(o.key, id.privateKey, 'the same key'); + } + } + assert.ok(loaded >= 5, `starters loaded the agent (${loaded}): the test measured something`); + }); + } +}); + +describe('F13: the live-lock fallback checks the tombstone', () => { + it('an identity exported away does not load from an unmoved 0.13 directory a live process holds', async () => { + const name = uniq('f13'); + const id = legacyNode(name); + const dir = path.join(NODES_DIR, name); + const child = spawn(process.execPath, ['-e', ` + const c = require(${JSON.stringify(path.resolve(__dirname, '../lib/config.js'))}); + c.acquireIdentityLock(${JSON.stringify(name)}, { dir: ${JSON.stringify(dir)} }); + console.log('held'); setInterval(() => {}, 1000);`], { stdio: ['ignore', 'pipe', 'inherit'] }); + try { + await new Promise((resolve, reject) => { child.stdout.once('data', resolve); child.once('exit', () => reject(new Error('lock holder exited'))); }); + assert.strictEqual(cfg.migrateNodeDir(name), 'skipped-live', 'setup: a live process holds the old directory'); + assert.strictEqual(cfg.loadIdentity({ name }).nodeId, id.nodeId, 'not tombstoned: it loads in place'); + put(path.join(cfg.nodeDirById(id.nodeId), cfg.TOMBSTONE), JSON.stringify({ movedAt: Date.now() })); + assert.throws(() => cfg.loadIdentity({ name }), (e) => e instanceof IdentityTombstonedError); + } finally { child.kill(); } + }); +}); + +describe('T9: the checks measure something', () => { + it('assertMoved fails a run that moved nothing, and a populated run passes it', () => { + const name = uniq('t9'); + legacyNode(name); + assertMoved(cfg.migrateIdentities()); + assert.throws(() => assertMoved(cfg.migrateIdentities()), /measured nothing/, 'a second pass moves nothing and must not satisfy the guard'); + }); +}); From 0e31e380d9d7ec7068fc3de24357658a304ae8b1 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 09:54:49 +0100 Subject: [PATCH 02/20] 0.14.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6f21af2d..7039b1f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sym-bot/sym", - "version": "0.14.2", + "version": "0.14.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sym-bot/sym", - "version": "0.14.2", + "version": "0.14.3", "license": "Apache-2.0", "dependencies": { "bonjour-service": "^1.3.0", diff --git a/package.json b/package.json index cc55b397..01d22355 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sym-bot/sym", - "version": "0.14.2", + "version": "0.14.3", "description": "Infrastructure and protocol for multi-agent collective intelligence", "main": "lib/node.js", "exports": { From f0e6905d9e6d172918a391c186b681811b1f88fc Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 09:57:10 +0100 Subject: [PATCH 03/20] Identity halt: say that the link has to go too A name whose identity file is missing halts and names what to remove to start the name again as a different agent. It named only nodes/by-name/.json, but nodes/ is a link to the old by-id directory and re-indexes the name from it on the next start, so removing the index alone halts again. The message now names both, and a test follows the message to the end (the first removal is shown not to be enough). The test count in the CHANGELOG is now 37 (31 fail on 0.14.2). --- CHANGELOG.md | 6 +++--- lib/config.js | 2 +- tests/identity-migration-by-id.test.js | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8876107d..16ba428b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,7 @@ or expose a key and the small ones beside them. The wire and the public surface halts (`IdentityHaltError`) and says why. A name that indexes a node whose identity is missing halts too. `create: false` still throws `IdentityAbsentError`. Behaviour change: a node whose `identity.json` was deleted by hand to "start over" now halts instead of silently becoming a new agent; to start the name again as a different agent, - remove the file the message names (`nodes/by-name/.json`). + remove the two paths the message names (`nodes/by-name/.json` and the link `nodes/`). - **Key files are private.** After a migration `identity.json` and `e2e-keypair.json` are 0600 and the directories that hold them 0700; loading an identity narrows an existing key file (and the E2E keypair beside it), so nodes already migrated by 0.14.0 to 0.14.2 are healed on their next start. A new identity's directory is 0700. Best effort on a @@ -84,8 +84,8 @@ or expose a key and the small ones beside them. The wire and the public surface ### Tests -- `tests/identity-migration-by-id.test.js`: 36 tests (35 run on macOS; one real cross-filesystem test runs on Linux). - 30 of them fail on 0.14.2; five pin behaviour that did not change, and one runs only on Linux. The code is mutation-tested: seventeen +- `tests/identity-migration-by-id.test.js`: 37 tests (36 run on macOS; one real cross-filesystem test runs on Linux). + 31 of them fail on 0.14.2; five pin behaviour that did not change, and one runs only on Linux. The code is mutation-tested: seventeen mutants of `lib/config.js` (each guard and each ordering removed in turn), each caught by at least one test. ## 0.14.2 — 2026-10-14 diff --git a/lib/config.js b/lib/config.js index f7a224b6..d3d1beba 100644 --- a/lib/config.js +++ b/lib/config.js @@ -684,7 +684,7 @@ function loadIdentity({ nodeId, name, create = true } = {}) { if (nodeIdForName(name) === nodeId) { throw new IdentityHaltError(name, idPath, `the name "${name}" indexes node ${nodeId}, but its identity.json is not there. If SYM_IDENTITY_DIR changed or its volume is not mounted, ` + - `restore that. If this agent is gone for good, remove ${nameIndexPath(name)} to let the name start again as a different agent.`); + `restore that. If this agent is gone for good, remove ${nameIndexPath(name)} and the link ${path.join(NODES_DIR, name)} to let the name start again as a different agent.`); } } if (!create) throw new IdentityAbsentError(`"${name}"`); diff --git a/tests/identity-migration-by-id.test.js b/tests/identity-migration-by-id.test.js index 7438d0cc..d7bcf364 100644 --- a/tests/identity-migration-by-id.test.js +++ b/tests/identity-migration-by-id.test.js @@ -322,6 +322,24 @@ describe('F4: a name that indexes a node whose identity is missing halts', () => assert.strictEqual(fs.readdirSync(path.join(NODES_DIR, 'by-id')).length, dirsBefore, 'nothing was minted'); }); + it('the halt names what to remove, and removing exactly that lets the name start again as a different agent', () => { + const name = uniq('f4reset'); + const first = cfg.loadIdentity({ name }); + fs.rmSync(path.join(cfg.nodeDirById(first.nodeId), 'identity.json')); + let halt = null; + try { cfg.loadIdentity({ name }); } catch (e) { halt = e; } + assert.ok(halt instanceof IdentityHaltError); + const index = path.join(NODES_DIR, 'by-name', `${encodeURIComponent(name)}.json`); + const link = path.join(NODES_DIR, name); + assert.ok(halt.reason.includes(index) && halt.reason.includes(link), 'the message names the index file and the link'); + fs.unlinkSync(index); + assert.throws(() => cfg.loadIdentity({ name }), IdentityHaltError, 'the index alone is not enough: the link re-indexes the name'); + assert.ok(exists(index), '(and it has: the index is back)'); + fs.unlinkSync(index); fs.unlinkSync(link); + const again = cfg.loadIdentity({ name }); + assert.notStrictEqual(again.nodeId, first.nodeId, 'a different agent, as the message says'); + }); + it('create: false is still IdentityAbsentError (EIDENTITYABSENT)', () => { const name = uniq('f4b'); const first = cfg.loadIdentity({ name }); From 6bd36ec426bd435a104a589626a6abc5f5950cd8 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 10:45:43 +0100 Subject: [PATCH 04/20] Identity migration: one migrator at a time (a stalled four-process run lost the key) The merge into an existing by-id directory was safe only if no other process was doing the same. It assumed each step was idempotent, so several processes could interleave them. They cannot: when one finishes it replaces nodes/ with the compat link, and another, still holding the old path, then sees that path lead to the destination, finds the "duplicate" file identical to itself and deletes it - the only copy of identity.json. Plain concurrent runs passed 320 starters. A fuzzer that stalls every file call for a few ms in four real processes lost the identity file in 2 of 300 rounds (everything else filed, identity.json gone). A Linux run under a throttled CPU (node 22, 0.3 cpu) failed the new race test twice in 40 with the same symptom: identity.json missing from by-id after every starter had finished. The design assumption was "a retry makes concurrent migration safe". It does not, and a guard on the alias only narrows the window. The migrator now holds a lock (nodes/.migrating/lock.pid, the identity lock) while it moves something; others wait, read the state again on their turn, and find it done. It is taken only when a 0.13 directory is there to move. The retry loop and its ENOENT tolerance are gone; the alias check stays as a last guard on a key file. With the lock the fuzzer ran 1,200 starters (300 rounds, husk) and 1,600 (400 rounds, none) with every starter loading the original agent and no errors. Also fixed in this commit: the F6 test passed for the wrong reason once the lock existed (its injected write failure hit the lock file's write); it now fails only the identity temp file. A starter that read the index just before another process wrote it, and then migrates nothing, re-reads the index instead of making a new identity; that line had no test and now has one. Tests: 41 (33 fail on 0.14.2); 17 mutants, each caught. --- CHANGELOG.md | 21 ++-- lib/config.js | 75 ++++++++++---- tests/identity-migration-by-id.test.js | 129 +++++++++++++++++++------ 3 files changed, 170 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ba428b..653d430d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,12 +47,17 @@ or expose a key and the small ones beside them. The wire and the public surface `identity.json` (or other content that cannot be merged) already filed under it. A directory that is empty, or holds a stale lock, claims nothing: the node is MERGED into it. The same identity on both sides is an interrupted move, and the next start finishes it. A real duplicate is still refused: nothing is moved and nothing is overwritten. -- **The name is indexed before anything moves, and the identity moves last.** A second starter that arrives in the - middle finds the agent; an interrupted move is re-entered on the next start by the identity's absence. A step that - fails because another process just did it is re-run (up to three times) against what that process left, and a path - that has become an alias of its destination through the other process's compat link is never "merged" onto itself. - Measured with real processes: four starters at once, 320 starters in all (with and without a by-id directory already - in place), every one loaded the original agent and the original key was intact every time. The same run on 0.14.2 +- **The name is indexed before anything moves, and the identity moves last.** A crash in the middle leaves the name + pointing at the agent and the identity still where the next start will find it, so an interrupted move is re-entered + by the identity's absence instead of being mistaken for "no such agent". +- **One process migrates at a time.** Moving a directory is many steps, and two processes interleaving them could each + delete what the other had just filed. Found by running four real processes at it with a stall injected into every + file call (about one round in a hundred lost the identity file), after plain concurrent runs had passed. The migrator + now holds a lock (`nodes/.migrating/lock.pid`, the identity lock: reclaimed when its holder is gone) while it moves + something; the others wait (up to 30 s, then fail loudly), read the state again and find it done. It is taken only + when a 0.13 directory is there to move. Under that lock: 1,200 starters in 300 rounds, then 1,600 in 400 rounds + without a husk, every one loaded the original agent, no errors, the key in place every round. A path that has + become an alias of its destination is never deleted as a "duplicate" of it. The same four-process run on 0.14.2 made a new identity in 14 of 100 starters, and destroyed the key in every round that had a by-id directory. - **A start never makes an identity where one exists.** Before it makes one it checks that no 0.13 `identity.json` sits under the name (in `nodes//` or `SYM_IDENTITY_DIR//`); if one does and could not be migrated it @@ -84,8 +89,8 @@ or expose a key and the small ones beside them. The wire and the public surface ### Tests -- `tests/identity-migration-by-id.test.js`: 37 tests (36 run on macOS; one real cross-filesystem test runs on Linux). - 31 of them fail on 0.14.2; five pin behaviour that did not change, and one runs only on Linux. The code is mutation-tested: seventeen +- `tests/identity-migration-by-id.test.js`: 41 tests (40 run on macOS; one real cross-filesystem test runs on Linux). + 33 of them fail on 0.14.2 (34 on Linux); seven pin behaviour that did not change. The code is mutation-tested: seventeen mutants of `lib/config.js` (each guard and each ordering removed in turn), each caught by at least one test. ## 0.14.2 — 2026-10-14 diff --git a/lib/config.js b/lib/config.js index d3d1beba..e311eaed 100644 --- a/lib/config.js +++ b/lib/config.js @@ -418,28 +418,27 @@ function sameFile(a, b) { * Move `src` onto `dst`, MERGING when `dst` already exists (check `mergeConflict` first): what is not * in `dst` moves in, what is already there stays. A directory that merely exists is not a reason to * leave a node where it was. `identity.json` moves LAST, so "the identity is in place" means "everything - * else is too" — an interrupted move is re-entered by the identity's absence. An entry already moved by - * another process (ENOENT) is skipped: the move is safe to run from several processes. + * else is too" — an interrupted move is re-entered by the identity's absence. + * + * One process at a time: a merge is many steps, and two processes interleaving them can each delete what the + * other has just filed (see `acquireMigrationLock`). Callers hold that lock. */ function mergeMove(src, dst) { const s = lstatOrNull(src); if (!s) return; const d = lstatOrNull(dst); - // Already the same file: the other process finished and replaced the old directory with the compat link, so - // this path now leads to `dst`. Moving it onto itself is a no-op at best; deleting it would delete `dst`. + // Never delete a path that leads to the copy it was verified against (through a symlink, say): that would + // delete the only copy. if (d && sameFile(src, dst)) return; - if (!d) { - try { moveTree(src, dst); } catch (e) { if (e.code === 'ENOENT' && !lstatOrNull(src)) return; throw e; } - return; - } + if (!d) { moveTree(src, dst); return; } if (s.isDirectory() && d.isDirectory()) { const names = fs.readdirSync(src).sort((a, b) => (a === 'identity.json') - (b === 'identity.json')); for (const n of names) mergeMove(path.join(src, n), path.join(dst, n)); - try { fs.rmdirSync(src); } catch (e) { if (e.code !== 'ENOENT') throw e; } + fs.rmdirSync(src); return; } // The same file on both sides (or a stale lock): the one in place stands. Checked again here, because - // this deletes: another process may have changed either side since mergeConflict ran. + // this deletes. if (path.basename(src) !== 'lock.pid' && !sameTree(src, dst)) throw new Error(`cannot merge ${src} onto ${dst}: they differ`); fs.rmSync(src, { recursive: true, force: true }); } @@ -533,9 +532,10 @@ function readIdentityFile(idPath, name) { * filed under it — not a directory that merely exists. `nodes/by-id//` is also made by a lock, or * by an earlier interrupted move, and an empty one used to leave the node where it was, unindexed, for * good. Now the node is MERGED into it. The same identity on both sides is an interrupted move, resumed. - * The name is indexed BEFORE anything moves, so a second process starting meanwhile finds the agent - * rather than concluding there is none; the identity moves last, so an interrupted move is re-entered - * by the identity's absence (`loadIdentity` does it). + * The name is indexed BEFORE anything moves, so a start that has no lock to wait on (a crash in the middle, + * a process that read the index too early) finds the agent rather than concluding there is none; the identity + * moves last, so an interrupted move is re-entered by the identity's absence (`loadIdentity` does it). Only one + * process migrates at a time (`acquireMigrationLock`): the others wait, then find it done. * * Key files end at 0600 (their directory at 0700): `rename` keeps the mode 0.13 wrote them with. * A move across filesystems (EXDEV) is copied, verified and only then removed. @@ -545,11 +545,48 @@ function readIdentityFile(idPath, name) { * @returns {'moved'|'indexed'|'skipped-live'|'skipped-duplicate'|'not-a-node'|'already'} */ function migrateNodeDir(name) { - // Safe from several processes: a step that fails because another process just did it (an entry gone, a - // directory already replaced by the compat link) is re-run against the state that process left, which - // reads as moved. What persists is thrown, the last one seen. - for (let attempt = 1; ; attempt++) { - try { return migrateNodeDirOnce(name); } catch (e) { if (attempt >= 3) throw e; } + // One migrator per name. Moving a directory is many steps; two processes doing it at once (a 0.14 start + // beside a CLI call, say) interleave them, and each can delete what the other just filed: found by running + // four real processes against it with a stall injected into every file call, which lost the key in about one + // round in a hundred. The lock is taken only when there is a 0.13 directory to move, and the state is read + // again under it: a process that waited finds the move done. + if (!legacyDirsRemain(name)) return migrateNodeDirOnce(name); + const release = acquireMigrationLock(name); + try { return migrateNodeDirOnce(name); } finally { release(); } +} + +/** True while a 0.13 directory for `name` is still in its old place (a symlink there is the move, done). */ +function legacyDirsRemain(name) { + const realDir = (p) => { const st = lstatOrNull(p); return Boolean(st && st.isDirectory()); }; + const idRoot = process.env.SYM_IDENTITY_DIR; + return realDir(path.join(NODES_DIR, name)) || Boolean(idRoot && realDir(path.join(idRoot, name))); +} + +/** How long a start waits for another process's migration of the same name before it gives up, loudly. */ +let migrationLockWaitMs = 30000; + +/** + * Take the exclusive right to migrate 0.13 directories, waiting for another process that holds it. It is the + * identity lock (`acquireIdentityLock`: a lock.pid carrying the holder's start time, reclaimed when its holder is + * gone) on a directory of its own beside the nodes, `nodes/.migrating/`. One lock for every name: a move is quick, + * it happens once per node, and a lock per name would need a directory made and removed around every move. + * @returns {() => void} release + */ +function acquireMigrationLock(name) { + const dir = path.join(NODES_DIR, '.migrating'); + const sleeper = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + migrationLockWaitMs; + for (;;) { + try { + return acquireIdentityLock(name, { dir }); + } catch (e) { + if (e.code !== 'EIDENTITYLOCK') throw e; + if (Date.now() >= deadline) { + throw new Error(`[SYM] another process (pid ${e.holderPid ?? 'unknown'}) has been moving 0.13 nodes for ${migrationLockWaitMs / 1000}s and has not finished ` + + `(it holds ${path.join(dir, 'lock.pid')}). Wait for it, or stop it and start again.`); + } + } + Atomics.wait(sleeper, 0, 0, 20); } } @@ -1284,6 +1321,8 @@ module.exports = { _startTimeWarned = false; }, _createStartTimeCache: createStartTimeCache, + /** Test hook: how long (ms) a start waits for another process's migration of the same name. */ + _setMigrationLockWait: (ms) => { migrationLockWaitMs = ms; }, /** Test hook (win32 only): the raw batched PowerShell lookup. */ _windowsStartTimes: windowsStartTimes, readLockFile, diff --git a/tests/identity-migration-by-id.test.js b/tests/identity-migration-by-id.test.js index d7bcf364..1c4be069 100644 --- a/tests/identity-migration-by-id.test.js +++ b/tests/identity-migration-by-id.test.js @@ -428,13 +428,18 @@ describe('F6: rewriting identity.json is atomic', () => { const f = path.join(NODES_DIR, name, 'identity.json'); const before = read(f); let injected = 0; - patchFs('writeSync', (orig) => function (fd, ...rest) { - injected++; - throw Object.assign(new Error('ENOSPC: no space left on device (injected)'), { code: 'ENOSPC' }); + const tempFds = new Set(); // the fds of identity.json's temp file: the lock file's writes are left alone + patchFs('openSync', (open) => function (p, ...rest) { + const fd = open.call(fs, p, ...rest); + if (/identity\.json\.\d+\.tmp$/.test(String(p))) tempFds.add(fd); + return fd; + }, () => patchFs('writeSync', (write) => function (fd, ...rest) { + if (tempFds.has(fd)) { injected++; throw Object.assign(new Error('ENOSPC: no space left on device (injected)'), { code: 'ENOSPC' }); } + return write.call(fs, fd, ...rest); }, () => { assert.throws(() => cfg.loadIdentity({ name }), /ENOSPC/); - }); - assert.ok(injected >= 1, 'the failure was reached'); + })); + assert.ok(injected >= 1, 'the failure was reached, in the write of the key file'); assert.strictEqual(read(f), before, 'the key file is byte-identical: it was never truncated'); assert.deepStrictEqual(fs.readdirSync(path.join(NODES_DIR, name)).filter((n) => n.endsWith('.tmp')), []); assert.strictEqual(cfg.loadIdentity({ name }).privateKey, id.privateKey, 'and the next start succeeds with the same key'); @@ -579,52 +584,118 @@ describe('F11: the window between the move and the index is not a mint race', () }); }); -describe('F11: another process finishing the move underneath this one', () => { - it('a step that fails because the other process just did it is re-run against what it left, and reads as moved', () => { - const name = uniq('f11-other'); +describe('F11: one migrator at a time', () => { + /** A second process that holds the migration lock for `ms`, then moves the node itself and lets go. */ + function holder(name, ms) { + return spawn(process.execPath, ['-e', ` + const c = require(${JSON.stringify(path.resolve(__dirname, '../lib/config.js'))}); + const release = c.acquireIdentityLock('migration', { dir: ${JSON.stringify(path.join(NODES_DIR, '.migrating'))} }); + console.log('held'); + setTimeout(() => { c.migrateNodeDir(${JSON.stringify(name)}); release(); console.log('released'); process.exit(0); }, ${ms});`], + { stdio: ['ignore', 'pipe', 'inherit'] }); + } + const held = (child) => new Promise((resolve, reject) => { child.stdout.once('data', resolve); child.once('exit', () => reject(new Error('the holder exited'))); }); + + it('a start waits for the process that is moving the node, then finds it moved', async () => { + const name = uniq('f11-wait'); const id = legacyNode(name); - const legacy = path.join(NODES_DIR, name); - const dest = cfg.nodeDirById(id.nodeId); - let helped = false; - const result = patchFs('renameSync', (orig) => function (a, b) { - if (!helped && a === legacy) { // the other process gets there first: it moves the directory and leaves the link - helped = true; - orig.call(fs, a, b); - fs.symlinkSync(path.join('by-id', id.nodeId), legacy, 'dir'); - throw Object.assign(new Error(`ENOENT: no such file or directory, rename '${a}' -> '${b}'`), { code: 'ENOENT' }); - } - return orig.call(fs, a, b); - }, () => cfg.migrateNodeDir(name)); - assert.ok(helped, 'the other process ran'); - assert.strictEqual(result, 'already'); - assert.strictEqual(JSON.parse(read(path.join(dest, 'identity.json'))).privateKey, id.privateKey); + husk(id.nodeId); // a merge, not a single rename + const child = holder(name, 400); + try { + await held(child); + const t0 = Date.now(); + const loaded = cfg.loadIdentity({ name }); // blocks until the holder lets go + const waited = Date.now() - t0; + assert.ok(waited >= 250, `it waited for the holder (${waited} ms)`); + assert.strictEqual(loaded.nodeId, id.nodeId); + assert.strictEqual(loaded.privateKey, id.privateKey); + assert.strictEqual(identitiesNamed(name).length, 1); + assert.ok(isLink(path.join(NODES_DIR, name)), 'the holder finished the move'); + assert.ok(!exists(path.join(NODES_DIR, '.migrating', 'lock.pid')), 'and the lock was let go'); + } finally { child.kill(); } + }); + + it('the lock of a process that is gone is reclaimed, not waited for', () => { + const name = uniq('f11-stale'); + const id = legacyNode(name); + put(path.join(NODES_DIR, '.migrating', 'lock.pid'), '2147483646\n', 0o644); // a pid that is not running + const t0 = Date.now(); + assert.strictEqual(cfg.migrateNodeDir(name), 'moved'); + assert.ok(Date.now() - t0 < 2000, 'no wait'); assert.strictEqual(cfg.loadIdentity({ name, create: false }).privateKey, id.privateKey); }); - it('a path that now leads through the other process\'s link to the destination is never "merged" onto itself (that would delete it)', () => { + it('a holder that never lets go makes the start fail loudly after the wait, and moves nothing', async () => { + const name = uniq('f11-timeout'); + const id = legacyNode(name); + const child = holder(name, 60000); + try { + await held(child); + cfg._setMigrationLockWait(300); + assert.throws(() => cfg.migrateNodeDir(name), /has been moving 0\.13 nodes/); + assert.throws(() => cfg.loadIdentity({ name }), /has been moving 0\.13 nodes/, 'and a start does not make a new identity meanwhile'); + assert.ok(exists(path.join(NODES_DIR, name, 'identity.json')), 'the node is where it was'); + assert.strictEqual(identitiesNamed(name).length, 0); + assert.ok(id.privateKey); + } finally { cfg._setMigrationLockWait(30000); child.kill(); } + }); + + it('nothing to move: no lock is taken and nothing is written', () => { + const name = uniq('f11-nolock'); + const id = cfg.loadIdentity({ name }); // a new agent: indexed, nodes/ is the link + const lockDir = path.join(NODES_DIR, '.migrating'); + fs.rmSync(lockDir, { recursive: true, force: true }); + assert.strictEqual(cfg.migrateNodeDir(name), 'already'); + assert.strictEqual(cfg.loadIdentity({ name, create: false }).nodeId, id.nodeId); + assert.ok(!exists(lockDir), 'the migration lock directory was not even made'); + }); + + it('a path that leads through a link to the destination is never "merged" onto itself (that would delete the only copy)', () => { const name = uniq('f11-alias'); const id = legacyNode(name); put(path.join(NODES_DIR, name, 'aaa.json'), '{"a":1}'); const legacy = path.join(NODES_DIR, name); const dest = husk(id.nodeId); // forces an entry-by-entry merge let helped = false; let listings = 0; - const result = patchFs('readdirSync', (orig) => function (p, ...rest) { + // Something not holding the lock finishes the move while this merge holds a list of what to move (it cannot + // happen among cooperating processes; the check is a last guard on a key file). + patchFs('readdirSync', (orig) => function (p, ...rest) { const names = orig.call(fs, p, ...rest); if (!helped && p === legacy && ++listings === 2) { // the 2nd listing is the merge's own, after the conflict check's - helped = true; // the other process finishes the whole move while this one holds the list + helped = true; for (const n of names) fs.renameSync(path.join(legacy, n), path.join(dest, n)); fs.rmdirSync(legacy); fs.symlinkSync(path.join('by-id', id.nodeId), legacy, 'dir'); } return names; - }, () => cfg.migrateNodeDir(name)); - assert.ok(helped, 'the other process ran'); - assert.strictEqual(result, 'already'); + }, () => { try { cfg.migrateNodeDir(name); } catch { /* the directory became a link under it: an error is fine, a deletion is not */ } }); + assert.ok(helped, 'the other actor ran'); for (const f of ['identity.json', 'aaa.json', path.join('cmbs', 'x.json')]) assert.ok(exists(path.join(dest, f)), `${f} survived`); assert.strictEqual(JSON.parse(read(path.join(dest, 'identity.json'))).privateKey, id.privateKey); }); }); +describe('F11: a starter that read the index too early', () => { + it('finds the agent by the index after migrating nothing, instead of making a new one', () => { + // The other process has moved everything and written the index, but not yet made the compat link: there is no + // 0.13 directory, so there is nothing to migrate, and this starter read the index (as missing) just before. + const name = uniq('f11-early'); + const id = legacyNode(name); + assert.strictEqual(cfg.migrateNodeDir(name), 'moved'); + fs.unlinkSync(path.join(NODES_DIR, name)); // the link not yet made + const index = path.join(NODES_DIR, 'by-name', `${encodeURIComponent(name)}.json`); + let fakedMissing = false; + const loaded = patchFs('readFileSync', (orig) => function (p, ...rest) { + if (!fakedMissing && String(p) === index) { fakedMissing = true; throw Object.assign(new Error('ENOENT (before the other process wrote it)'), { code: 'ENOENT' }); } + return orig.call(fs, p, ...rest); + }, () => cfg.loadIdentity({ name })); + assert.ok(fakedMissing, 'the early read happened'); + assert.strictEqual(loaded.nodeId, id.nodeId); + assert.strictEqual(loaded.privateKey, id.privateKey); + assert.strictEqual(identitiesNamed(name).length, 1); + }); +}); + describe('F11 with real processes: several starters at once never make a second identity', () => { /** Four processes call loadIdentity({ name }) at the same moment on a 0.13 node, in a state root of their own. */ async function race({ withHusk }) { From 4f1240786a2fedaa138d73325532b74f01e56a07 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 12:00:18 +0100 Subject: [PATCH 05/20] Test: the waiting start is judged by when it returned, not by how long it waited The test asserted the second start waited at least 250 ms of a 400 ms hold. Under a throttled cpu (node 20 in a container at 0.3 cpu) the holder's timer had run for 150-170 ms before the test process was told it held the lock, so 4 of 30 runs measured 225-243 ms and failed although the start had waited. It now has the holder report the time it let go, and asserts the start returned at or after that time (a start that moved the node alongside the holder returns well before it). Mutant "no migration lock" still fails it. --- tests/identity-migration-by-id.test.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/identity-migration-by-id.test.js b/tests/identity-migration-by-id.test.js index 1c4be069..ba577f51 100644 --- a/tests/identity-migration-by-id.test.js +++ b/tests/identity-migration-by-id.test.js @@ -591,22 +591,28 @@ describe('F11: one migrator at a time', () => { const c = require(${JSON.stringify(path.resolve(__dirname, '../lib/config.js'))}); const release = c.acquireIdentityLock('migration', { dir: ${JSON.stringify(path.join(NODES_DIR, '.migrating'))} }); console.log('held'); - setTimeout(() => { c.migrateNodeDir(${JSON.stringify(name)}); release(); console.log('released'); process.exit(0); }, ${ms});`], + setTimeout(() => { c.migrateNodeDir(${JSON.stringify(name)}); release(); console.log('released:' + Date.now()); process.exit(0); }, ${ms});`], { stdio: ['ignore', 'pipe', 'inherit'] }); } const held = (child) => new Promise((resolve, reject) => { child.stdout.once('data', resolve); child.once('exit', () => reject(new Error('the holder exited'))); }); + /** When the holder let go (its clock, ms), read after the fact. */ + const releasedAt = (child) => new Promise((resolve, reject) => { + let buf = ''; + child.stdout.on('data', (d) => { buf += d; const m = /released:(\d+)/.exec(buf); if (m) resolve(Number(m[1])); }); + child.once('exit', () => { const m = /released:(\d+)/.exec(buf); if (m) resolve(Number(m[1])); else reject(new Error('the holder never released')); }); + }); it('a start waits for the process that is moving the node, then finds it moved', async () => { const name = uniq('f11-wait'); const id = legacyNode(name); husk(id.nodeId); // a merge, not a single rename - const child = holder(name, 400); + const child = holder(name, 1000); try { await held(child); - const t0 = Date.now(); const loaded = cfg.loadIdentity({ name }); // blocks until the holder lets go - const waited = Date.now() - t0; - assert.ok(waited >= 250, `it waited for the holder (${waited} ms)`); + const returnedAt = Date.now(); + const released = await releasedAt(child); + assert.ok(returnedAt >= released, `it returned (${returnedAt}) only after the holder let go (${released}): it waited, it did not move the node alongside it`); assert.strictEqual(loaded.nodeId, id.nodeId); assert.strictEqual(loaded.privateKey, id.privateKey); assert.strictEqual(identitiesNamed(name).length, 1); From 051f44bccb4c1b14a3dbe83d3a2fad566e7f5c03 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 12:15:30 +0100 Subject: [PATCH 06/20] CHANGELOG 0.14.3: the fuzzer's numbers as measured --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 653d430d..390f25c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,11 +52,12 @@ or expose a key and the small ones beside them. The wire and the public surface by the identity's absence instead of being mistaken for "no such agent". - **One process migrates at a time.** Moving a directory is many steps, and two processes interleaving them could each delete what the other had just filed. Found by running four real processes at it with a stall injected into every - file call (about one round in a hundred lost the identity file), after plain concurrent runs had passed. The migrator + file call (about one round in 150 lost the identity file), after plain concurrent runs had passed. The migrator now holds a lock (`nodes/.migrating/lock.pid`, the identity lock: reclaimed when its holder is gone) while it moves something; the others wait (up to 30 s, then fail loudly), read the state again and find it done. It is taken only - when a 0.13 directory is there to move. Under that lock: 1,200 starters in 300 rounds, then 1,600 in 400 rounds - without a husk, every one loaded the original agent, no errors, the key in place every round. A path that has + when a 0.13 directory is there to move. Under that lock, in the same stall fuzzer: 1,200 starters in 300 rounds, 1,600 + in 400 rounds without a by-id directory in place, and 3,600 in 600 rounds with six starters at once: every one loaded + the original agent, no errors, the key in place every round. A path that has become an alias of its destination is never deleted as a "duplicate" of it. The same four-process run on 0.14.2 made a new identity in 14 of 100 starters, and destroyed the key in every round that had a by-id directory. - **A start never makes an identity where one exists.** Before it makes one it checks that no 0.13 `identity.json` From 632b95d19e8019b2f0374c0e9105580f2adf6a29 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 12:28:56 +0100 Subject: [PATCH 07/20] A start pinned to a nodeId migrates the 0.13 directory too loadIdentity ran the migration only for a start by name alone (`!nodeId && name`). mesh-channel starts every node as { name, nodeId: , create: false }, so a pinned node whose files were still the 0.13 nodes// could not start (EIDENTITYABSENT) until something started it by name; with the default create: true, 0.14.2 made a new identity and lost the key, and the first 0.14.3 draft halted. Reproduced in a sandbox state dir on fe285a3 and on the branch before this change (plain, empty by-id/, create true/false). When the pin's identity is not in place and a 0.13 directory under the name is still there, loadIdentity now runs the same locked, idempotent migrateNodeDir(name) and goes on to load the pinned agent. It is one path: the unpinned and pinned starts share the block; migrateNodeDir takes the pin as an optional second argument. It is not run when there is no 0.13 directory to move, so a pinned start writes nothing it did not before. Decision, a pin that is a DIFFERENT nodeId than the directory's: that directory is another agent's, so it is left where it is, unindexed (migrateNodeDir answers 'skipped-duplicate' before it moves or indexes anything). The pinned identity stays absent and the start fails as before: IdentityAbsentError with create: false, and with create: true the halt that names the stranded 0.13 identity and the pin. Nothing is lost and nothing is made. That agent moves when it is started by name or by its own pin. Tests (tests/identity-migration-by-id.test.js, 51 now): plain and empty/locked by-id/ with create false; create true loads the original and does not mint; SYM_IDENTITY_DIR; a pin that is another agent's; a link to another agent does not get indexed by a pinned start; the name indexed to another agent; a live 0.13 holder and the tombstone; four real processes with injected stalls, with and without a by-id dir already in place. Eight of them fail on 0.14.3 as pushed (051f44b) and on fe285a3; two guard against over-migrating. Four new mutants (no pinned migrate, no pin guard, no legacy-dir gate, adopting the index) are each caught. --- CHANGELOG.md | 30 +++++- lib/config.js | 40 +++++--- tests/identity-migration-by-id.test.js | 132 ++++++++++++++++++++++++- 3 files changed, 180 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 390f25c3..1f725f6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ or expose a key and the small ones beside them. The wire and the public surface - **A name whose identity file was missing made a replacement agent.** If the name index named a node and its `identity.json` was not there (the `SYM_IDENTITY_DIR` volume not mounted at boot, the variable set by one embedder and not another), a start by name made a new nodeId and key and re-pointed the name to it. +- **A start pinned by nodeId never migrated.** `loadIdentity` ran the migration only for a start by name alone. mesh-channel + starts every node as `{ name, nodeId, create: false }`, so a node it pinned to the nodeId in its `node.json` and whose + files were still the 0.13 `nodes//` could not start at all (`EIDENTITYABSENT`) until something started it by name + once; with `create: true` (the default) 0.14.2 made a new identity and lost the key. 0.14.3 loads the agent. - Smaller, same family: a stray symlink at `nodes/` was reported as already indexed when nothing was indexed; switching `SYM_IDENTITY_DIR` on for a 0.13 node whose identity was in its node directory reported "not a node" and went on to make a new identity (the old key survived, orphaned in the new node's directory); an identity with a public key @@ -40,6 +44,9 @@ or expose a key and the small ones beside them. The wire and the public surface - Anyone who switched `SYM_IDENTITY_DIR` on for an existing 0.13 node: the old key is probably still on disk, in `nodes/by-id//identity.json` (the node that was made in its place). - Every node migrated from 0.13 holds a private key readable by other local users until it is next loaded with 0.14.3. +- A node started by its pinned nodeId (mesh-channel's start, `create: false`) on the first start after upgrading from 0.13: + no key was lost, but it could not start until something started it by name. A pinned start with `create: true` could lose it + as above. ### Changed @@ -47,6 +54,14 @@ or expose a key and the small ones beside them. The wire and the public surface `identity.json` (or other content that cannot be merged) already filed under it. A directory that is empty, or holds a stale lock, claims nothing: the node is MERGED into it. The same identity on both sides is an interrupted move, and the next start finishes it. A real duplicate is still refused: nothing is moved and nothing is overwritten. +- **A start pinned to a nodeId migrates too.** `loadIdentity({ nodeId, name })` whose identity is not in place and for + which a 0.13 directory under `name` is still there runs the same locked, idempotent migration as a start by name, then + loads the pinned agent. It is one code path, not a copy, and it is not run when there is no 0.13 directory to move. + **A pin that is a different nodeId than the directory's does not migrate it:** the directory belongs to another agent, + so it is left exactly where it is, unindexed (nothing moved, nothing lost), the pinned identity stays absent, and the + start fails as it would have: `IdentityAbsentError` with `create: false`, and with `create: true` the halt that says a + 0.13 identity sits under the name (naming the pin). That agent moves when it is started by name or by its own pin. A + 0.13 process holding the pinned agent's directory still lets it load in place (tombstone checked), as for a start by name. - **The name is indexed before anything moves, and the identity moves last.** A crash in the middle leaves the name pointing at the agent and the identity still where the next start will find it, so an interrupted move is re-entered by the identity's absence instead of being mistaken for "no such agent". @@ -85,14 +100,19 @@ or expose a key and the small ones beside them. The wire and the public surface should report them is open. - `loadIdentity({ nodeId, name })` still re-points a name that already indexes a different node. - `SYM_IDENTITY_DIR` switched on for an existing node does not move its key for you (it halts and says how). -- `loadIdentity({ nodeId, name, create: true })` with a `nodeId` the name index does not hold and no identity file still - makes a new identity under a new nodeId (it does not use the one given). +- `loadIdentity({ nodeId, name, create: true })` with a `nodeId` the name index does not hold, no identity file, and no 0.13 + directory under the name still makes a new identity under a new nodeId (it does not use the one given). mesh-channel + passes `create: false`, so a pin is never ignored there. +- A pinned start leaves another agent's 0.13 directory (same name, different nodeId) unmigrated, as above; with + `SYM_IDENTITY_DIR` set and that identity in the node directory it halts with the instructions given for that case. ### Tests -- `tests/identity-migration-by-id.test.js`: 41 tests (40 run on macOS; one real cross-filesystem test runs on Linux). - 33 of them fail on 0.14.2 (34 on Linux); seven pin behaviour that did not change. The code is mutation-tested: seventeen - mutants of `lib/config.js` (each guard and each ordering removed in turn), each caught by at least one test. +- `tests/identity-migration-by-id.test.js`: 51 tests (50 run on macOS; one real cross-filesystem test runs on Linux). + 41 of them fail on 0.14.2 (42 on Linux); nine pin behaviour that did not change (two of them keep a pinned start from + migrating what is not its own). Of the 51, ten are for pinned starts, among them four real processes starting one 0.13 + agent at once under injected stalls, with and without a by-id directory already in place. The code is mutation-tested: + twenty-one mutants of `lib/config.js` (each guard and each ordering removed in turn), each caught by at least one test. ## 0.14.2 — 2026-10-14 diff --git a/lib/config.js b/lib/config.js index e311eaed..161cd102 100644 --- a/lib/config.js +++ b/lib/config.js @@ -542,17 +542,24 @@ function readIdentityFile(idPath, name) { * * A 0.13 identity in `nodes//` while SYM_IDENTITY_DIR is set (the volume switched on after the * agent was made) HALTS: it is neither where the identity dir looks nor safe to leave for a mint. + * + * `onlyNodeId`: a start pinned to a nodeId (`loadIdentity({ nodeId, name })`) migrates the directory under + * `name` only if its identity IS that nodeId. Another agent's directory is left unmoved and unindexed + * ('skipped-duplicate'): the pin asked for one agent, and moving or indexing a different one is not its + * business. + * @param {string} name + * @param {string} [onlyNodeId] * @returns {'moved'|'indexed'|'skipped-live'|'skipped-duplicate'|'not-a-node'|'already'} */ -function migrateNodeDir(name) { +function migrateNodeDir(name, onlyNodeId) { // One migrator per name. Moving a directory is many steps; two processes doing it at once (a 0.14 start // beside a CLI call, say) interleave them, and each can delete what the other just filed: found by running // four real processes against it with a stall injected into every file call, which lost the key in about one // round in a hundred. The lock is taken only when there is a 0.13 directory to move, and the state is read // again under it: a process that waited finds the move done. - if (!legacyDirsRemain(name)) return migrateNodeDirOnce(name); + if (!legacyDirsRemain(name)) return migrateNodeDirOnce(name, onlyNodeId); const release = acquireMigrationLock(name); - try { return migrateNodeDirOnce(name); } finally { release(); } + try { return migrateNodeDirOnce(name, onlyNodeId); } finally { release(); } } /** True while a 0.13 directory for `name` is still in its old place (a symlink there is the move, done). */ @@ -590,7 +597,7 @@ function acquireMigrationLock(name) { } } -function migrateNodeDirOnce(name) { +function migrateNodeDirOnce(name, onlyNodeId) { const legacy = path.join(NODES_DIR, name); const idRoot = process.env.SYM_IDENTITY_DIR; const legacyIdDir = idRoot ? path.join(idRoot, name) : legacy; @@ -618,6 +625,9 @@ function migrateNodeDirOnce(name) { const identity = readIdentityFile(idFile, name); // halts on an identity it cannot establish const nodeId = identity.nodeId; if (!isNodeIdText(nodeId)) throw new IdentityHaltError(name, idFile, `the nodeId ${JSON.stringify(String(nodeId).slice(0, 40))} cannot name a directory under nodes/by-id`); + // A start pinned to one nodeId moves that agent and no other: the identity under this name is another + // agent's, and stays exactly where it is (nothing is indexed or moved, nothing lost). + if (onlyNodeId && nodeId !== onlyNodeId) return 'skipped-duplicate'; if (st) { const lock = readLockFile(path.join(legacy, 'lock.pid')); if (lock && lock.pid !== process.pid && lockIsHeldByLiveProcess(lock)) return 'skipped-live'; @@ -687,14 +697,19 @@ function loadIdentity({ nodeId, name, create = true } = {}) { if (!nodeId && !name) throw new Error('loadIdentity needs a nodeId or a name'); if (nodeId && !isNodeIdText(nodeId)) throw new Error(`not a nodeId: ${String(nodeId).slice(0, 40)}`); let migrated = null; - if (!nodeId && name) { - nodeId = nodeIdForName(name); - // Not indexed, or indexed but the identity is not in place: the 0.13 layout may still hold it (a move - // another process is making, or one that was interrupted). Migrating is idempotent. - if (!nodeId || !fs.existsSync(path.join(identityDirById(nodeId), 'identity.json'))) { - migrated = migrateNodeDir(name); + if (name) { + const pinned = nodeId; + if (!pinned) nodeId = nodeIdForName(name); + // The identity is not in place: the 0.13 layout may still hold it. Unpinned, that is when the name is not + // indexed, or is but the identity is missing (a move another process is making, or one that was + // interrupted). Pinned (mesh-channel starts every node as `{ name, nodeId, create: false }`), when a 0.13 + // directory for the name is still there. Migrating is idempotent, and one path serves both. + const inPlace = Boolean(nodeId) && fs.existsSync(path.join(identityDirById(nodeId), 'identity.json')); + if (!inPlace && (!pinned || legacyDirsRemain(name))) { + migrated = migrateNodeDir(name, pinned); if (migrated === 'skipped-live') { - // A 0.13 process of this name holds its old directory: load it in place, unmoved. + // A 0.13 process of this name holds its old directory: load it in place, unmoved. (Pinned: it is the + // pinned agent's, or the migration would have said 'skipped-duplicate'.) const live = readIdentityFile(legacyIdentityPath(name), name); const liveTomb = readTombstone(live.nodeId); if (liveTomb) throw new IdentityTombstonedError(live.nodeId, liveTomb); @@ -732,7 +747,8 @@ function loadIdentity({ nodeId, name, create = true } = {}) { const stranded = legacyIdentityPath(name); if (stranded) { throw new IdentityHaltError(name, stranded, - `a 0.13 identity exists under this name and was not migrated (migration said: ${migrated || 'nothing'}). ` + + `a 0.13 identity exists under this name and was not migrated (migration said: ${migrated || 'nothing'}` + + `${nodeId && migrated === 'skipped-duplicate' ? `; this start is pinned to ${nodeId}, and that identity is another agent's` : ''}). ` + 'Making a new identity here would orphan or overwrite its key.'); } const kp = generateSigningKeyPair(); diff --git a/tests/identity-migration-by-id.test.js b/tests/identity-migration-by-id.test.js index ba577f51..16212c6c 100644 --- a/tests/identity-migration-by-id.test.js +++ b/tests/identity-migration-by-id.test.js @@ -704,7 +704,7 @@ describe('F11: a starter that read the index too early', () => { describe('F11 with real processes: several starters at once never make a second identity', () => { /** Four processes call loadIdentity({ name }) at the same moment on a 0.13 node, in a state root of their own. */ - async function race({ withHusk }) { + async function race({ withHusk, pinned = false, stall = false }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-race-')); try { const state = path.join(root, 'state'); @@ -715,10 +715,20 @@ describe('F11 with real processes: several starters at once never make a second for (let i = 0; i < 30; i++) fs.writeFileSync(path.join(nodes, name, 'cmbs', `c${i}.json`), '{}'); fs.writeFileSync(path.join(nodes, name, 'identity.json'), JSON.stringify(id, null, 2)); if (withHusk) { fs.mkdirSync(path.join(nodes, 'by-id', id.nodeId), { recursive: true }); fs.writeFileSync(path.join(nodes, 'by-id', id.nodeId, 'lock.pid'), '999999\n'); } + // `stall`: every file call may pause a few ms first, which spreads the four processes' steps through each other. + const stalls = stall ? ` + const sab = new Int32Array(new SharedArrayBuffer(4)); + for (const k of ['renameSync', 'lstatSync', 'statSync', 'readdirSync', 'mkdirSync', 'existsSync', 'readFileSync', 'writeFileSync', 'rmdirSync', 'rmSync', + 'symlinkSync', 'unlinkSync', 'realpathSync', 'readlinkSync', 'chmodSync', 'openSync', 'copyFileSync']) { + const orig = fs[k]; fs[k] = function (...a) { if (Math.random() < 0.35) Atomics.wait(sab, 0, 0, Math.random() * 4); return orig.apply(fs, a); }; + }` : ''; + // `pinned`: the start mesh-channel makes, { name, nodeId, create: false }. + const options = pinned ? `{ name: ${JSON.stringify(name)}, nodeId: ${JSON.stringify(id.nodeId)}, create: false }` : `{ name: ${JSON.stringify(name)} }`; const code = ` + const fs = require('fs');${stalls} const c = require(${JSON.stringify(path.resolve(__dirname, '../lib/config.js'))}); process.stdin.once('data', () => { - try { const i = c.loadIdentity({ name: ${JSON.stringify(name)} }); console.log(JSON.stringify({ id: i.nodeId, key: i.privateKey })); } + try { const i = c.loadIdentity(${options}); console.log(JSON.stringify({ id: i.nodeId, key: i.privateKey })); } catch (e) { console.log(JSON.stringify({ error: e.name + ': ' + String(e.message).split('\\n')[0].slice(0, 200) })); } process.exit(0); }); @@ -733,11 +743,11 @@ describe('F11 with real processes: several starters at once never make a second } finally { fs.rmSync(root, { recursive: true, force: true }); } } - for (const withHusk of [false, true]) { - it(`${withHusk ? 'with a by-id dir that already exists' : 'plain'}: every starter that loads gets the original agent, and the key survives`, async () => { + for (const [withHusk, pinned, stall] of [[false, false, false], [true, false, false], [false, true, true], [true, true, true]]) { + it(`${pinned ? 'pinned starts (nodeId + name, create: false), stalled' : 'starts by name'}, ${withHusk ? 'with a by-id dir that already exists' : 'plain'}: every starter that loads gets the original agent, and the key survives`, async () => { let loaded = 0; for (let round = 0; round < 5; round++) { - const { id, outcomes, kept } = await race({ withHusk }); + const { id, outcomes, kept } = await race({ withHusk, pinned, stall }); assert.strictEqual(kept.privateKey, id.privateKey, 'the original private key is still the one on disk'); for (const o of outcomes) { if (o.error) continue; // a starter may be told to start again; it must not become somebody else @@ -770,6 +780,118 @@ describe('F13: the live-lock fallback checks the tombstone', () => { }); }); +describe('a start pinned to a nodeId migrates too (mesh-channel starts every node as { name, nodeId, create: false })', () => { + it('create: false, a plain 0.13 dir: the pinned agent loads, moved, with its key files private', () => { + const name = uniq('pin-plain'); + const id = legacyNode(name, { e2e: true }); + const loaded = cfg.loadIdentity({ name, nodeId: id.nodeId, create: false }); + assert.strictEqual(loaded.nodeId, id.nodeId); + assert.strictEqual(loaded.privateKey, id.privateKey); + const dir = cfg.nodeDirById(id.nodeId); + assert.ok(exists(path.join(dir, 'cmbs', 'x.json')), 'the store moved with it'); + assert.ok(isLink(path.join(NODES_DIR, name)), 'nodes/ is the compat link'); + assert.strictEqual(cfg.nodeIdForName(name), id.nodeId); + if (posix) { + assert.strictEqual(modeOf(path.join(dir, 'identity.json')), 0o600); + assert.strictEqual(modeOf(path.join(dir, 'e2e-keypair.json')), 0o600); + assert.strictEqual(modeOf(dir), 0o700); + } + assert.strictEqual(cfg.loadIdentity({ name, nodeId: id.nodeId, create: false }).privateKey, id.privateKey, 'and the second start is the same'); + assert.strictEqual(identitiesNamed(name).length, 1); + }); + + it('create: false, a by-id/ dir that already exists (empty, or holding only a lock): the pinned agent loads', () => { + for (const lock of [false, true]) { + const name = uniq(`pin-husk${lock ? '-lock' : ''}`); + const id = legacyNode(name); + husk(id.nodeId, { lock }); + const loaded = cfg.loadIdentity({ name, nodeId: id.nodeId, create: false }); + assert.strictEqual(loaded.privateKey, id.privateKey); + assert.strictEqual(identitiesNamed(name).length, 1); + } + }); + + it('the default create: true loads the original and does not mint (0.14.2 made a new nodeId and lost the key)', () => { + for (const withHusk of [false, true]) { + const name = uniq(`pin-create${withHusk ? '-husk' : ''}`); + const id = legacyNode(name); + if (withHusk) husk(id.nodeId); + const loaded = cfg.loadIdentity({ name, nodeId: id.nodeId }); + assert.strictEqual(loaded.nodeId, id.nodeId); + assert.strictEqual(loaded.privateKey, id.privateKey); + assert.strictEqual(identitiesNamed(name).length, 1, 'no second identity answers to the name'); + } + }); + + it('with SYM_IDENTITY_DIR: the identity under SYM_IDENTITY_DIR/ moves and loads for the pin', () => { + withIdentityDir((idRoot) => { + const name = uniq('pin-idroot'); + const id = makeIdentity(name); + put(path.join(idRoot, name, 'identity.json'), JSON.stringify(id, null, 2), 0o644); + put(path.join(NODES_DIR, name, 'cmbs', 'x.json'), '{}'); + const loaded = cfg.loadIdentity({ name, nodeId: id.nodeId, create: false }); + assert.strictEqual(loaded.privateKey, id.privateKey); + assert.ok(exists(path.join(idRoot, 'by-id', id.nodeId, 'identity.json'))); + if (posix) assert.strictEqual(modeOf(path.join(idRoot, 'by-id', id.nodeId, 'identity.json')), 0o600); + }); + }); + + it('a pin that is a DIFFERENT nodeId than the 0.13 directory\'s: the pin stays absent, the directory is left exactly as it is, nothing is made', () => { + const name = uniq('pin-other'); + const other = legacyNode(name); // the agent whose directory this is + const pin = cfg.uuidv7(); // the start asks for another + const identityBefore = read(path.join(NODES_DIR, name, 'identity.json')); + const byIdBefore = fs.readdirSync(path.join(NODES_DIR, 'by-id')).length; + assert.throws(() => cfg.loadIdentity({ name, nodeId: pin, create: false }), (e) => e.code === 'EIDENTITYABSENT'); + assert.throws(() => cfg.loadIdentity({ name, nodeId: pin }), (e) => e instanceof IdentityHaltError && /pinned to/.test(e.reason), 'create: true halts; it does not mint under the name'); + assert.strictEqual(read(path.join(NODES_DIR, name, 'identity.json')), identityBefore, 'the other agent\'s key is byte-unchanged'); + assert.ok(!isLink(path.join(NODES_DIR, name)) && exists(path.join(NODES_DIR, name, 'cmbs', 'x.json')), 'and its directory is where it was, unmoved'); + assert.strictEqual(cfg.nodeIdForName(name), null, 'the name was not indexed to either'); + assert.ok(!exists(cfg.nodeDirById(other.nodeId)) && !exists(cfg.nodeDirById(pin)), 'no directory was made for either'); + assert.strictEqual(fs.readdirSync(path.join(NODES_DIR, 'by-id')).length, byIdBefore); + assert.strictEqual(identitiesNamed(name).length, 0); + // the unpinned start still moves it, as before + assert.strictEqual(cfg.loadIdentity({ name }).nodeId, other.nodeId); + }); + + it('nothing 0.13 to move (only a link to another agent): a pinned start does not index the name from it', () => { + const name = uniq('pin-link'); + const owner = cfg.loadIdentity({ name: uniq('pin-link-owner') }); // some other agent, filed by id + fs.symlinkSync(path.join('by-id', owner.nodeId), path.join(NODES_DIR, name), 'dir'); // a link under this name, name unindexed + assert.throws(() => cfg.loadIdentity({ name, nodeId: cfg.uuidv7(), create: false }), (e) => e.code === 'EIDENTITYABSENT'); + assert.strictEqual(cfg.nodeIdForName(name), null, 'the pinned start wrote no index entry for the name'); + }); + + it('the name is indexed to another agent and the pin\'s 0.13 directory sits under it: the pin stays absent, and the start does not answer with the indexed agent', () => { + const name = uniq('pin-held'); + const indexed = cfg.loadIdentity({ name }); // N -> indexed + fs.unlinkSync(path.join(NODES_DIR, name)); // the link out of the way: a real directory for the pin + const pin = legacyNode(name); + const before = read(path.join(NODES_DIR, name, 'identity.json')); + assert.throws(() => cfg.loadIdentity({ name, nodeId: pin.nodeId, create: false }), (e) => e.code === 'EIDENTITYABSENT'); + assert.strictEqual(read(path.join(NODES_DIR, name, 'identity.json')), before, 'the pin\'s key is where it was'); + assert.strictEqual(cfg.nodeIdForName(name), indexed.nodeId, 'and the index still says what it said'); + }); + + it('a 0.13 process holds the pinned agent\'s directory: it loads in place, and a tombstone still refuses it', async () => { + const name = uniq('pin-live'); + const id = legacyNode(name); + const dir = path.join(NODES_DIR, name); + const child = spawn(process.execPath, ['-e', ` + const c = require(${JSON.stringify(path.resolve(__dirname, '../lib/config.js'))}); + c.acquireIdentityLock(${JSON.stringify(name)}, { dir: ${JSON.stringify(dir)} }); + console.log('held'); setInterval(() => {}, 1000);`], { stdio: ['ignore', 'pipe', 'inherit'] }); + try { + await new Promise((resolve, reject) => { child.stdout.once('data', resolve); child.once('exit', () => reject(new Error('lock holder exited'))); }); + assert.strictEqual(cfg.loadIdentity({ name, nodeId: id.nodeId, create: false }).privateKey, id.privateKey); + assert.ok(exists(path.join(dir, 'cmbs', 'x.json')) && !isLink(dir), 'unmoved: its process is running there'); + assert.throws(() => cfg.loadIdentity({ name, nodeId: cfg.uuidv7(), create: false }), (e) => e.code === 'EIDENTITYABSENT', 'a different pin does not get it'); + put(path.join(cfg.nodeDirById(id.nodeId), cfg.TOMBSTONE), JSON.stringify({ movedAt: Date.now() })); + assert.throws(() => cfg.loadIdentity({ name, nodeId: id.nodeId, create: false }), (e) => e instanceof IdentityTombstonedError); + } finally { child.kill(); } + }); +}); + describe('T9: the checks measure something', () => { it('assertMoved fails a run that moved nothing, and a populated run passes it', () => { const name = uniq('t9'); From 85ae65cdfb6019f4f8517244c33946bd662e8ebc Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 15:42:04 +0100 Subject: [PATCH 08/20] R2-1: only an unscoped anchor/admin-chain grant binds a key; a grant view never refuses a proving session and is never promoted to proven A delegating grant of any scope (an issuer seating one world) could bind any unbound nodeId in the mesh to its holder's key: the victim's own session was then refused as an identity conflict, and once the squatter connected the binding was stored as proven and outlived the revoke. - AuthorityStore.grantKey takes only grants that are unscoped and whose whole chain is admin grants rooted in the anchor (bindsKey). - The settle offers only those grants to the key registry. - The grant view binds only a nodeId nothing else binds: a session, pin or legacy claim naming another key binds on its own and the grant's disagreement is recorded; a session proving the key the view names stores nothing, so a revoke removes what the grant bound. - A live session's proven key outranks the view in _identityKey. --- lib/authority-store.js | 24 ++++++++++- lib/node-authority.js | 6 ++- lib/node.js | 46 +++++++++++++++------- lib/roster-keys.js | 41 +++++++++++++++++-- tests/core-secure-binding-lifetime.test.js | 6 ++- tests/roster-keys.test.js | 25 +++++++++++- 6 files changed, 125 insertions(+), 23 deletions(-) diff --git a/lib/authority-store.js b/lib/authority-store.js index b602da55..fe811efd 100644 --- a/lib/authority-store.js +++ b/lib/authority-store.js @@ -373,19 +373,41 @@ class AuthorityStore { /** * The key the in-force grants name for `nodeId` (§6.6.9: a binding source, a view over what is in * force now): undefined when none does, or when two different keys do. + * + * ONLY A MESH-WIDE GRANT ROOTED IN THE ANCHOR THROUGH ADMINS BINDS A KEY (security review R2-1, + * 0.14.3; the §6.6.9 narrowing): the grant is unscoped, and every grant in its chain is an `admin` + * grant, so its root is the anchor. A scoped grant (an `issuer` seating one world, a validator of + * one namespace) confers its role inside its scope and binds nothing: a key binding is mesh-wide, + * and the holder of a scoped delegating role must not be able to bind any unbound nodeId in the + * mesh to a key of its choosing. */ grantKey(nodeId) { const { inForce } = this.view(); let found; for (const id of inForce) { const e = this._held.get(id); - if (!e || e.kind !== 'grant' || e.subject.nodeId !== nodeId) continue; + if (!e || e.kind !== 'grant' || e.subject.nodeId !== nodeId || !this.bindsKey(e)) continue; if (found !== undefined && found !== e.subject.key) return undefined; found = e.subject.key; } return found; } + /** + * Whether a held grant (an entry, or its id) is a key-binding source (§6.6.9 as narrowed, R2-1): + * unscoped, and authorised by the anchor directly or through a chain of unscoped `admin` grants. + */ + bindsKey(entryOrId) { + const e = typeof entryOrId === 'string' ? this._held.get(entryOrId) : entryOrId; + if (!e || e.kind !== 'grant' || e.scope !== null) return false; + for (const id of e.chain) { + const a = this._held.get(id); + if (!a || a.kind !== 'grant' || a.role !== 'admin' || a.scope !== null) return false; + } + const root = e.chain.length ? this._held.get(e.chain[e.chain.length - 1]) : e; + return !!root && root.authBy === 'anchor'; + } + /** The in-force grants naming `nodeId` with a key other than `key` (a foreign key for this node). */ foreignGrantsFor(nodeId, key) { const out = []; diff --git a/lib/node-authority.js b/lib/node-authority.js index df09a0fa..9edd34cc 100644 --- a/lib/node-authority.js +++ b/lib/node-authority.js @@ -537,8 +537,10 @@ const methods = { this._log(`[sym-security] an in-force grant (${id.slice(0, 17)}) names this node's nodeId with a key that is not its own: it confers nothing here; someone vouched a foreign key for this node's identity`); this.emit('metric', { type: 'authority-foreign-self-key', id, key: s.subject.key }); } - } else if (typeof this._roster.bind === 'function') { - // A conflicting binding is recorded by the registry, and the binding stays as it was. + } else if (typeof this._roster.bind === 'function' && this._authority.bindsKey(id)) { + // A conflicting binding is recorded by the registry, and the binding stays as it was. Only a + // grant that is a key-binding source is offered (unscoped, anchor/admin chain: R2-1); a scoped + // grant binds nothing, so it can neither bind a key nor fill the conflict record. this._roster.bind(s.subject.nodeId, s.subject.key, 'grant'); } } diff --git a/lib/node.js b/lib/node.js index 9d230572..046690b5 100644 --- a/lib/node.js +++ b/lib/node.js @@ -908,22 +908,27 @@ class SymNode extends EventEmitter { * @private */ _identityKey(nodeId) { - const k = this._roster.get(nodeId); - if (k !== undefined) return k; + // A durable binding (the anchor, this node's own key, a stored binding) decides; a legacy claim + // verifies only while a key-binding grant vouches the very key it expects (the registry's get). + if (this._roster.durable(nodeId) !== undefined) return this._roster.get(nodeId); // A nodeId with no durable binding verifies under the key its live Core Secure session proved, - // for as long as that session lasts (the session-scoped binding). A legacy claim is not a - // binding, so this never applies to an id the registry expects another key for. - if (this._roster.expected(nodeId) !== undefined) return undefined; + // for as long as that session lasts (the session-scoped binding). It outranks the grant view: + // a grant binds only a nodeId nothing else binds (§6.6.9 as narrowed, R2-1). + const live = this._liveSessionKey(nodeId); + return live !== undefined ? live : this._roster.get(nodeId); // else the grant view, or undefined + } + + /** @private The key a live confirmed Core Secure session proved for `nodeId`, or undefined. */ + _liveSessionKey(nodeId) { const held = this._sessionKeys && this._sessionKeys.get(nodeId); return held && [...held.sessions].some((s) => s.confirmed && !s.closed && !s.legacy) ? held.key : undefined; } /** Where the key that verifies `nodeId` comes from: a registry source, 'session', or undefined. */ _keySource(nodeId) { - const src = this._roster.source(nodeId); - if (src !== undefined && src !== 'legacy-claim') return src; - if (src === undefined && this._identityKey(nodeId) !== undefined) return 'session'; - return src; + if (this._roster.durable(nodeId) !== undefined) return this._roster.source(nodeId); + if (this._liveSessionKey(nodeId) !== undefined) return 'session'; + return this._roster.source(nodeId); } /** @@ -934,9 +939,13 @@ class SymNode extends EventEmitter { */ _earnBinding(nodeId, key) { if (!nodeId || !key) return; - const k = this._roster.get(nodeId); - if (k === key) { this._roster.noteVerified(nodeId); return; } - if (k !== undefined || this._roster.expected(nodeId) !== undefined) return; + if (this._roster.durable(nodeId) !== undefined) { + if (this._roster.get(nodeId) === key) this._roster.noteVerified(nodeId); + return; + } + // A key the grant view vouches is never stored (R2-1): the binding ends with the grant. A session + // that proved another key earns its own binding; the registry records the grant's disagreement. + if (this._roster.viewKey(nodeId) === key) return; const held = this._sessionKeys.get(nodeId); if (!held || held.key !== key || ![...held.sessions].some((s) => s.confirmed && !s.closed)) return; const b = this._roster.bind(nodeId, key, 'proven'); @@ -3839,7 +3848,16 @@ class SymNode extends EventEmitter { */ _onSessionConfirmed(session) { this._sessionStats.confirmed++; - const expected = this._roster.expected(session.nodeId); + // A durable binding refuses a different key; the grant view never does (§6.6.9 as narrowed, + // R2-1): a grant binds only a nodeId nothing else binds, so a session proving another key is + // admitted on its own proof and the grant's disagreement is recorded for the operator. (A grant + // view that refused it let any holder of a delegating grant lock a nodeId out mesh-wide.) + const viewKey = this._roster.viewKey(session.nodeId); + if (viewKey !== undefined && viewKey !== session.identityKey && this._roster.durable(session.nodeId) === undefined) { + this._roster.noteConflict(session.nodeId, viewKey, 'grant', session.identityKey, 'proven'); + this.emit('metric', { type: 'grant-key-conflict', nodeId: session.nodeId }); + } + const expected = this._roster.durable(session.nodeId); if (expected && expected !== session.identityKey) { this._roster.bind(session.nodeId, session.identityKey, 'proven'); // records the conflict this._log(`Refused ${session.name} (${session.nodeId.slice(0, 8)}): it proved a key that is not the one bound to its nodeId (${this._roster.source(session.nodeId)}) — a key conflict for the operator (sym keys resolve)`); @@ -3905,7 +3923,7 @@ class SymNode extends EventEmitter { // and a second key for that nodeId is a conflict. The durable registry takes only EARNED // bindings — a pin, a grant in effect, or an admitted verified record (_earnBinding) — and the // key it already expects for nodeId (a legacy claim becomes `proven`; a pin stays pinned). - const expected = this._roster.expected(nodeId); + const expected = this._roster.durable(nodeId); // never the grant view (R2-1) if (expected !== undefined && expected !== session.identityKey) { this._refuseIdentityConflict(session); return; } if (expected === session.identityKey) { const b = this._roster.bind(nodeId, session.identityKey, 'proven'); diff --git a/lib/roster-keys.js b/lib/roster-keys.js index 5f037b21..b2833a29 100644 --- a/lib/roster-keys.js +++ b/lib/roster-keys.js @@ -23,6 +23,12 @@ * the grant or its chain ends it, and grants never fill the registry. A grant * naming the key a stored binding holds marks it vouched; one naming another key * is a conflict. A grant never binds this node's own nodeId (`opts.self`). + * NARROWED (security review R2-1, 0.14.3): only an unscoped grant whose chain is the + * anchor and admins is a binding source (the authority store's `bindsKey`), and the + * view binds only a nodeId nothing else binds: a session, a pin or a legacy claim + * naming another key binds on its own and the grant's disagreement is recorded; a + * session proving the key the view names stores nothing (it is never promoted to + * `proven`), so a revoke removes everything the grant bound. * legacy-claim — every pre-0.14 `handshake` entry, relabelled on first load. It was learned from * an unproven hello, so it verifies nothing; it is the EXPECTED key: a proven * session presenting it binds `proven`, and one presenting a different key is a @@ -31,6 +37,10 @@ * The conflict matrix: * * nodeId unbound, any source → bind at that source + * only the grant view binds it, another + * source names a DIFFERENT key → bind at that source; the grant's disagreement is recorded + * only the grant view binds it, a + * session proves the SAME key → nothing stored: the binding ends with the grant * nodeId bound, same key, any source → keep; record the stronger source * nodeId bound, DIFFERENT key, any → conflict: refused, recorded (roster-conflicts.jsonl), * shown in `sym status`; the operator resolves it @@ -185,6 +195,21 @@ class RosterKeyRegistry { return this._viewKey(nodeId) !== undefined ? 'grant' : undefined; } + /** + * The key bound to `nodeId` by anything but the grant view — the anchor, this node's own key, or a + * stored binding at any source, a legacy claim included — or undefined. The grant view binds only a + * nodeId this leaves unbound (§6.6.9 as narrowed, R2-1). + */ + durable(nodeId) { + if (this._anchors.has(nodeId)) return this._anchors.get(nodeId); + if (this._self && nodeId === this._self.nodeId) return this._self.publicKey; + const b = this._byNode.get(nodeId); + return b ? b.key : undefined; + } + + /** The key the in-force key-binding grants name for `nodeId` now (the view), or undefined. */ + viewKey(nodeId) { return this._viewKey(nodeId); } + /** @private The key the grants in effect now vouch for `nodeId` (never this node's own). */ _viewKey(nodeId) { if (!this._grantView || (this._self && nodeId === this._self.nodeId)) return undefined; @@ -292,12 +317,22 @@ class RosterKeyRegistry { return { bound: false, reason: 'conflict', had: b.key }; } // Nothing stored: the grants in effect now may still vouch a key (the view). A grant is never - // stored; any other source naming a different key than the view is a conflict. + // stored, and it binds only a nodeId nothing else binds (§6.6.9 as narrowed, security review + // R2-1): a session's proof, a pin or a legacy claim naming another key is a binding of its own. + // It is made, and the disagreement is recorded as a conflict for the operator; the grant stays + // in force and confers nothing on the bound key here. A grant view is never turned into a stored + // binding either: a source naming the very key the view vouches stores nothing, so the binding + // ends with the grant (a revoke removes what the grant bound). const viewKey = this._viewKey(nodeId); if (source === 'grant') return viewKey === key ? { bound: true, source: 'grant' } : { bound: false, reason: viewKey ? 'conflict' : 'not-in-effect', ...(viewKey ? { had: viewKey } : {}) }; + if (viewKey !== undefined && viewKey === key && source === 'proven') { + this.armFloor(nodeId); + return { bound: true, source: 'grant' }; + } + let conflict = null; if (viewKey !== undefined && viewKey !== key) { this._recordConflict(nodeId, viewKey, 'grant', key, source, at); - return { bound: false, reason: 'conflict', had: viewKey }; + conflict = viewKey; } // A first proof arms the floor even when the registry is full and keeps no binding. if (source === 'proven') this.armFloor(nodeId); @@ -312,7 +347,7 @@ class RosterKeyRegistry { this._byNode.set(nodeId, nb); if (!this._loading) this._track(nodeId, nb); this._persist({ nodeId, key, source, seen: now }); - return { bound: true, source, created: true }; + return conflict ? { bound: true, source, created: true, conflict: 'grant', had: conflict } : { bound: true, source, created: true }; } /** diff --git a/tests/core-secure-binding-lifetime.test.js b/tests/core-secure-binding-lifetime.test.js index c869e74b..7e6c963a 100644 --- a/tests/core-secure-binding-lifetime.test.js +++ b/tests/core-secure-binding-lifetime.test.js @@ -49,12 +49,14 @@ describe('binding lifetime (D3)', () => { it('pinned, grant-vouched (a view), grant, legacy-claim and live bindings never expire; one seen recently does not', () => { let t = 0; const live = new Set(['live']); - const view = new Map([['grant', key(2)], ['vouched', key(4)]]); // the grants in effect now + const view = new Map([['grant', key(2)]]); // the grants in effect now const r = new RosterKeyRegistry({ now: () => t, isLive: (id) => live.has(id), grantView: (id) => view.get(id) }); r.bind('pinned', key(1), 'pinned'); assert.strictEqual(r.bind('grant', key(2), 'grant').bound, true, 'a grant in effect binds as a view'); r.bind('claim', key(3), 'legacy-claim'); - r.bind('vouched', key(4), 'proven'); r.bind('vouched', key(4), 'grant'); + // A proven binding a grant comes to vouch later (a session proving the key a grant already names + // stores nothing: R2-1, never promoted to proven). + r.bind('vouched', key(4), 'proven'); view.set('vouched', key(4)); r.bind('vouched', key(4), 'grant'); r.bind('live', key(5), 'proven'); r.bind('seen', key(6), 'proven'); r.bind('record', key(7), 'proven'); diff --git a/tests/roster-keys.test.js b/tests/roster-keys.test.js index c9335bdb..727c6a9e 100644 --- a/tests/roster-keys.test.js +++ b/tests/roster-keys.test.js @@ -69,7 +69,30 @@ describe('the conflict matrix, row by row', () => { assert.strictEqual(r.conflicts().length, 0); }); - for (const [first, second] of [['grant', 'proven'], ['proven', 'grant'], ['pinned', 'proven'], ['proven', 'pinned'], ['proven', 'proven'], ['grant', 'pinned']]) { + for (const second of ['proven', 'pinned']) { + it(`only a grant view binds the nodeId, a DIFFERENT key from ${second} → bound at ${second}, the grant's disagreement recorded (R2-1)`, () => { + const k1 = key(); const k2 = key(); + const r = new RosterKeyRegistry({ grantView: (id) => (id === 'N' ? k1 : undefined) }); + assert.strictEqual(r.bind('N', k1, 'grant').source, 'grant'); + const res = r.bind('N', k2, second); + assert.strictEqual(res.bound, true, 'a grant binds only a nodeId nothing else binds'); + assert.strictEqual(res.conflict, 'grant'); + assert.strictEqual(r.get('N'), k2); + assert.deepStrictEqual(r.conflicts().map((c) => [c.nodeId, c.had, c.got, c.hadSource, c.gotSource]), [['N', k1, k2, 'grant', second]]); + }); + } + + it('only a grant view binds the nodeId, a session proves the SAME key → nothing stored: the binding ends with the grant (R2-1)', () => { + const k = key(); let inEffect = true; + const r = new RosterKeyRegistry({ grantView: (id) => (id === 'N' && inEffect ? k : undefined) }); + assert.deepStrictEqual(r.bind('N', k, 'proven'), { bound: true, source: 'grant' }); + assert.strictEqual(r.size(), 0, 'never promoted to proven'); + inEffect = false; + assert.strictEqual(r.get('N'), undefined); + assert.strictEqual(r.bind('N', key(), 'proven').created, true, 'after the revoke the nodeId is unbound'); + }); + + for (const [first, second] of [['proven', 'grant'], ['pinned', 'proven'], ['proven', 'pinned'], ['proven', 'proven']]) { it(`nodeId bound (${first}), DIFFERENT key from ${second} → conflict: refused and recorded`, () => { const k1 = key(); const k2 = key(); // A grant binding is a view over grants in effect (security review C): the first is one. From 84559b2b75c92cbcb5168b9a1a149cdba296d4c9 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 15:42:12 +0100 Subject: [PATCH 09/20] R2-2: a record whose signed bytes the AEAD did not authenticate is dropped and counted, never charged as a forgery The cmb-encrypted AAD binds only key, assertionId, createdByNodeId, room and to; the signature, createdBy, createdTimestamp, lineage and the application descriptor travel in clear. A relay or on-path writer could edit one and get an honest peer closed and banned 60 s as a forger. - The AAD-bound assertionId is recomputed and checked before the signature (the 8.8.5 order): a mismatch is clear metadata altered, dropped and counted (cmb-clear-metadata-altered). - A bad record signature is never a penalty: the signature itself is outside the AEAD. A record in the session's own name is dropped and counted (cmb-signature-unattributable); one relayed in another's name keeps the relayed path. The forgery penalty stays for attestations, checkpoints and witnesses, which travel wholly sealed. - tests: security-0.14.3 (R2-1, R2-2); core-secure-rereview N1 encoded the penalty for an own-name record and now expects drop-and-count. --- lib/frame-handler.js | 37 +++++-- tests/core-secure-rereview.test.js | 10 +- tests/security-0.14.3.test.js | 154 +++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 13 deletions(-) create mode 100644 tests/security-0.14.3.test.js diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 7d0cfb4c..6d49a353 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -723,20 +723,37 @@ class FrameHandler { return refuse('unresolvable-author', `no proven, pinned or in-force-grant key for ${String(md.createdByNodeId).slice(0, 8)}`); } const rec = msg.cmb; + // §8.8.5 ORDER: the assertion identity before the signature (security review R2-2). The + // `cmb-encrypted` AAD binds the assertionId (with key, author nodeId, room and to), and the + // assertionId is the hash of the whole signing preimage, so a record whose recomputed id matches + // the AAD-bound one has every signed byte authenticated by the AEAD. One that does not was altered + // in its clear metadata (createdBy, createdTimestamp, lineage, the application descriptor travel + // in clear), by the peer or by anyone on the path: dropped and counted, never charged. A record + // that declares the v2.0 suite but is malformed for it has no preimage: assertionIdV2_0 throws. + // That is a mismatch to refuse, never an exception. + let derivedId = null; + try { derivedId = assertionIdV2_0(rec); } catch { /* not a well-formed v2.0 record */ } + if (!derivedId || rec.metadata.assertionId !== derivedId) { + this._node.emit('metric', { type: 'cmb-clear-metadata-altered', from: peerName, key: k }); + return refuse('assertion-id-mismatch'); + } const v = verifyCMB(rec, authorKey); if (!v.valid) { - // A bad signature is attributable only when the record names the session's own peer as its - // author (0.14.0 re-review N1): the node then closes the session. A relayed record that fails - // under this node's binding for its author is dropped and counted, never charged to the relayer, - // whose binding for that author may differ from this node's. - if (v.error === 'bad-signature' && typeof this._node._penaliseForgery === 'function') this._node._penaliseForgery(session, 'record', { author: md.createdByNodeId, key: authorKey, peer: peerId }); + // NEVER A PENALTY (security review R2-2). The signature itself travels in the clear metadata of + // `cmb-encrypted`, outside the AEAD, so a relay or an on-path writer can break it on an honest + // peer's genuine record: a bad record signature proves nothing about who sent it. The record is + // dropped and counted (the session's verification budget already bounds what junk costs); the + // forgery penalty stays for statements whose signed bytes travel wholly sealed (attestations, + // checkpoints, witnesses in `control-encrypted`). A record relayed in another's name takes the + // relayed path (dropped, counted, and the pair muted past a few: _penaliseForgery charges no + // relayer); one in the session's own name is only dropped and counted. + if (session && md.createdByNodeId !== session.nodeId && v.error === 'bad-signature' && typeof this._node._penaliseForgery === 'function') { + this._node._penaliseForgery(session, 'record', { author: md.createdByNodeId, key: authorKey, peer: peerId }); + } else { + this._node.emit('metric', { type: 'cmb-signature-unattributable', from: peerName, peer: peerId, author: String(md.createdByNodeId).slice(0, 128), key: k }); + } return refuse('invalid', v.error || 'invalid'); } - // A record that declares the v2.0 suite but is malformed for it has no preimage: assertionIdV2_0 - // throws. That is a mismatch to refuse, never an exception. - let derivedId = null; - try { derivedId = assertionIdV2_0(rec); } catch { /* not a well-formed v2.0 record */ } - if (!derivedId || rec.metadata.assertionId !== derivedId) return refuse('assertion-id-mismatch'); msg._cmbVerified = true; msg._verifiedAuthorNodeId = rec.metadata.createdByNodeId; msg._authorKey = authorKey; diff --git a/tests/core-secure-rereview.test.js b/tests/core-secure-rereview.test.js index 6a4a6ea7..208973d5 100644 --- a/tests/core-secure-rereview.test.js +++ b/tests/core-secure-rereview.test.js @@ -68,10 +68,14 @@ describe('N1: the forgery penalty is charged only to a session that signed in it assert.strictEqual(b._store.allEntries().length, 0, 'the record is not stored'); // The squatter's session delivering a record in its own name (X) that its proven key did not - // sign is attributable: it is closed and refused. + // sign: a record's signature travels in the clear metadata of cmb-encrypted, outside the AEAD, + // so a bad record signature is never attributable (security review R2-2, 0.14.3): the record is + // dropped and counted, and the session is neither closed nor refused. deliver(b, sM, { type: 'cmb', cmb: signedRecord(X, { categories: { focus: 'X said this too' }, room: b._room }) }); - assert.strictEqual(sM.closed, true, 'the session that sent a record in its own name its key did not sign is closed'); - assert.strictEqual(b._penalised(X.nodeId), true); + assert.strictEqual(sM.closed, false, 'a bad record signature is not charged to the session (R2-2)'); + assert.strictEqual(b._penalised(X.nodeId), false); + assert.ok(metrics.some((m) => m.type === 'cmb-signature-unattributable' && m.peer === X.nodeId)); + assert.strictEqual(b._store.allEntries().length, 0, 'and the record is not stored'); } finally { await stopAll(b); } }); diff --git a/tests/security-0.14.3.test.js b/tests/security-0.14.3.test.js new file mode 100644 index 00000000..bd5dc9d7 --- /dev/null +++ b/tests/security-0.14.3.test.js @@ -0,0 +1,154 @@ +'use strict'; + +process.env.SYM_SEMANTIC_ENCODER = process.env.SYM_SEMANTIC_ENCODER || 'off'; +require('./_isolate-home'); // redirect $HOME before lib/config loads + +/** + * The 0.14.3 security review (R2): each finding fixed here as a regression test that fails on the + * 0.14.3 base (632b95d) for its own reason. + * + * R2-1 a delegating grant, even a scoped one, bound any unbound nodeId to its holder's key + * R2-2 a relay editing a sealed record's clear metadata got an honest peer banned as a forger + * R2-3 one admitted peer's large records cost CPU, memory and disk without bound + * R2-6 actionable room records were de-duplicated by cognition key, not assertion identity + * R2-9 durable key bindings earned by one record never aged out, and nothing capped a source + * R2-22 the delivery ledger was never pruned + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDirById } = require('../lib/config'); +const A = require('../lib/core/authority'); +const { identity, connectNodes, admitAs, deliver, signedRecord, applicationFor, until } = require('./_core-secure'); + +const uniq = (b) => `${b}-${process.pid}-${Math.random().toString(36).slice(2, 7)}`; +const made = []; +function mk(base, extra = {}) { + const n = new SymNode({ name: uniq(base), silent: true, discovery: new NullDiscovery(), room: 'sec', ...extra }); + made.push(n); + return n; +} +async function stopAll() { + for (const n of made.splice(0)) { + try { await n.stop(); } catch { /* */ } + try { fs.rmSync(nodeDirById(n.nodeId), { recursive: true, force: true }); } catch { /* */ } + } +} +const sign = (byId, by, fields) => A.signStatement({ ...fields, authorisedBy: byId, nonce: A.freshNonce(), sigs: [] }, by.privateKey, by.publicKey); + +describe('R2-1: a grant binds a key only when it is unscoped and its chain is the anchor and admins', () => { + const anchor = identity('anchor'); + const PIN = { threshold: 1, keys: [{ key: anchor.publicKey }] }; + + it('R2-1: a scoped issuer seating one world binds no key: the victim nodeId stays unbound, a record forged in its name is not verified, and the victim connects', async () => { + try { + const R = mk('r21-recv', { anchor: PIN }); + const V = mk('r21-victim', { anchor: PIN }); + const I = identity('issuer'); + const gI = sign('anchor', anchor, { kind: 'grant', subject: { nodeId: I.nodeId, key: I.publicKey }, role: 'issuer', scope: 'xmesh-world:w1' }); + const gSquat = sign(A.statementId(gI), I, { kind: 'grant', subject: { nodeId: V.nodeId, key: I.publicKey }, role: 'participant', scope: 'xmesh-world:w1' }); + assert.strictEqual(R.submitAuthority(gI).status, 'in-force'); + assert.strictEqual(R.submitAuthority(gSquat).status, 'in-force', 'the scoped grant is in force (it confers its role in its scope)'); + assert.strictEqual(R._identityKey(V.nodeId), undefined, 'but binds no key for the victim nodeId'); + assert.strictEqual(R._keySource(V.nodeId), undefined); + const sess = admitAs(R, { nodeId: I.nodeId, name: 'issuer', publicKey: I.publicKey }); + let accepted = null; + R.on('verified-record', (e) => { accepted = e; }); + deliver(R, sess, { type: 'cmb', timestamp: Date.now(), cmb: signedRecord({ nodeId: V.nodeId, name: 'victim', privateKey: I.privateKey }, { categories: { focus: 'forged in the victim name' }, room: 'sec' }) }); + await until(() => accepted, 300); + assert.strictEqual(accepted, null, 'a record in the victim\'s name signed by the issuer key is not verified'); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.ok(R._peers.has(V.nodeId), 'the victim\'s own session is admitted'); + } finally { await stopAll(); } + }); + + it('R2-1: an unscoped grant under an issuer (not an admin) binds no key either; one under the anchor or an admin does', async () => { + try { + const R = mk('r21-chain', { anchor: PIN }); + const I = identity('issuer'); const Ad = identity('admin'); + const x = identity('x'); const y = identity('y'); const z = identity('z'); + const gI = sign('anchor', anchor, { kind: 'grant', subject: { nodeId: I.nodeId, key: I.publicKey }, role: 'issuer' }); + const gAd = sign('anchor', anchor, { kind: 'grant', subject: { nodeId: Ad.nodeId, key: Ad.publicKey }, role: 'admin' }); + for (const s of [gI, gAd, + sign(A.statementId(gI), I, { kind: 'grant', subject: { nodeId: x.nodeId, key: x.publicKey }, role: 'participant' }), + sign(A.statementId(gAd), Ad, { kind: 'grant', subject: { nodeId: y.nodeId, key: y.publicKey }, role: 'participant' }), + sign('anchor', anchor, { kind: 'grant', subject: { nodeId: z.nodeId, key: z.publicKey }, role: 'validator' })]) { + assert.strictEqual(R.submitAuthority(s).status, 'in-force'); + } + assert.strictEqual(R._identityKey(x.nodeId), undefined, 'issuer-chained: no binding'); + assert.strictEqual(R._identityKey(y.nodeId), y.publicKey, 'admin-chained, unscoped: a binding (a view)'); + assert.strictEqual(R._identityKey(z.nodeId), z.publicKey, 'anchor-issued, unscoped: a binding'); + assert.strictEqual(R._identityKey(I.nodeId), I.publicKey); + } finally { await stopAll(); } + }); + + it('R2-1: a grant view never refuses a proving session: the session is admitted and the conflict recorded', async () => { + try { + const R = mk('r21-norefuse', { anchor: PIN }); + const V = mk('r21-victim2', { anchor: PIN }); + const evil = identity('evil'); + const g = sign('anchor', anchor, { kind: 'grant', subject: { nodeId: V.nodeId, key: evil.publicKey }, role: 'participant' }); + assert.strictEqual(R.submitAuthority(g).status, 'in-force'); + assert.strictEqual(R._identityKey(V.nodeId), evil.publicKey, 'the scene: the view names another key'); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.ok(R._peers.has(V.nodeId), 'admitted on its own proof'); + assert.strictEqual(R._identityKey(V.nodeId), V._identity.publicKey, 'its proven key verifies, not the view\'s'); + assert.ok(R._roster.conflicts().some((c) => c.nodeId === V.nodeId && c.hadSource === 'grant' && c.got === V._identity.publicKey), 'the disagreement is recorded'); + } finally { await stopAll(); } + }); + + it('R2-1: a session proving the key a grant names is never promoted to proven; a revoke removes what the grant bound', async () => { + try { + const R = mk('r21-sticky', { anchor: PIN }); + const V = mk('r21-victim3', { anchor: PIN }); + const S = mk('r21-squatter', { anchor: PIN }); + S.nodeId = V.nodeId; if (S._identity) S._identity.nodeId = V.nodeId; + const Ad = identity('admin'); + const gAd = sign('anchor', anchor, { kind: 'grant', subject: { nodeId: Ad.nodeId, key: Ad.publicKey }, role: 'admin' }); + const gSquat = sign(A.statementId(gAd), Ad, { kind: 'grant', subject: { nodeId: V.nodeId, key: S._identity.publicKey }, role: 'participant' }); + R.submitAuthority(gAd); R.submitAuthority(gSquat); + assert.strictEqual(R._roster.source(V.nodeId), 'grant'); + await connectNodes(S, R, { timeoutMs: 3000 }); + assert.notStrictEqual(R._roster.source(V.nodeId), 'proven', 'never promoted to proven'); + assert.strictEqual(R._roster.durable(V.nodeId), undefined, 'nothing stored'); + assert.strictEqual(R.submitAuthority(sign('anchor', anchor, { kind: 'revoke', targets: [A.statementId(gAd)] })).status, 'in-force'); + for (const s of [...R._sessions]) s.close('test'); + await new Promise((r) => setTimeout(r, 100)); + assert.strictEqual(R._roster.source(V.nodeId), undefined, 'the revoke removed what the grant bound'); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.ok(R._peers.has(V.nodeId) && R._peers.get(V.nodeId).identityKey === V._identity.publicKey, 'the real owner of the nodeId connects'); + } finally { await stopAll(); } + }); +}); + +describe('R2-2: a record whose signed bytes the AEAD did not authenticate is dropped and counted, never charged', () => { + const CATS = (focus) => ({ focus, issue: 'x', intent: 'y', motivation: 'z', commitment: 'none', perspective: 'p', mood: { text: 'calm', valence: 0, arousal: 0 } }); + for (const [what, edit] of [ + ['createdBy', (m) => { m.createdBy += 'X'; }], + ['createdTimestamp', (m) => { m.createdTimestamp += 1; }], + ['the signature', (m) => { m.sig = (m.sig[0] === 'A' ? 'B' : 'A') + m.sig.slice(1); }], + ]) { + it(`R2-2: a relay editing ${what} in a sealed record's clear metadata cannot get the honest sender closed or refused`, async () => { + try { + const a = mk('r22-a'); const b = mk('r22-b'); + const metrics = []; b.on('metric', (m) => metrics.push(m)); + await a.start(); await b.start(); + let tampered = 0; + await connectNodes(a, b, { kind: 'relay', tap: (f, dir) => { + if (dir === 'a→b' && f.type === 'cmb-encrypted' && f.metadata && f.metadata.createdBy) { edit(f.metadata); tampered++; } + } }); + a.remember(CATS(`an honest observation from a (${what})`)); + await until(() => metrics.some((m) => m.type === 'cmb-signature-rejected'), 3000); + assert.ok(tampered >= 1, 'the scene: the relay altered the frame'); + assert.ok(metrics.some((m) => m.type === 'cmb-signature-rejected'), 'the altered record is refused'); + assert.ok(!metrics.some((m) => m.type === 'forged-signature'), 'and nobody is called a forger'); + assert.ok(b._peers.has(a.nodeId), 'the honest peer keeps its session'); + assert.strictEqual(b._penalised(a.nodeId), false, 'and is not refused admission'); + if (what !== 'the signature') assert.ok(metrics.some((m) => m.type === 'cmb-clear-metadata-altered'), 'the assertion identity is checked first (§8.8.5)'); + } finally { await stopAll(); } + }); + } +}); From e5b6d3135be565f16bacad2ff0aa9f90d2c19c49 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:04:54 +0100 Subject: [PATCH 10/20] R2-3, R2-6, R2-22: size-charged budgets, bounded encoder/context/cache, actionable de-dup by assertion, pruned ledger R2-3: one admitted peer could saturate CPU and grow memory and disk without bound with large, distinct, validly signed records (24 records of 2 x 200 KB: 15 s CPU, 1.6 s loop block). - The verification and record lanes are also charged by size (record texts and application data): 1 MiB/s burst 4 MiB before verification, 128 KiB/s burst 1.5 MiB before SVAF. Small records count as before. - The SVAF kernel reads at most 16 KiB of one text. - The local-state context reads at most 2 KiB of an entry, 16 KiB in all. - The re-encode after an admit runs at most once a second (leading run, one trailing run). - The store's entry cache is an LRU: 2,048 entries, 32 M characters. - A record relayed in another's name whose assertionId does not match counts toward the pair's failure window (the mute), as a bad signature does. R2-6 (8.8.2): an actionable room record (a signed application section) is de-duplicated for delivery by its assertion identity, and one that memory de-duplicates (redundant, or its cognition key held) is still delivered, remixed:false. An actionable room record signed longer ago than the ledger keeps its mark (30 days) is refused as stale. R2-22: delivery-ledger marks carry an expiry (directed: the replay window past the signed time; others 30 days), expired marks drop at load, the file is rewritten when it doubles, and past 200,000 marks the author holding the most loses its oldest. --- lib/core/context-encoder.js | 17 ++- lib/frame-handler.js | 251 ++++++++++++++++++++++++++++++---- lib/memory-store.js | 74 +++++++++- lib/node.js | 21 ++- tests/security-0.14.3.test.js | 151 ++++++++++++++++++++ 5 files changed, 478 insertions(+), 36 deletions(-) diff --git a/lib/core/context-encoder.js b/lib/core/context-encoder.js index 069e5e2d..b74b767d 100644 --- a/lib/core/context-encoder.js +++ b/lib/core/context-encoder.js @@ -242,9 +242,20 @@ function svafKernel() { if (_semanticReady && !_off) { const pipe = _semanticEncoder; const dim = _semanticDim; - return { id: `minilm-l6-v2-h${dim}`, encode: (text) => encodeWith(pipe, dim, text) }; + return { id: `minilm-l6-v2-h${dim}`, encode: (text) => encodeWith(pipe, dim, svafText(text)) }; } - return { id: `ngram-h${DIM}`, encode: async (text) => encodeNgram(text) }; + return { id: `ngram-h${DIM}`, encode: async (text) => encodeNgram(svafText(text)) }; +} + +/** + * The most of one text the SVAF kernel reads (security review R2-3, 0.14.3): a category may hold + * 256 KiB, and encoding all of it, for every category of every record a peer sends, let one peer buy + * CPU without bound. The semantic model reads its first few hundred tokens anyway; the n-gram kernel + * now reads the first SVAF_TEXT_MAX characters. A text no longer than this encodes as before. + */ +const SVAF_TEXT_MAX = 16 * 1024; +function svafText(text) { + return typeof text === 'string' && text.length > SVAF_TEXT_MAX ? text.slice(0, SVAF_TEXT_MAX) : text; } /** @@ -258,4 +269,4 @@ function kernelId() { return svafKernel().id; } -module.exports = { encode, encodeForSVAF, isSemanticReady, semanticSettled, kernelId, svafKernel, DIM, loadSemanticEncoder, configureSemanticEncoder, semanticMode }; +module.exports = { encode, encodeForSVAF, isSemanticReady, semanticSettled, kernelId, svafKernel, DIM, loadSemanticEncoder, configureSemanticEncoder, semanticMode, SVAF_TEXT_MAX }; diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 6d49a353..3fee8140 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -94,6 +94,25 @@ const DIRECTED_MARK = /#(asrt-|sd:|sig:)/; const { PEER_INFO_MAX } = require('./core/wake'); /** A record refused for its audience is said at most once per this many ms per peer and reason. */ +/** Refill a session's token bucket `slot` (a burst, then a rate a second) and return it. */ +function refill(session, slot, perSecond, burst, now) { + const b = session[slot] || (session[slot] = { tokens: burst, at: now }); + b.tokens = Math.min(burst, b.tokens + ((now - b.at) * perSecond) / 1000); + b.at = now; + return b; +} +/** Take one record from a count bucket and `chars` from a size bucket, both or neither. */ +function takeBoth(session, countSlot, countRate, countBurst, sizeSlot, sizeRate, sizeBurst, chars) { + const now = Date.now(); + const c = refill(session, countSlot, countRate, countBurst, now); + const s = refill(session, sizeSlot, sizeRate, sizeBurst, now); + const need = Math.max(0, Math.min(Number(chars) || 0, sizeBurst)); + if (c.tokens < 1 || s.tokens < need) return false; + c.tokens -= 1; + s.tokens -= need; + return true; +} + /** Freeze an object graph (plain data: a structured clone of a record). */ function deepFreeze(o) { if (o && typeof o === 'object' && !Object.isFrozen(o)) { @@ -118,6 +137,35 @@ const RECORDS_BURST = 32; */ const VERIFY_PER_SECOND = 32; const VERIFY_BURST = 128; +/** + * BOTH LANES ARE ALSO CHARGED BY SIZE (security review R2-3, 0.14.3): a record costs the verifier and + * the SVAF encoder in proportion to its text, and a record may carry 720 KiB, so counting records let + * one peer send 32 of the largest a second and buy CPU, memory and disk without bound. A record spends + * its size in characters (category texts and application data; recordChars) from a second bucket on + * each lane, and passes only if both buckets hold enough. Each burst holds at least two records of the + * largest size, so any one record can pass. The usual peer sends records of a few KiB: far inside both. + */ +const VERIFY_CHARS_PER_SECOND = 1024 * 1024; +const VERIFY_CHARS_BURST = 4 * 1024 * 1024; +const RECORD_CHARS_PER_SECOND = 128 * 1024; +const RECORD_CHARS_BURST = 1536 * 1024; +/** What a record weighs for the size budgets: its texts and application data, in characters. */ +function recordChars(cmb) { + let n = 512; + const cats = cmb && typeof cmb === 'object' ? cmb.categories : null; + if (cats && typeof cats === 'object') { + for (const v of Object.values(cats)) { + if (typeof v === 'string') n += v.length; + else if (v && typeof v === 'object' && typeof v.text === 'string') n += v.text.length; + } + } + const md = cmb && typeof cmb === 'object' ? cmb.metadata : null; + if (md && typeof md === 'object') { + if (md.application && typeof md.application === 'object' && typeof md.application.data === 'string') n += md.application.data.length; + if (typeof md.createdBy === 'string') n += md.createdBy.length; + } + return n; +} /** cmb-fetch serving bounds per session (security review D, fetch-amp). */ const FETCH_SERVE_PER_SECOND = 2; const FETCH_SERVE_BURST = 8; @@ -130,6 +178,13 @@ const DIRECTED_WINDOW_MS = 24 * 60 * 60 * 1000; const HOOK_SEEN_MAX = 100_000; const AUDIENCE_REPORT_MS = 60_000; +/** The local state is re-encoded after an admit at most this often (R2-3). */ +const REENCODE_MIN_MS = 1000; +/** The delivery ledger (R2-22): how long a mark protects, how many are kept, and file slack. */ +const LEDGER_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const LEDGER_SKEW_MS = 60 * 60 * 1000; +const LEDGER_MAX = 200_000; +const LEDGER_COMPACT_SLACK = 10_000; /** * Processes inbound peer frames for a SymNode. @@ -214,22 +269,109 @@ class FrameHandler { // The TTL cache above only converges a replay storm; it was throttled to disk every 5 s and // capped, so a hot-swap or a restart soon after a read, or an old enough record, surfaced again // as a new delivery. The ledger is append-only and written before the delivery is announced. + // + // PRUNED (security review R2-22, 0.14.3): it grew by one line per delivery for ever, in memory and + // on disk. Each mark now carries when it stops protecting anything: a directed mark the end of its + // record's replay window (a directed record signed longer ago than DIRECTED_WINDOW_MS is refused + // anyway), any other LEDGER_TTL_MS after its delivery. Expired marks are dropped at load and when + // the ledger is full, and the file is rewritten once it holds twice the marks kept. Past + // LEDGER_MAX unexpired marks, the author holding the most loses its oldest, so one author's flood + // never pushes out another's marks. this._ledgerPath = null; - this._ledger = new Set(); + this._ledger = new Map(); // mark -> expiresAt (ms) + this._ledgerByAuthor = new Map(); // author -> Set of its marks, oldest first + this._ledgerLines = 0; // lines the file holds + this._ledgerEvicted = 0; this._loadLedger(); } - /** @private Read the delivery ledger (one mark per line). Best-effort: a missing file is empty. */ + /** @private Read the delivery ledger (`mark\texpiresAt` per line; a 0.14.2 line is a bare mark). */ _loadLedger() { try { const dir = this._node && this._node._store && this._node._store._dir; if (!dir) return; this._ledgerPath = path.join(dir, '.delivered-marks'); const text = fs.readFileSync(this._ledgerPath, 'utf8'); - for (const line of text.split('\n')) if (line) this._ledger.add(line); + const now = Date.now(); + let lines = 0; + let old = false; + for (const line of text.split('\n')) { + if (!line) continue; + lines++; + const tab = line.lastIndexOf('\t'); + if (tab <= 0) old = true; + const mark = tab > 0 ? line.slice(0, tab) : line; + const at = tab > 0 ? Number(line.slice(tab + 1)) : now + LEDGER_TTL_MS; // an old mark protects from now + if (!Number.isFinite(at) || at <= now) continue; + this._ledgerPut(mark, at); + } + this._ledgerLines = lines; + this._ledgerTrim(now); + // Rewritten once when the load dropped anything or read a 0.14.2 line (which gets its expiry). + if (old || lines !== this._ledger.size) this._ledgerCompact(); } catch { /* none yet */ } } + /** @private Hold `mark` until `expiresAt` (in memory). */ + _ledgerPut(mark, expiresAt) { + const had = this._ledger.get(mark); + if (had !== undefined && had >= expiresAt) return; + this._ledger.set(mark, expiresAt); + const author = mark.slice(0, mark.indexOf('|')); + let set = this._ledgerByAuthor.get(author); + if (!set) { set = new Set(); this._ledgerByAuthor.set(author, set); } + set.delete(mark); + set.add(mark); + } + + /** @private Drop `mark` from memory. */ + _ledgerDrop(mark) { + if (!this._ledger.delete(mark)) return; + const author = mark.slice(0, mark.indexOf('|')); + const set = this._ledgerByAuthor.get(author); + if (set) { set.delete(mark); if (!set.size) this._ledgerByAuthor.delete(author); } + } + + /** @private Keep the ledger within LEDGER_MAX: expired marks first, then the heaviest author's oldest. */ + _ledgerTrim(now = Date.now()) { + if (this._ledger.size <= LEDGER_MAX) return; + for (const [mark, at] of [...this._ledger]) if (at <= now) this._ledgerDrop(mark); + const target = Math.floor(LEDGER_MAX * 0.99); + while (this._ledger.size > target) { + // The author holding the most marks gives up its oldest, down to the next heaviest's count. + let first = null; let second = 0; + for (const set of this._ledgerByAuthor.values()) { + if (!first || set.size > first.size) { if (first) second = Math.max(second, first.size); first = set; } else second = Math.max(second, set.size); + } + if (!first) break; + let n = Math.max(1, Math.min(first.size - second + 1, this._ledger.size - target)); + for (const mark of [...first]) { if (n-- <= 0) break; this._ledgerDrop(mark); this._ledgerEvicted++; } + } + } + + /** @private Rewrite the ledger file from memory, atomically. */ + _ledgerCompact() { + if (!this._ledgerPath) return; + try { + const tmp = `${this._ledgerPath}.${process.pid}.tmp`; + let out = ''; + for (const [mark, at] of this._ledger) out += `${mark}\t${at}\n`; + fs.writeFileSync(tmp, out, { mode: 0o600 }); + fs.renameSync(tmp, this._ledgerPath); + this._ledgerLines = this._ledger.size; + } catch { /* best effort: the appended file still holds every mark */ } + } + + /** @private When a delivery's mark stops protecting anything (see the ledger above). */ + _ledgerExpiry(msg, key, now) { + if (msg && msg._directedToMe === true && String(key).includes('#')) { + const ts = msg.cmb && msg.cmb.metadata && msg.cmb.metadata.createdTimestamp; + const from = typeof ts === 'number' && Number.isFinite(ts) ? Math.max(now, ts) : now; + return from + DIRECTED_WINDOW_MS + LEDGER_SKEW_MS; + } + return now + LEDGER_TTL_MS; + } + /** @private The ledger mark of a delivery: its author and its incoming key. */ _ledgerMark(msg, key) { if (!key) return null; @@ -307,26 +449,17 @@ class FrameHandler { * chance to be delivered. Idempotent and no-op when the key is absent. * @private */ - /** @private Take one of `session`'s pre-verification tokens. @returns {boolean} */ - _verifyBudget(session) { - const now = Date.now(); - const b = session._verifyBucket || (session._verifyBucket = { tokens: VERIFY_BURST, at: now }); - b.tokens = Math.min(VERIFY_BURST, b.tokens + ((now - b.at) * VERIFY_PER_SECOND) / 1000); - b.at = now; - if (b.tokens < 1) return false; - b.tokens -= 1; - return true; + /** + * @private Take one of `session`'s pre-verification tokens and `chars` of its pre-verification size + * budget (R2-3), or neither. @returns {boolean} + */ + _verifyBudget(session, chars = 0) { + return takeBoth(session, '_verifyBucket', VERIFY_PER_SECOND, VERIFY_BURST, '_verifyCharsBucket', VERIFY_CHARS_PER_SECOND, VERIFY_CHARS_BURST, chars); } - /** @private Take one of `session`'s record tokens. @returns {boolean} */ - _recordBudget(session) { - const now = Date.now(); - const b = session._recordBucket || (session._recordBucket = { tokens: RECORDS_BURST, at: now }); - b.tokens = Math.min(RECORDS_BURST, b.tokens + ((now - b.at) * RECORDS_PER_SECOND) / 1000); - b.at = now; - if (b.tokens < 1) return false; - b.tokens -= 1; - return true; + /** @private Take one of `session`'s record tokens and `chars` of its record size budget (R2-3), or neither. @returns {boolean} */ + _recordBudget(session, chars = 0) { + return takeBoth(session, '_recordBucket', RECORDS_PER_SECOND, RECORDS_BURST, '_recordCharsBucket', RECORD_CHARS_PER_SECOND, RECORD_CHARS_BURST, chars); } _markCmbSurfaced(key, msg) { @@ -336,8 +469,14 @@ class FrameHandler { // loses nothing a later copy cannot bring, and nothing surfaced is surfaced twice. const mark = this._ledgerMark(msg, key); if (mark && !this._ledger.has(mark)) { - this._ledger.add(mark); - if (this._ledgerPath) { try { fs.appendFileSync(this._ledgerPath, `${mark}\n`, { mode: 0o600 }); } catch { /* the in-memory mark still holds for this instance */ } } + const now = Date.now(); + const at = this._ledgerExpiry(msg, key, now); + this._ledgerPut(mark, at); + if (this._ledgerPath) { + try { fs.appendFileSync(this._ledgerPath, `${mark}\t${at}\n`, { mode: 0o600 }); this._ledgerLines++; } catch { /* the in-memory mark still holds for this instance */ } + } + if (this._ledger.size > LEDGER_MAX) this._ledgerTrim(now); + else if (this._ledgerLines > 2 * this._ledger.size + LEDGER_COMPACT_SLACK) this._ledgerCompact(); } } @@ -627,7 +766,7 @@ class FrameHandler { * @private */ _surfaceDirectedReject(msg, peerName, peerId, now, decision) { - if (!msg._directedToMe) return false; + if (!msg._directedToMe) return this._surfaceActionableDuplicate(msg, peerName, peerId, now, decision); const entry = this._markProvenance({ ...msg, content: msg.content, @@ -644,6 +783,35 @@ class FrameHandler { return true; } + /** + * An ACTIONABLE room record (verified, with a signed application section) that memory de-duplicated + * — SVAF found it redundant, or the store already holds its cognition key — is still delivered + * (security review R2-6, 0.14.3; §8.8.2: memory de-duplication uses the cognition key, actionable + * delivery de-duplication MUST use the assertion identity). It surfaces as `remixed: false`, like a + * directed record SVAF did not store; its delivery is de-duplicated by its assertion identity + * (_handleMemoryShare's incoming key). A record SVAF rejected for drift is not delivered. + * @private + */ + _surfaceActionableDuplicate(msg, peerName, peerId, now, decision) { + if (msg._cmbVerified !== true || msg._legacyImport) return false; + if (!(msg.cmb && msg.cmb.metadata && msg.cmb.metadata.application != null)) return false; + if (decision !== 'redundant' && decision !== 'not-stored') return false; + const entry = this._markProvenance({ + ...msg, + content: msg.content, + source: msg.source || peerName, + peerId, + storedAt: now, + directed: false, + remixed: false, + decision, + }, msg); + this._node._log(`Actionable CMB from ${peerName} — memory already holds its categories (${decision}); delivered by its assertion identity (remixed:false)`); + this._markCmbSurfaced(msg._incomingKey, msg); + this._node._emitAccepted(entry); + return true; + } + /** * §8.8.5 for an inbound record, Core Secure (design D4, §18.3.1): returns true when the record must * be REFUSED, and on success sets msg._cmbVerified, msg._verifiedAuthorNodeId and msg._authorKey. @@ -696,7 +864,7 @@ class FrameHandler { // session's verification lane, then the (relayer, author) failure window. Dropped unchecked and // unmarked either way, so a genuine copy from elsewhere is still taken. const session = this._sessionOf.get(msg); - if (session && !this._verifyBudget(session)) { + if (session && !this._verifyBudget(session, recordChars(cmb))) { msg._cmbVerified = false; this._node.emit('metric', { type: 'cmb-over-verify-budget', from: peerName, key: k }); this._sayOnceAMinute(this._audienceSaid, `${peerId}|verify-budget`, (more) => `CMBs from ${peerName} over its verification budget (${VERIFY_PER_SECOND}/s) — dropped unchecked${more}`); @@ -735,6 +903,9 @@ class FrameHandler { try { derivedId = assertionIdV2_0(rec); } catch { /* not a well-formed v2.0 record */ } if (!derivedId || rec.metadata.assertionId !== derivedId) { this._node.emit('metric', { type: 'cmb-clear-metadata-altered', from: peerName, key: k }); + // One relayed in another's name counts toward that pair's failure window, as a bad signature + // does (final re-review, Finding 3): past a few, the pair is dropped unread for the window. + if (session && md.createdByNodeId !== session.nodeId && typeof this._node._noteRelayFailure === 'function') this._node._noteRelayFailure(peerId, md.createdByNodeId); return refuse('assertion-id-mismatch'); } const v = verifyCMB(rec, authorKey); @@ -1225,7 +1396,19 @@ class FrameHandler { return; } const mark = msg._cmbVerified === true ? assertionMark(msg.cmb) : null; - const incomingKey = cognitionKey && msg._directedToMe && mark + // §8.8.2: directed OR ACTIONABLE delivery de-duplication MUST use the assertion identity (security + // review R2-6, 0.14.3). A room record that carries a signed application section is actionable: + // two of them with the same categories are two assertions (another action, or another author's), + // and keyed by the cognition key alone the first suppressed the second, so a peer could front-run + // an action with its own under the same words. + const actionable = !!(msg.cmb && msg.cmb.metadata && msg.cmb.metadata.application != null); + // Its delivery mark is kept LEDGER_TTL_MS (R2-22), so an actionable record signed longer ago than + // that could only be a replay an expired mark no longer catches: refused, counted. + if (actionable && !msg._directedToMe && verified && typeof signedTs === 'number' && now - signedTs > LEDGER_TTL_MS) { + this._node.emit('metric', { type: 'cmb-actionable-stale', from: peerName, key: cognitionKey, age: now - signedTs }); + return; + } + const incomingKey = cognitionKey && (msg._directedToMe || actionable) && mark ? `${cognitionKey}#${mark}` : cognitionKey; if (incomingKey) { @@ -1261,7 +1444,7 @@ class FrameHandler { // here, after the signature and the de-duplication and before SVAF encodes anything, which is // most of what a record costs (security review D, record-flood). Past it the record is dropped // unmarked, so a later copy is taken. - if (session && !this._recordBudget(session)) { + if (session && !this._recordBudget(session, recordChars(msg.cmb))) { this._node.emit('metric', { type: 'cmb-over-budget', from: peerName, key: cognitionKey }); this._sayOnceAMinute(this._audienceSaid, `${peerId}|record-budget`, (more) => `CMBs from ${peerName} over its budget (${RECORDS_PER_SECOND}/s) — dropped unevaluated${more}`); return; @@ -1829,6 +2012,20 @@ class FrameHandler { * @private */ _reencodeLocalStateAfterAdmit() { + // PACED (security review R2-3, 0.14.3): at most once every REENCODE_MIN_MS. The first admit after + // a quiet spell re-encodes at once; admits inside the spell share one trailing re-encode, which + // reads the context as it is then (so the last admit is always reflected). Re-encoding on every + // admit let one peer's stream of records hold the event loop for the encoder on each of them. + if (this._reencodeTimer) return; + const wait = (this._reencodedAt || 0) + REENCODE_MIN_MS - Date.now(); + if (wait <= 0) { this._reencodeLocalStateNow(); return; } + this._reencodeTimer = setTimeout(() => { this._reencodeTimer = null; this._reencodeLocalStateNow(); }, wait); + if (this._reencodeTimer.unref) this._reencodeTimer.unref(); + } + + /** @private The re-encode itself (see _reencodeLocalStateAfterAdmit). */ + _reencodeLocalStateNow() { + this._reencodedAt = Date.now(); // Never let a state update break an admission that already succeeded: the block is stored, // and losing the re-encode is recoverable where throwing here would discard the admission. try { diff --git a/lib/memory-store.js b/lib/memory-store.js index 9c885aa4..69eabad5 100644 --- a/lib/memory-store.js +++ b/lib/memory-store.js @@ -82,6 +82,74 @@ function asStored(entry) { return entry; } +/** + * The entry cache, BOUNDED (security review R2-3, 0.14.3): it held every entry the store had ever + * seen, read at load or written since, so a peer's large records grew memory without bound. It is a + * cache in front of the files (every entry it holds is persisted first; a miss reads the file), so it + * keeps the most recently used entries only: at most CACHE_MAX_ENTRIES, and at most CACHE_MAX_CHARS + * of text in all (the entry's content and category texts, measured in string length). + */ +const CACHE_MAX_ENTRIES = 2048; +const CACHE_MAX_CHARS = 32 * 1024 * 1024; + +function entryWeight(e) { + if (!e || typeof e !== 'object') return 1; + let n = 256 + (typeof e.content === 'string' ? e.content.length : 0); + const cats = e.cmb && e.cmb.categories; + if (cats && typeof cats === 'object') { + for (const v of Object.values(cats)) { + if (typeof v === 'string') n += v.length; + else if (v && typeof v.text === 'string') n += v.text.length; + } + } + const app = e.cmb && e.cmb.metadata && e.cmb.metadata.application; + if (app && typeof app.data === 'string') n += app.data.length; + return n; +} + +class EntryCache { + constructor({ maxEntries = CACHE_MAX_ENTRIES, maxChars = CACHE_MAX_CHARS } = {}) { + this._map = new Map(); // key -> { entry, weight }, least recently used first + this._weight = 0; + this._maxEntries = maxEntries; + this._maxChars = maxChars; + } + get size() { return this._map.size; } + get weight() { return this._weight; } + has(key) { return this._map.has(key); } + get(key) { + const slot = this._map.get(key); + if (!slot) return undefined; + this._map.delete(key); + this._map.set(key, slot); + return slot.entry; + } + set(key, entry) { + const old = this._map.get(key); + if (old) { this._weight -= old.weight; this._map.delete(key); } + const weight = entryWeight(entry); + this._map.set(key, { entry, weight }); + this._weight += weight; + while (this._map.size > 1 && (this._map.size > this._maxEntries || this._weight > this._maxChars)) { + const [k, slot] = this._map.entries().next().value; + this._map.delete(k); + this._weight -= slot.weight; + } + return this; + } + delete(key) { + const slot = this._map.get(key); + if (!slot) return false; + this._weight -= slot.weight; + return this._map.delete(key); + } + clear() { this._map.clear(); this._weight = 0; } + *keys() { yield* this._map.keys(); } + *values() { for (const s of this._map.values()) yield s.entry; } + *entries() { for (const [k, s] of this._map) yield [k, s.entry]; } + [Symbol.iterator]() { return this.entries(); } +} + class MemoryIndex { /** Create an empty index. */ constructor() { @@ -177,7 +245,7 @@ class MemoryStore { this.__indexed = false; // has the index been built (by either path)? this.__loading = null; // the in-flight asynchronous load, if any this._index = new MemoryIndex(); - this._cache = new Map(); // key → full entry (avoids disk reads on recall) + this._cache = new EntryCache(); // key → full entry (avoids disk reads on recall); bounded (R2-3) ensureDir(this._dir); @@ -226,7 +294,7 @@ class MemoryStore { const batch = Math.max(1, opts.batch ?? 64); this.__loading = (async () => { const index = new MemoryIndex(); - const cache = new Map(); + const cache = new EntryCache(); let files = []; try { files = (await fs.promises.readdir(this._dir)).filter(isStoreFile); } catch { files = []; } for (let i = 0; i < files.length; i += batch) { @@ -1099,4 +1167,4 @@ class MemoryStore { } } -module.exports = { MemoryStore }; +module.exports = { MemoryStore, EntryCache, CACHE_MAX_ENTRIES, CACHE_MAX_CHARS }; diff --git a/lib/node.js b/lib/node.js index 046690b5..8835c800 100644 --- a/lib/node.js +++ b/lib/node.js @@ -293,6 +293,9 @@ function assertAddressee(to) { /** The schema of the signed application section remember({ payload }) carries (§8.8.3). */ const PAYLOAD_SCHEMA = 'https://sym.bot/schema/payload-v1.json'; +/** The local-state context reads at most this much of one entry, and this much in all (R2-3). */ +const CONTEXT_ENTRY_CHARS = 2048; +const CONTEXT_MAX_CHARS = 16 * 1024; /** A remember() payload as a signed application section: its JSON bytes. */ function payloadApplication(payload) { @@ -2131,11 +2134,23 @@ class SymNode extends EventEmitter { } } + /** + * The text the local state is encoded from: the cognitive profile and the newest entries. BOUNDED + * (security review R2-3, 0.14.3): at most CONTEXT_ENTRY_CHARS of each entry and CONTEXT_MAX_CHARS + * in all, so its encoding costs the same whatever a peer's records weigh (it took the 20 newest + * entries whole, up to 720 KiB each, and encoded them synchronously on every admit). + */ _buildContext() { const parts = []; - if (this._cognitiveProfile) parts.push(this._cognitiveProfile); - const entries = this._store.allEntries().slice(0, 20); - parts.push(...entries.map(e => e.content || '')); + let left = CONTEXT_MAX_CHARS; + const take = (text) => { + if (left <= 0 || typeof text !== 'string' || !text) return; + const t = text.length > Math.min(CONTEXT_ENTRY_CHARS, left) ? text.slice(0, Math.min(CONTEXT_ENTRY_CHARS, left)) : text; + parts.push(t); + left -= t.length + 1; + }; + if (this._cognitiveProfile) take(this._cognitiveProfile); + for (const e of this._store.allEntries().slice(0, 20)) take(e.content || ''); return parts.join('\n'); } diff --git a/tests/security-0.14.3.test.js b/tests/security-0.14.3.test.js index bd5dc9d7..5f3b88eb 100644 --- a/tests/security-0.14.3.test.js +++ b/tests/security-0.14.3.test.js @@ -152,3 +152,154 @@ describe('R2-2: a record whose signed bytes the AEAD did not authenticate is dro }); } }); + +describe('R2-6: actionable delivery is de-duplicated by assertion identity (§8.8.2)', () => { + const { settle } = require('./_settle'); + const appOf = (m) => (m.record && m.record.metadata.application ? Buffer.from(m.record.metadata.application.data, 'base64url').toString() : 'none'); + + it('R2-6: two actionable room records with the same categories are both delivered; a replay of either is not', async () => { + try { + const node = mk('r26'); + if (typeof node._store.load === 'function') await node._store.load(); + const mallory = identity('mallory'); const alice = identity('alice'); + const sm = admitAs(node, mallory); const sa = admitAs(node, alice); + const cats = { focus: 'release 0.14.3 approved', intent: 'deploy' }; + const front = signedRecord(mallory, { categories: cats, room: 'sec', application: applicationFor(Buffer.from('{"deploy":"mallory-build"}')) }); + const real = signedRecord(alice, { categories: cats, room: 'sec', application: applicationFor(Buffer.from('{"deploy":"official-build"}')) }); + deliver(node, sm, { type: 'cmb', cmb: front }); await settle(100); + deliver(node, sa, { type: 'cmb', cmb: real }); await settle(100); + const job1 = signedRecord(alice, { categories: { focus: 'run job', intent: 'act' }, room: 'sec', application: applicationFor(Buffer.from('{"job":1}')) }); + const job2 = signedRecord(alice, { categories: { focus: 'run job', intent: 'act' }, room: 'sec', application: applicationFor(Buffer.from('{"job":2}')) }); + deliver(node, sa, { type: 'cmb', cmb: job1 }); await settle(100); + deliver(node, sa, { type: 'cmb', cmb: job2 }); await settle(100); + // Replays of each, by the author and by a relay. + for (const r of [front, real, job1, job2]) { deliver(node, sa, { type: 'cmb', cmb: JSON.parse(JSON.stringify(r)) }); await settle(30); } + const got = node.inbox({ peek: true }).messages.map((m) => `${m.from}:${appOf(m)}`); + assert.deepStrictEqual(got.sort(), ['alice:{"deploy":"official-build"}', 'alice:{"job":1}', 'alice:{"job":2}', 'mallory:{"deploy":"mallory-build"}'].sort()); + } finally { await stopAll(); } + }); +}); + +describe('R2-3: one admitted peer cannot buy CPU, memory or disk without bound', () => { + const { settle } = require('./_settle'); + const { FrameHandler } = require('../lib/frame-handler'); + const big = (n, seed) => { let t = ''; let j = 0; while (t.length < n) t += `deploy pipeline status report build ${seed}_${j++} `; return t.slice(0, n); }; + + it('R2-3: the record and verification budgets are charged by size: two of the largest records pass, a third waits', () => { + const fh = new FrameHandler({ _log: () => {} }, {}); + const session = {}; + const largest = 720 * 1024; + const r = [0, 1, 2].map(() => fh._recordBudget(session, largest)); + assert.deepStrictEqual(r, [true, true, false], 'the record lane spends the size'); + const small = {}; + for (let i = 0; i < 32; i++) assert.strictEqual(fh._recordBudget(small, 2048), true, 'small records are counted by number as before'); + const v = {}; + let passed = 0; + for (let i = 0; i < 12; i++) if (fh._verifyBudget(v, 400 * 1024)) passed++; + assert.ok(passed >= 8 && passed <= 11, `the verification lane spends the size too (${passed} of 12 passed)`); + }); + + it('R2-3: a burst of large records from one peer: only what its size budget holds reaches SVAF', async () => { + try { + const node = mk('r23-flood'); + if (typeof node._store.load === 'function') await node._store.load(); + const mallory = identity('mallory'); + const s = admitAs(node, mallory); + const over = []; node.on('metric', (m) => { if (m.type === 'cmb-over-budget' || m.type === 'cmb-over-verify-budget') over.push(m.type); }); + let evaluated = 0; + const admit = node._frameHandler._admit; + node._frameHandler._admit = (...a) => { evaluated++; return admit(...a); }; + for (let i = 0; i < 12; i++) { + const rec = signedRecord(mallory, { categories: { focus: big(200000, `f${i}`), issue: big(200000, `i${i}`) }, room: 'sec' }); + deliver(node, s, { type: 'cmb', cmb: rec }); + } + await settle(300); + assert.ok(evaluated <= 4, `at most what 1.5 MiB holds was evaluated (${evaluated})`); + assert.ok(over.length >= 8, 'the rest was dropped and counted'); + } finally { await stopAll(); } + }); + + it('R2-3: the local-state context reads a bounded amount of each entry and in all', async () => { + try { + const node = mk('r23-context'); + if (typeof node._store.load === 'function') await node._store.load(); + node._store.allEntries = () => Array.from({ length: 20 }, (_, i) => ({ content: big(300000, `e${i}`) })); + const ctx = node._buildContext(); + assert.ok(ctx.length <= 16 * 1024 + 64, `the context is bounded (${ctx.length} characters)`); + assert.ok(ctx.length > 4096, 'and still reads several entries'); + } finally { await stopAll(); } + }); + + it('R2-3: the local state is re-encoded at most once a second, whatever the admits', async () => { + const calls = []; + const node = { _buildContext: () => 'context', _meshNode: { updateLocalState: () => calls.push(Date.now()) }, _log: () => {} }; + const fh = new FrameHandler(node, {}); + for (let i = 0; i < 50; i++) fh._reencodeLocalStateAfterAdmit(); + assert.strictEqual(calls.length, 1, 'the first admit re-encodes at once; the rest share one trailing re-encode'); + await until(() => calls.length >= 2, 2500); + assert.strictEqual(calls.length, 2, 'one trailing re-encode, so the last admit is reflected'); + }); + + it('R2-3: the SVAF kernel reads at most SVAF_TEXT_MAX characters of one text', async () => { + const enc = require('../lib/core/context-encoder'); + const text = big(300000, 'svaf'); + const head = text.slice(0, 16 * 1024); + const a = await enc.svafKernel().encode(text); + const b = await enc.svafKernel().encode(head); + assert.deepStrictEqual(a.h1, b.h1, 'a text longer than the cap encodes as its first SVAF_TEXT_MAX characters'); + }); + + it('R2-3: the store\'s entry cache is bounded, by count and by text', async () => { + try { + const node = mk('r23-cache'); + if (typeof node._store.load === 'function') await node._store.load(); + const cache = node._store._cache; + for (let i = 0; i < 3000; i++) cache.set(`cmb-${String(i).padStart(64, '0')}`, { key: `k${i}`, content: 'x' }); + assert.ok(cache.size <= 2048, `bounded by count (${cache.size})`); + for (let i = 0; i < 40; i++) cache.set(`big-${i}`, { key: `b${i}`, content: big(1024 * 1024, `c${i}`) }); + let chars = 0; for (const [, e] of cache) chars += (e.content || '').length; + assert.ok(chars <= 32 * 1024 * 1024 + 1024 * 1024, `bounded by text (${chars} characters)`); + assert.ok(cache.has('big-39'), 'the most recent is kept'); + } finally { await stopAll(); } + }); +}); + +describe('R2-22: the delivery ledger is pruned', () => { + const { FrameHandler } = require('../lib/frame-handler'); + const path = require('path'); + const os = require('os'); + + it('R2-22: each mark carries its expiry; expired marks are dropped at load and the file is rewritten', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sym-ledger-')); + try { + const file = path.join(dir, '.delivered-marks'); + const now = Date.now(); + fs.writeFileSync(file, [ + 'a|cmb-old-format-line', // a 0.14.2 line: protects from now on + `a|cmb-expired\t${now - 1000}`, + `a|cmb-live#asrt-x\t${now + 3600_000}`, + ].join('\n') + '\n'); + const fh = new FrameHandler({ _log: () => {}, _store: { _dir: dir } }, {}); + assert.deepStrictEqual([...fh._ledger.keys()].sort(), ['a|cmb-live#asrt-x', 'a|cmb-old-format-line']); + const lines = fs.readFileSync(file, 'utf8').trim().split('\n'); + assert.strictEqual(lines.length, 2, 'the file holds what is kept'); + assert.ok(lines.every((l) => /\t\d+$/.test(l)), 'each with its expiry'); + // A directed mark lasts the replay window past its signed time; any other LEDGER_TTL_MS. + const msg = { _cmbVerified: true, _verifiedAuthorNodeId: 'b', _directedToMe: true, cmb: { metadata: { createdTimestamp: now } } }; + fh._markCmbSurfaced('cmb-d#asrt-d', msg); + const at = fh._ledger.get('b|cmb-d#asrt-d'); + assert.ok(at > now + 24 * 3600_000 && at < now + 26 * 3600_000, 'a directed mark ends with its replay window'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('R2-22: the ledger is bounded, and one author\'s flood pushes out only its own marks', () => { + const fh = new FrameHandler({ _log: () => {} }, {}); + fh._ledgerPath = null; + const as = (author) => ({ _cmbVerified: true, _verifiedAuthorNodeId: author }); + for (let i = 0; i < 10; i++) fh._markCmbSurfaced(`cmb-alice-${i}`, as('alice')); + for (let i = 0; i < 200_100; i++) fh._markCmbSurfaced(`cmb-m-${i}`, as('mallory')); + assert.ok(fh._ledger.size <= 200_000, `bounded (${fh._ledger.size})`); + for (let i = 0; i < 10; i++) assert.ok(fh._ledger.has(`alice|cmb-alice-${i}`), 'another author\'s marks are kept'); + assert.ok(fh._ledger.has('mallory|cmb-m-200099'), 'the flooder keeps its newest'); + }); +}); From 34f94e29d9698d1be5b52e566db0099324a86198 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:11:55 +0100 Subject: [PATCH 11/20] R2-9: earned key bindings age out, and new ones hold a quarter of the registry One record (a directed one is delivered whatever SVAF decides, and an empty memory admits anything) earned a binding that never expired, so a flood of fresh identities filled the registry for good and no honest newcomer could earn one. - A proven binding is NEW until it has verified records on occasions at least a day apart: it expires after 30 days unseen, and new bindings hold at most a quarter of the registry (past it a newcomer is held for its session only, counted as refusedShareCount). - An ESTABLISHED binding expires after 180 days unseen. - Pinned, vouched, anchored and legacy-claim bindings never expire. A 0.14.2 binding that verified something counts as established. - tests: core-secure-binding-lifetime encoded 'verified never expires' and an unshared registry; updated to the new rule. --- lib/roster-keys.js | 103 +++++++++++++++++---- tests/core-secure-binding-lifetime.test.js | 34 ++++--- tests/security-0.14.3.test.js | 43 +++++++++ 3 files changed, 149 insertions(+), 31 deletions(-) diff --git a/lib/roster-keys.js b/lib/roster-keys.js index b2833a29..fd7c07b8 100644 --- a/lib/roster-keys.js +++ b/lib/roster-keys.js @@ -58,6 +58,9 @@ * vouched (a grant in effect, or a pin, names its key), anchored or a legacy claim (the finite 0.13 * migration), never expires, so no binding that protects history is forgotten. The facts `seen` and * `verified` are persisted with the binding (a `seen` line at most once a day per binding). + * AMENDED (security review R2-9, 0.14.3): an earned binding ages out too — a new one (everything it + * verified fell within a day) after BINDING_TTL_MS unseen, and new ones hold at most a quarter of the + * registry; an established one after EARNED_TTL_MS (180 days) unseen (see EARNED_TTL_MS). * * A binding is NEVER EVICTED before it expires (security review D): when the registry is full, what * has expired goes, and if nothing has, a newcomer is refused a binding (`full`) and is held for its @@ -99,6 +102,25 @@ const rankOf = (s) => SOURCE_RANK[s] ?? 0; const MAX_BINDINGS = 65536; /** A first-contact proven binding that verified nothing expires after this long unseen. */ const BINDING_TTL_MS = 30 * 24 * 60 * 60 * 1000; +/** + * EARNED BINDINGS AGE OUT TOO, AND NEW ONES HAVE A SHARE (security review R2-9, 0.14.3). One record, + * which any fresh identity can send (a directed one is delivered whatever SVAF decides, and an empty + * memory admits anything), earned a binding that never expired, so a flood of identities filled the + * registry for good and no honest newcomer could earn one again. Now a proven binding is in one of + * two expiry classes: + * - NEW: nothing verified under it yet, or what did all fell within ESTABLISH_MS (one day) of the + * first. It expires after BINDING_TTL_MS unseen, and new bindings hold at most a 1/SHORT_SHARE + * share of the registry: past it a newcomer is held for its session only (`full`), however much + * room the rest of the registry has, so a flood of one-shot identities fills a quarter at most, + * and only for a month; + * - ESTABLISHED: it verified records on occasions at least ESTABLISH_MS apart (a peer that came + * back). It expires after EARNED_TTL_MS (180 days) unseen. + * Pinned, vouched, anchored and legacy-claim bindings never expire, as before. A 0.14.2 binding that + * verified something counts as established (it was one that never expired). + */ +const EARNED_TTL_MS = 180 * 24 * 60 * 60 * 1000; +const ESTABLISH_MS = 24 * 60 * 60 * 1000; +const SHORT_SHARE = 4; /** A `seen` or `verified` fact is written at most this often per binding (in memory it is exact). */ const FACT_PERSIST_MS = 24 * 60 * 60 * 1000; /** Conflicts recorded at most per nodeId, and in all; past either, a conflict is only counted. */ @@ -129,6 +151,7 @@ class RosterKeyRegistry { constructor(opts = {}) { this._now = typeof opts.now === 'function' ? opts.now : Date.now; this._ttl = Number.isFinite(opts.bindingTtlMs) && opts.bindingTtlMs > 0 ? opts.bindingTtlMs : BINDING_TTL_MS; + this._earnedTtl = Number.isFinite(opts.earnedTtlMs) && opts.earnedTtlMs > 0 ? opts.earnedTtlMs : EARNED_TTL_MS; this._isLive = typeof opts.isLive === 'function' ? opts.isLive : () => false; this._isRouted = typeof opts.isRouted === 'function' ? opts.isRouted : () => false; // The in-force grants now (the authority store, MMP §6.6.9): nodeId -> the one key they name, or undefined. @@ -141,7 +164,11 @@ class RosterKeyRegistry { // The expirable bindings (first contact, proven, nothing verified, not vouched), least recently // seen first: expiry takes from the head, O(1) each. this._lru = new Map(); + // The established bindings (verified on occasions a day or more apart), least recently seen first: + // they expire after EARNED_TTL_MS unseen (R2-9). + this._lruLong = new Map(); this._refusedFull = 0; // newcomers refused a binding because nothing had expired + this._refusedShare = 0; // of them, refused because new bindings held their share (R2-9) this._conflicts = []; // { nodeId, had, hadSource, got, gotSource, at } this._conflictCount = 0; // every conflict, recorded or not this._floorArmed = new Set(); // nodeIds whose sticky floor is armed (persisted, never expires) @@ -342,6 +369,11 @@ class RosterKeyRegistry { // signs under the key its live session proved, for as long as that session lasts). if (this._byNode.size >= this._max && source !== 'pinned' && !this._loading) this.expire(); if (this._byNode.size >= this._max && source !== 'pinned') { this._refusedFull++; return { bound: false, reason: 'full' }; } + // New proven bindings hold at most their share of the registry (R2-9). + if (source === 'proven' && !this._loading && this._lru.size >= this._shortCap()) { + this.expire(); + if (this._lru.size >= this._shortCap()) { this._refusedFull++; this._refusedShare++; return { bound: false, reason: 'full' }; } + } const now = this._now(); const nb = { key, source, at, seenAt: this._loading ? null : now, verifiedAt: null, vouched: false, seenSaved: this._loading ? null : now, verifiedSaved: null }; this._byNode.set(nodeId, nb); @@ -374,10 +406,13 @@ class RosterKeyRegistry { if (!b) return; const now = this._now(); const first = b.verifiedAt == null; + if (b.firstVerifiedAt == null) b.firstVerifiedAt = now; + const before = this._class(b); b.verifiedAt = now; b.seenAt = now; + const changed = this._class(b) !== before; // it became established (R2-9) this._track(nodeId, b); - if (first || now - (b.verifiedSaved ?? 0) >= FACT_PERSIST_MS) { b.verifiedSaved = now; b.seenSaved = now; this._persist({ nodeId, verified: now }); } + if (first || changed || now - (b.verifiedSaved ?? 0) >= FACT_PERSIST_MS) { b.verifiedSaved = now; b.seenSaved = now; this._persist({ nodeId, verified: now, first: b.firstVerifiedAt }); } } /** @@ -385,20 +420,37 @@ class RosterKeyRegistry { * the expiry index). A grant in effect naming its key also keeps it (dynamic, checked at expiry). * @private */ - _expirable(b) { - return b.source === 'proven' && b.verifiedAt == null && !b.vouched; + _expirable(b) { return this._class(b) !== null; } + + /** + * @private Which expiry class a binding is in (R2-9): 'short' (new: nothing verified, or all of it + * within ESTABLISH_MS of the first; BINDING_TTL_MS, and the capped share), 'long' (established: + * EARNED_TTL_MS), or null (never expires: pinned, vouched, anchored, a legacy claim). + */ + _class(b) { + if (!b || b.source !== 'proven' || b.vouched) return null; + if (b.verifiedAt == null) return 'short'; + const first = b.firstVerifiedAt ?? b.verifiedAt; + return b.verifiedAt - first >= ESTABLISH_MS ? 'long' : 'short'; } - /** @private Keep the expiry index: an expirable binding moves to the end (just seen), any other leaves. */ + /** @private The most bindings the short class may hold. */ + _shortCap() { return Math.max(1, Math.floor(this._max / SHORT_SHARE)); } + + /** @private Keep the expiry indexes: an expirable binding moves to the end of its class (just seen), any other leaves. */ _track(nodeId, b) { this._lru.delete(nodeId); - if (b && this._expirable(b)) this._lru.set(nodeId, true); + this._lruLong.delete(nodeId); + const c = this._class(b); + if (c === 'short') this._lru.set(nodeId, true); + else if (c === 'long') this._lruLong.set(nodeId, true); } /** @private Drop an expired binding. Its sticky floor, a fact of its own, stays. */ _drop(nodeId) { this._byNode.delete(nodeId); this._lru.delete(nodeId); + this._lruLong.delete(nodeId); } /** @@ -409,18 +461,21 @@ class RosterKeyRegistry { expire() { const now = this._now(); const gone = []; - // From the head of the index (least recently seen first) until a binding seen within the TTL. - while (this._lru.size) { - const nodeId = this._lru.keys().next().value; - const b = this._byNode.get(nodeId); - if (!b || !this._expirable(b)) { this._lru.delete(nodeId); continue; } - const seen = b.seenAt ?? b.seenSaved ?? null; - if (seen != null && now - seen <= this._ttl) break; - // A node with a live session, or a binding a grant in effect vouches, counts as seen now, and - // that is persisted (a restart reads the file: a live peer's old `seen` must not expire it). - if (this._isLive(nodeId) || (this._grantView && this._viewKey(nodeId) === b.key)) { this.noteSeen(nodeId); continue; } - this._lru.delete(nodeId); - gone.push(nodeId); + // From the head of each class's index (least recently seen first) until a binding seen within + // that class's TTL. + for (const [lru, cls, ttl] of [[this._lru, 'short', this._ttl], [this._lruLong, 'long', this._earnedTtl]]) { + while (lru.size) { + const nodeId = lru.keys().next().value; + const b = this._byNode.get(nodeId); + if (!b || this._class(b) !== cls) { lru.delete(nodeId); if (b) this._track(nodeId, b); continue; } + const seen = b.seenAt ?? b.seenSaved ?? null; + if (seen != null && now - seen <= ttl) break; + // A node with a live session, or a binding a grant in effect vouches, counts as seen now, and + // that is persisted (a restart reads the file: a live peer's old `seen` must not expire it). + if (this._isLive(nodeId) || (this._grantView && this._viewKey(nodeId) === b.key)) { this.noteSeen(nodeId); continue; } + lru.delete(nodeId); + gone.push(nodeId); + } } for (const nodeId of gone) this._drop(nodeId); this._expired += gone.length; @@ -440,6 +495,9 @@ class RosterKeyRegistry { /** Newcomers refused a binding because the registry was full and nothing had expired. */ refusedFullCount() { return this._refusedFull; } + /** Newcomers refused because the share for new bindings was full (R2-9). */ + refusedShareCount() { return this._refusedShare || 0; } + /** * 0.13-compatible spelling of bind, for callers that pin a vouched key (`pin(id, key, 'grant')`). * @returns {{ pinned: boolean, reason?: string }} @@ -464,6 +522,7 @@ class RosterKeyRegistry { if (this._self && nodeId === this._self.nodeId) return { resolved: false, reason: 'self' }; this._byNode.set(nodeId, { key, source: 'pinned', at: Date.now(), seenAt: this._now(), verifiedAt: null, vouched: false, seenSaved: null, verifiedSaved: null }); this._lru.delete(nodeId); + this._lruLong.delete(nodeId); this._persist({ nodeId, key, source: 'pinned', operator: true }); this._conflicts = this._conflicts.filter((c) => c.nodeId !== nodeId); this._rewriteConflicts(); @@ -544,7 +603,8 @@ class RosterKeyRegistry { for (const b of this._byNode.values()) if (this._expirable(b) && b.seenAt == null) { b.seenAt = now; b.seenSaved = now; unstamped++; } // The expiry index, least recently seen first. this._lru.clear(); - for (const [id] of [...this._byNode].filter(([, b]) => this._expirable(b)).sort((x, y) => x[1].seenAt - y[1].seenAt)) this._lru.set(id, true); + this._lruLong.clear(); + for (const [id, b] of [...this._byNode].filter(([, x]) => this._expirable(x)).sort((x, y) => x[1].seenAt - y[1].seenAt)) this._track(id, b); if (!this._readOnly) expired = this.expire(); } finally { this._loading = false; } // Rewritten once, atomically: a 0.13 file (migration), or one whose lines outgrew its bindings. @@ -586,6 +646,10 @@ class RosterKeyRegistry { const now = this._now(); if (Number.isFinite(r.seen)) { const t = Math.min(r.seen, now); if (b.seenAt == null || t > b.seenAt) { b.seenAt = t; b.seenSaved = t; } } if (Number.isFinite(r.verified)) { + // When it first verified something (R2-9). A 0.14.2 line names nothing: it counts as + // established (it was a binding that never expired). + const firstAt = Number.isFinite(r.first) ? Math.min(r.first, now) : Math.min(r.verified, now) - ESTABLISH_MS; + if (b.firstVerifiedAt == null || firstAt < b.firstVerifiedAt) b.firstVerifiedAt = firstAt; const t = Math.min(r.verified, now); if (b.verifiedAt == null || t > b.verifiedAt) { b.verifiedAt = t; b.verifiedSaved = t; } if (b.seenAt == null || t > b.seenAt) { b.seenAt = t; b.seenSaved = t; } @@ -633,6 +697,7 @@ class RosterKeyRegistry { const line = { nodeId, key: b.key, source: b.source }; if (b.seenAt != null) line.seen = b.seenAt; if (b.verifiedAt != null) line.verified = b.verifiedAt; + if (b.verifiedAt != null && b.firstVerifiedAt != null) line.first = b.firstVerifiedAt; if (b.vouched) line.vouched = true; lines.push(JSON.stringify(line)); } @@ -667,4 +732,4 @@ class RosterKeyRegistry { } } -module.exports = { RosterKeyRegistry, SOURCE_RANK, isIdentityKey, keyFingerprint, KEYS_FILE, CONFLICTS_FILE, FORMAT_MARKER, BINDING_TTL_MS, MAX_CONFLICTS, MAX_CONFLICTS_PER_NODE }; +module.exports = { RosterKeyRegistry, SOURCE_RANK, isIdentityKey, keyFingerprint, KEYS_FILE, CONFLICTS_FILE, FORMAT_MARKER, BINDING_TTL_MS, EARNED_TTL_MS, ESTABLISH_MS, SHORT_SHARE, MAX_CONFLICTS, MAX_CONFLICTS_PER_NODE }; diff --git a/tests/core-secure-binding-lifetime.test.js b/tests/core-secure-binding-lifetime.test.js index 7e6c963a..b4ee820a 100644 --- a/tests/core-secure-binding-lifetime.test.js +++ b/tests/core-secure-binding-lifetime.test.js @@ -17,7 +17,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); -const { RosterKeyRegistry, BINDING_TTL_MS, KEYS_FILE } = require('../lib/roster-keys'); +const { RosterKeyRegistry, BINDING_TTL_MS, EARNED_TTL_MS, KEYS_FILE } = require('../lib/roster-keys'); const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { identity, admitAs, deliver, signedRecord } = require('./_core-secure'); @@ -26,24 +26,30 @@ const DAY = 24 * 60 * 60 * 1000; const key = (i) => crypto.createHash('sha256').update(`k${i}`).digest().toString('base64url').slice(0, 43); describe('binding lifetime (D3)', () => { - it('a flood of 20,000 one-shot identities ages out; an honest long-lived peer\'s binding survives', () => { + it('a flood of one-shot identities ages out and holds at most its share; an honest long-lived peer\'s binding survives (R2-9)', () => { let t = 1_000_000; const r = new RosterKeyRegistry({ now: () => t }); const honest = 'honest-peer'; r.bind(honest, key('honest'), 'proven'); t += DAY; r.bind(honest, key('honest'), 'proven'); r.noteVerified(honest); // a later session re-proved it - for (let i = 0; i < 20_000; i++) assert.strictEqual(r.bind(`churn-${i}`, key(i), 'proven').created, true); - assert.strictEqual(r.size(), 20_001); + t += DAY; + r.noteVerified(honest); // and it verified records on two days: established (R2-9) + let created = 0; let full = 0; + for (let i = 0; i < 20_000; i++) { const b = r.bind(`churn-${i}`, key(i), 'proven'); if (b.created) created++; else if (b.reason === 'full') full++; } + assert.strictEqual(created, 65536 / 4, 'new bindings hold a quarter of the registry at most (R2-9)'); + assert.strictEqual(full, 20_000 - 65536 / 4, 'the rest are held for their sessions only'); + assert.strictEqual(r.size(), 65536 / 4 + 1); t += BINDING_TTL_MS - DAY; assert.strictEqual(r.expire(), 0, 'nothing expires before 30 days unseen'); t += 2 * DAY; - assert.strictEqual(r.expire(), 20_000, 'every one-shot identity expired'); + assert.strictEqual(r.expire(), 65536 / 4, 'every one-shot identity expired'); assert.strictEqual(r.size(), 1); assert.strictEqual(r.get(honest), key('honest'), 'the honest peer is still bound'); - t += 365 * DAY; - assert.strictEqual(r.expire(), 0, 'and never expires: it verified something'); - assert.strictEqual(r.expiredCount(), 20_000); + t += EARNED_TTL_MS - 40 * DAY; + assert.strictEqual(r.expire(), 0, 'an established binding lasts EARNED_TTL_MS unseen'); + t += 20 * DAY; + assert.strictEqual(r.expire(), 1, 'and then ages out too (R2-9)'); }); it('pinned, grant-vouched (a view), grant, legacy-claim and live bindings never expire; one seen recently does not', () => { @@ -81,7 +87,7 @@ describe('binding lifetime (D3)', () => { it('a full registry evicts nothing before it expires: a newcomer is refused a durable binding, and re-handshakes earn nothing', () => { let t = 0; - const r = new RosterKeyRegistry({ now: () => t, maxBindings: 4 }); + const r = new RosterKeyRegistry({ now: () => t, maxBindings: 16 }); // new bindings: a share of 4 (R2-9) const H = 'honest-once-seen'; r.bind(H, key('h'), 'proven'); for (let i = 0; i < 3; i++) r.bind(`fill-${i}`, key(i), 'proven'); @@ -104,8 +110,10 @@ describe('binding lifetime (D3)', () => { const r = new RosterKeyRegistry({ dir, now: () => t }); r.bind('honest', key('h'), 'proven'); for (let i = 0; i < 2000; i++) r.bind(`churn-${i}`, key(i), 'proven'); - // A record verified under the honest binding (frame-handler calls this on §8.8.5 success). + // Records verified under the honest binding on two days (frame-handler calls this on §8.8.5 + // success): established (R2-9). t += 1000; r.noteVerified('honest'); + t += DAY + 1; r.noteVerified('honest'); t += 31 * DAY; const again = new RosterKeyRegistry({ dir, now: () => t }); assert.strictEqual(again.size(), 1, 'the flood expired at load'); @@ -113,8 +121,10 @@ describe('binding lifetime (D3)', () => { assert.ok(again.entries()[0].verified, 'the verified fact survived the restart'); const lines = fs.readFileSync(path.join(dir, KEYS_FILE), 'utf8').trim().split('\n'); assert.strictEqual(lines.length, 2, 'compacted: the marker and the one binding'); - t += 365 * DAY; - assert.strictEqual(new RosterKeyRegistry({ dir, now: () => t }).size(), 1, 'and it never expires'); + t += 140 * DAY; + assert.strictEqual(new RosterKeyRegistry({ dir, now: () => t }).size(), 1, 'established: it lasts EARNED_TTL_MS unseen, across restarts'); + t += 10 * DAY; + assert.strictEqual(new RosterKeyRegistry({ dir, now: () => t }).size(), 0, 'and then ages out (R2-9)'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); diff --git a/tests/security-0.14.3.test.js b/tests/security-0.14.3.test.js index 5f3b88eb..fd01b5ec 100644 --- a/tests/security-0.14.3.test.js +++ b/tests/security-0.14.3.test.js @@ -303,3 +303,46 @@ describe('R2-22: the delivery ledger is pruned', () => { assert.ok(fh._ledger.has('mallory|cmb-m-200099'), 'the flooder keeps its newest'); }); }); + +describe('R2-9: earned key bindings age out, and new ones hold a share of the registry', () => { + const { settle } = require('./_settle'); + const { RosterKeyRegistry } = require('../lib/roster-keys'); + const DAY = 24 * 60 * 60 * 1000; + + it('R2-9: identities that each sent one directed record hold at most a quarter and age out; an honest newcomer then earns a binding', async () => { + try { + const node = mk('r29', { maxKeyBindings: 8 }); + if (typeof node._store.load === 'function') await node._store.load(); + let clock = Date.now(); node._roster._now = () => clock; + const leave = (id, s) => { s.close(); node._sessionKeys.delete(id.nodeId); node._peers.delete(id.nodeId); }; + for (let i = 0; i < 3; i++) { + const sybil = identity('sybil' + i); + const s = admitAs(node, sybil); + deliver(node, s, { type: 'cmb', cmb: signedRecord(sybil, { categories: { focus: 'hi ' + i }, room: 'sec', to: node.nodeId }) }); + await settle(50); + leave(sybil, s); + } + assert.strictEqual(node._roster.size(), 2, 'one-shot bindings hold their share (8 / 4) and no more'); + assert.ok(node._roster.refusedShareCount() >= 1); + clock += 31 * DAY; + assert.strictEqual(node._roster.expire(), 2, 'and age out after 30 days unseen, though each verified a record'); + const honest = identity('honest'); + const s = admitAs(node, honest); + deliver(node, s, { type: 'cmb', cmb: signedRecord(honest, { categories: { focus: 'real work' }, room: 'sec', to: node.nodeId }) }); + await settle(50); + assert.strictEqual(node._roster.get(honest.nodeId), honest.publicKey, 'the honest newcomer earns a durable binding'); + } finally { await stopAll(); } + }); + + it('R2-9: a binding established by records on two days ages out after EARNED_TTL_MS unseen', () => { + let t = 1_000_000; + const r = new RosterKeyRegistry({ now: () => t }); + const k = 'A'.repeat(42) + 'A'; + r.bind('peer', k, 'proven'); r.noteVerified('peer'); + t += DAY + 1; r.noteVerified('peer'); + t += 170 * DAY; + assert.strictEqual(r.expire(), 0); + t += 11 * DAY; + assert.strictEqual(r.expire(), 1, 'an earned binding no longer lives for ever'); + }); +}); From f843faceb1dd18360c1eca1e46e8a1d7c1d2e22b Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 15:47:21 +0100 Subject: [PATCH 12/20] Security R2-5/11/18/20: LAN hello rate, gated rooms over a relay, peer text in logs, bounded Bonjour cache R2-5: an unauthenticated LAN client-hello costs the server a key agreement and a signature and its sender nothing. The listener now takes a token per remote host (8/s, burst 32) and one over all hosts (64/s, burst 256) when the first frame is a client-hello, before any crypto; past either the connection closes unanswered, counted and said at most once a minute. R2-11: a sealed frame from a relay `from` went only to an ADMITTED session, so a gated room's grant-holder, whose room-join is sealed and sent right after it confirms, never reached the door and a gated room admitted nobody over a relay. It now goes to any confirmed session carrying its sessionId; the door still decides (only room-join is taken before admission). R2-18: node._log writes every C0/C1 control, DEL, line separator and bidi override as an escape, so a peer-chosen name (hello, relay, TXT) cannot forge a log line or drive a terminal. mesh-agent and the sym CLI escape peer text too. R2-20: the Bonjour reconnect cache holds only what the node dials (a canonical nodeId, mmp=2.0), at most 512, the least recently announced going first. --- bin/sym.js | 13 ++- lib/discovery.js | 91 ++++++++++++++- lib/mesh-agent.js | 13 ++- lib/node.js | 14 ++- lib/wire-identity.js | 25 ++++- tests/security-0.14.3-net.test.js | 181 ++++++++++++++++++++++++++++++ 6 files changed, 318 insertions(+), 19 deletions(-) create mode 100644 tests/security-0.14.3-net.test.js diff --git a/bin/sym.js b/bin/sym.js index bb8df349..92868a94 100755 --- a/bin/sym.js +++ b/bin/sym.js @@ -2,6 +2,7 @@ 'use strict'; const { recordCreatedBy } = require('../lib/record'); +const { logSafe } = require('../lib/wire-identity'); // peer-chosen text on the terminal (R2-18) /** * sym — CLI for the SYM mesh. @@ -1003,14 +1004,14 @@ function cmdListen() { } if (msg.event === 'cmb-accepted') { const d = msg.data; - console.log(`[${d.source}] ${d.focus}`); + console.log(`[${logSafe(d.source)}] ${logSafe(d.focus)}`); } else if (msg.event === 'message') { const d = msg.data; - console.log(`[message from ${d.from}] ${d.content}`); + console.log(`[message from ${logSafe(d.from)}] ${logSafe(d.content)}`); } else if (msg.event === 'peer-joined') { - console.log(`[+] ${msg.data.name} joined`); + console.log(`[+] ${logSafe(msg.data.name)} joined`); } else if (msg.event === 'peer-left') { - console.log(`[-] ${msg.data.name} left`); + console.log(`[-] ${logSafe(msg.data.name)} left`); } else if (jsonFlag) { console.log(JSON.stringify(msg)); } @@ -1109,8 +1110,8 @@ function formatPeers(msg) { console.log(`${peers.length} peer(s):\n`); for (const p of peers) { const via = p.via || (p.relay ? 'relay' : 'lan'); - console.log(` ${bold(p.name || p.nodeId)} ${dim('via ' + via)}`); - if (p.mood) console.log(` mood: ${p.mood}`); + console.log(` ${bold(logSafe(p.name || p.nodeId))} ${dim('via ' + logSafe(via))}`); + if (p.mood) console.log(` mood: ${logSafe(p.mood)}`); } } diff --git a/lib/discovery.js b/lib/discovery.js index 4c1a1e58..a6489311 100644 --- a/lib/discovery.js +++ b/lib/discovery.js @@ -101,6 +101,50 @@ const LISTENER_INFLIGHT_MAX = 256; const LISTENER_INFLIGHT_PER_HOST = 16; /** A LAN peer not re-announced for this long is dropped from the reconnect cache (see _reconnectCachedPeers). */ const BONJOUR_PEER_TTL_MS = 5 * 60_000; +/** + * LAN peers the reconnect cache holds at most (R2-20). An advertisement is anyone's to send on the + * LAN, and each new node-id was a new entry, kept for five minutes and re-offered every 15 s: 200,000 + * advertisements held 200,000 entries. Past the cap the least recently announced entry goes; a live + * peer is announced again at every browser restart, so it comes back. + */ +const BONJOUR_PEER_CACHE_MAX = 512; +/** + * Unauthenticated LAN client-hellos, as the relay's are (R2-5; RELAY_HELLO_* in node.js): a token + * bucket per remote host, and one over all hosts, charged when the first frame is a client-hello and + * before it reaches any crypto. Answering a hello costs the server a key agreement and a signature + * (~475 µs), and a hello costs its sender nothing (random bytes are a valid X25519 key), so one host + * opening connections could keep a core busy. A host may run many nodes (every one of them dials a + * restarted peer), so its bucket is 8 a second after a burst of 32; all hosts together get 64 a + * second after 256 (at most ~3% of a core). A hello past either bucket is closed unanswered, and its + * dialler retries after its backoff. + */ +const LAN_HELLO_PER_SECOND = 8; +const LAN_HELLO_BURST = 32; +const LAN_HELLO_GLOBAL_PER_SECOND = 64; +const LAN_HELLO_GLOBAL_BURST = 256; +const LAN_HELLO_HOSTS_MAX = 4096; + +/** + * The host a LAN hello's rate is kept for: its address, an IPv4-mapped one unwrapped and an IPv6 zone + * dropped. An IPv6 address is not widened to its /64: on a LAN the /64 is the whole subnet, every + * host on it. A host minting IPv6 addresses gets a bucket for each, and meets the bucket over all. + */ +function helloHost(addr) { + let a = typeof addr === 'string' && addr ? addr.toLowerCase() : '?'; + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(a); + if (mapped) return mapped[1]; + const zone = a.indexOf('%'); + if (zone >= 0) a = a.slice(0, zone); + return a; +} + +/** Refill a token bucket to `now` (a clock stepped back refills nothing). */ +function refillBucket(b, rate, burst, now) { + const elapsed = now - b.at; + if (elapsed > 0) b.tokens = Math.min(burst, b.tokens + (elapsed * rate) / 1000); + b.at = now; + return b; +} // ── TXT records (RFC 6763 §6) ──────────────────────────────── @@ -305,6 +349,8 @@ class BonjourDiscovery extends Discovery { if (msg && msg.type === 'handshake') this.emit('legacy-hello-refused', remote); return; } + // Rate-limited before any crypto (R2-5): past its host's bucket, or all hosts', closed unanswered. + if (!this._takeHelloToken(host)) { transport.close(); this._noteHelloRefused(host); return; } this.emit('inbound-connection', transport, msg, remote); }); @@ -314,6 +360,38 @@ class BonjourDiscovery extends Discovery { transport.on('close', () => { clearTimeout(timeout); leave(); }); } + /** @private Take a LAN client-hello token for remote `host` (R2-5): its host's first, then all hosts'. */ + _takeHelloToken(host, now = Date.now()) { + const all = this._helloAll || (this._helloAll = { tokens: LAN_HELLO_GLOBAL_BURST, at: now }); + const buckets = this._helloBuckets || (this._helloBuckets = new Map()); // least recently used first + const key = helloHost(host); + let b = buckets.get(key); + if (b) buckets.delete(key); + else { + b = { tokens: LAN_HELLO_BURST, at: now }; + if (buckets.size >= LAN_HELLO_HOSTS_MAX) buckets.delete(buckets.keys().next().value); + } + buckets.set(key, b); + refillBucket(b, LAN_HELLO_PER_SECOND, LAN_HELLO_BURST, now); + if (b.tokens < 1) return false; + refillBucket(all, LAN_HELLO_GLOBAL_PER_SECOND, LAN_HELLO_GLOBAL_BURST, now); + if (all.tokens < 1) return false; + b.tokens -= 1; + all.tokens -= 1; + return true; + } + + /** @private A LAN hello refused for its rate: counted, and said at most once a minute. */ + _noteHelloRefused(host, now = Date.now()) { + this._helloRefused = (this._helloRefused || 0) + 1; + if (now - (this._helloRefusedSaidAt || 0) < 60_000) return; + this._helloRefusedSaidAt = now; + this._log(`LAN client-hellos refused past their rate: ${this._helloRefused} so far (latest from ${helloHost(host)})`); + } + + /** LAN client-hellos refused for their rate since this discovery was built (R2-5). */ + helloRefusedCount() { return this._helloRefused || 0; } + _startBonjourFallback() { const identity = this._identity; // A host whose multicast socket cannot bind or send (Termux on Android without a multicast @@ -391,8 +469,15 @@ class BonjourDiscovery extends Discovery { if (!address || !port) return; const info = { mmp: typeof txt.get('mmp') === 'string' ? txt.get('mmp') : null, room: typeof txt.get('room') === 'string' ? txt.get('room') : null, source: 'bonjour' }; - // Update cache with current address:port, and when this peer was last announced - this._bonjourPeerCache.set(peerId, { address, port, peerName, info, seenAt: Date.now() }); + // Update cache with current address:port, and when this peer was last announced. Only what the + // node would dial is cached (a canonical nodeId, a 2.0 advertisement), the most recently + // announced last, and at most BONJOUR_PEER_CACHE_MAX: past it the least recently announced + // goes (R2-20). + if (wireNodeId(peerId) && mmpLists20(info.mmp)) { + this._bonjourPeerCache.delete(peerId); + if (this._bonjourPeerCache.size >= BONJOUR_PEER_CACHE_MAX) this._bonjourPeerCache.delete(this._bonjourPeerCache.keys().next().value); + this._bonjourPeerCache.set(peerId, { address, port, peerName, info, seenAt: Date.now() }); + } this.emit('peer-found', address, port, peerId, peerName, info); }; @@ -576,4 +661,4 @@ class NullDiscovery extends Discovery { async stop() {} } -module.exports = { Discovery, BonjourDiscovery, NullDiscovery, createBonjour, parseTxtStrings, serviceTxt, mmpLists20 }; +module.exports = { Discovery, BonjourDiscovery, NullDiscovery, createBonjour, parseTxtStrings, serviceTxt, mmpLists20, helloHost, BONJOUR_PEER_CACHE_MAX, LAN_HELLO_PER_SECOND, LAN_HELLO_BURST, LAN_HELLO_GLOBAL_PER_SECOND, LAN_HELLO_GLOBAL_BURST }; diff --git a/lib/mesh-agent.js b/lib/mesh-agent.js index b5832405..5cc0d49b 100644 --- a/lib/mesh-agent.js +++ b/lib/mesh-agent.js @@ -1,6 +1,7 @@ 'use strict'; const { recordCreatedBy, recordLineage } = require('./record'); +const { logSafe } = require('./wire-identity'); // peer-chosen text in a log line (R2-18) /** * MeshAgent — protocol-level agent lifecycle per MMP v0.2.1. @@ -202,8 +203,8 @@ class MeshAgent { console.error(`[${this._name}] remix error:`, err.message) ); }); - node.on('peer-joined', ({ name }) => console.log(`[${this._name}] peer joined: ${name}`)); - node.on('peer-left', ({ name }) => console.log(`[${this._name}] peer left: ${name}`)); + node.on('peer-joined', ({ name }) => console.log(`[${this._name}] peer joined: ${logSafe(name)}`)); + node.on('peer-left', ({ name }) => console.log(`[${this._name}] peer left: ${logSafe(name)}`)); // Catchup: peers broadcast "catchup" message to trigger immediate domain poll // See MMP Section 7: message frame emits (fromName, content, msg) @@ -270,18 +271,18 @@ class MeshAgent { // severed record still names its parents as signed, though it is a root to this node. const lineage = entry.lineage || recordLineage(entry.cmb); if (lineage?.parents?.length > 0) { - console.log(`[${this._name}] accepted remix from ${source} — no re-remix`); + console.log(`[${this._name}] accepted remix from ${logSafe(source)} — no re-remix`); return; } // Fix: skip anchor CMBs replayed on reconnect — they're historical context, // not new signals worth remixing. if (entry._anchor) { - console.log(`[${this._name}] anchor from ${source} — no remix`); + console.log(`[${this._name}] anchor from ${logSafe(source)} — no remix`); return; } - console.log(`[${this._name}] accepted from ${source}: ${focusText.slice(0, 80)}`); + console.log(`[${this._name}] accepted from ${logSafe(source)}: ${logSafe(focusText.slice(0, 80))}`); // MMP §15.7: remix requires new domain data if (!this._node.canRemix()) { @@ -329,7 +330,7 @@ class MeshAgent { return; } - console.log(`[${this._name}] remixed ${source} → ${stored.key.slice(0, 16)}`); + console.log(`[${this._name}] remixed ${logSafe(source)} → ${stored.key.slice(0, 16)}`); } // ── Domain observation (Section 13) ──────────────────── diff --git a/lib/node.js b/lib/node.js index 8835c800..c9d60b80 100644 --- a/lib/node.js +++ b/lib/node.js @@ -44,7 +44,7 @@ const projectionOrNull = (cmb) => { if (!cmb || !cmb.metadata || cmb.metadata.signatureSuite !== 'mmp-sig-v2.0') return null; try { return signedProjection(cmb); } catch { return null; } }; -const { wireNodeId, wireName, wireKey } = require('./wire-identity'); +const { wireNodeId, wireName, wireKey, logSafe } = require('./wire-identity'); const { keepPeerState } = require('./peer-state'); const { RoomOwnershipRegistry } = require('./room-ownership'); const { verifyRoomGrant, grantForWire } = require('./core/room-grant'); @@ -4601,7 +4601,13 @@ class SymNode extends EventEmitter { return; case 'cmb-encrypted': case 'control-encrypted': { - const s = st && [...st.confirmed].find((x) => x.sessionId === payload.sessionId && !x.closed); + // Any CONFIRMED session for this `from` that carries the sessionId: an admitted one, or one + // whose handshake is done and whose room door is still deciding (R2-11). A gated room's + // grant-holder sends its room-join sealed right after it confirms; routed to admitted + // 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)); 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 @@ -4849,7 +4855,9 @@ class SymNode extends EventEmitter { } _log(msg) { - if (!this._silent) logMsg(this.name, msg); + // Peer-chosen text reaches many lines (a hello's name, a relay's peer name and error text, a TXT + // record): a newline or escape sequence in it is written as an escape here, for every line (R2-18). + if (!this._silent) logMsg(this.name, logSafe(msg)); } } diff --git a/lib/wire-identity.js b/lib/wire-identity.js index 6f333b5d..f2f456b3 100644 --- a/lib/wire-identity.js +++ b/lib/wire-identity.js @@ -63,4 +63,27 @@ function wireWakeChannel(ch) { return { platform, token: token ?? undefined, environment: environment ?? undefined }; } -module.exports = { wireNodeId, wireName, wireKey, wireWakeChannel, MAX_NODE_ID, MAX_NAME }; +/** + * Text for a log line (security review R2-18). A peer chooses its own name, and its hello, relay + * announcement and TXT record carry it as given: a newline in it forged a whole log line + * ("[sym-security] ..."), and an escape sequence rewrote what a terminal showed. Every character a + * log line must not carry as itself is written as an escape instead: the C0 and C1 controls (newline, + * carriage return, escape, tab and the rest), DEL, the line and paragraph separators, and the bidi + * overrides and isolates (which reorder what a reader sees). Printable text is unchanged, so a line + * that carries none of these reads exactly as before. Applied to the whole line where a node logs. + */ +// eslint-disable-next-line no-control-regex +const LOG_UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g; +function logSafe(x) { + let s; + try { s = typeof x === 'string' ? x : String(x); } catch { s = '[unprintable]'; } + return s.replace(LOG_UNSAFE, (c) => { + const n = c.charCodeAt(0); + if (c === '\n') return '\\n'; + if (c === '\r') return '\\r'; + if (c === '\t') return '\\t'; + return n <= 0xff ? `\\x${n.toString(16).padStart(2, '0')}` : `\\u${n.toString(16).padStart(4, '0')}`; + }); +} + +module.exports = { wireNodeId, wireName, wireKey, wireWakeChannel, logSafe, MAX_NODE_ID, MAX_NAME }; diff --git a/tests/security-0.14.3-net.test.js b/tests/security-0.14.3-net.test.js new file mode 100644 index 00000000..3b170842 --- /dev/null +++ b/tests/security-0.14.3-net.test.js @@ -0,0 +1,181 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME before lib/config loads +process.env.SYM_SEMANTIC_ENCODER = process.env.SYM_SEMANTIC_ENCODER || 'off'; + +/** + * sym 0.14.3 security review R2, the network findings, each a regression test that fails on 632b95d: + * + * R2-5 LAN client-hellos were unauthenticated work with no rate: each costs a key agreement and a + * signature, and one host opening connections could keep a core busy + * R2-11 a gated room admitted nobody over a relay: a grant-holder's sealed room-join was routed to + * admitted sessions only, so it never reached the door + * R2-18 a peer-chosen name reached the log as given: a newline forged a whole line, an escape + * sequence rewrote the terminal + * R2-20 the Bonjour reconnect cache held every node-id ever advertised, re-offered every 15 s + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const net = require('net'); +const { SymNode } = require('../lib/node'); +const { BonjourDiscovery, NullDiscovery } = require('../lib/discovery'); +const { PeerSession } = require('../lib/session'); +const { loadOrCreateIdentity } = require('../lib/config'); +const { signRoomGrant } = require('../lib/core/room-grant'); +const { sendFrame } = require('../lib/frame-parser'); +const { fakeRelay } = require('./_fake-relay'); +const { memoryPipe, identity, until } = require('./_core-secure'); + +const uniq = (b) => `${b}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; +const b64 = () => crypto.randomBytes(32).toString('base64url'); +const uuid7 = () => require('../lib/config').uuidv7(); + +describe('R2-5: LAN client-hellos are rate-limited per host, and over all hosts, before any crypto', () => { + it('R2-5: a flood of client-hellos from one host reaches the node only up to its burst; the rest are closed unanswered', async () => { + const d = new BonjourDiscovery({ mdns: false }); + const port = await d.start({ nodeId: 'srv', name: 'srv', publicKey: 'pk', hostname: 'h' }, () => {}); + try { + let handed = 0; + d.on('inbound-connection', (t) => { handed++; try { t.close(); } catch { /* */ } }); + const N = 60; + const closed = []; + for (let i = 0; i < N; i++) { + closed.push(new Promise((resolve) => { + const c = net.createConnection({ host: '127.0.0.1', port }, () => { + sendFrame(c, { type: 'client-hello', protocolVersion: '2.0', room: 'r1', nodeId: uuid7(), name: 'n', identityPublicKey: b64(), e2ePublicKey: b64(), nonce: b64(), implementation: { name: 'x', version: '1' }, extensions: [] }); + }); + c.on('error', () => {}); + c.on('close', resolve); + })); + } + await Promise.all(closed); + // The burst, and what the rate refilled while the flood arrived (8 a second). + assert.ok(handed >= 32 && handed <= 36, `hellos handed to the node: ${handed} of ${N} (until 0.14.3: all of them)`); + assert.ok(d.helloRefusedCount() >= N - 36, `refused for their rate: ${d.helloRefusedCount()}`); + } finally { await d.stop(); } + }); + + it('R2-5: many hosts together are held to the bucket over all hosts; a host that kept its rate is not refused by its own bucket', () => { + const d = new BonjourDiscovery({ mdns: false }); + const now = 1_000_000; + let ok = 0; + for (let i = 0; i < 400; i++) if (d._takeHelloToken(`10.1.${i >> 8}.${i & 255}`, now)) ok++; + assert.strictEqual(ok, 256, 'the burst over all hosts'); + assert.strictEqual(d._takeHelloToken('10.9.9.9', now), false, 'a new host waits for the bucket over all hosts'); + assert.strictEqual(d._takeHelloToken('10.9.9.9', now + 1000), true, 'which refills at its rate'); + assert.strictEqual(d._takeHelloToken('::ffff:10.9.9.9', now + 1000), true, 'an IPv4-mapped address is the same host'); + assert.strictEqual(d._helloBuckets.has('10.9.9.9'), true); + assert.strictEqual(d._helloBuckets.has('::ffff:10.9.9.9'), false); + }); +}); + +describe('R2-11: a gated room admits a grant-holder over a relay; the door still decides', () => { + async function scene({ ownerDials }) { + const relay = fakeRelay(); + const names = [uniq('gr-a'), uniq('gr-b')]; + const ids = names.map((n) => ({ name: n, id: loadOrCreateIdentity(n) })).sort((x, y) => (x.id.nodeId < y.id.nodeId ? -1 : 1)); + // The smaller nodeId sends the client-hello (design D2). + const [owner, guest] = ownerDials ? ids : [ids[1], ids[0]]; + const intruderName = uniq('gr-x'); + const intruderId = loadOrCreateIdentity(intruderName); + const ROOM = 'gated-room'; + const roomOwners = [{ room: ROOM, nodeId: owner.id.nodeId, publicKey: owner.id.publicKey }]; + const grant = signRoomGrant({ room: ROOM, grantee: guest.id.nodeId, granteeKey: guest.id.publicKey, grantedBy: owner.id.nodeId }, owner.id.privateKey); + const mk = (name, extra = {}) => new SymNode({ name, silent: true, relayOnly: true, discovery: new NullDiscovery(), relay: relay.url, relayToken: 'tok-0123456789abcdef0123456789abcdef', room: ROOM, roomOwners, ...extra }); + const O = mk(owner.name); + const G = mk(guest.name, { roomGrant: grant }); + // The intruder presents the guest's grant: it binds the guest's key, not the one the intruder proves. + const X = mk(intruderName, { roomGrant: grant }); + return { relay, O, G, X, intruderId }; + } + const stop = async ({ relay, O, G, X }) => { for (const n of [O, G, X]) { try { await n.stop(); } catch { /* */ } } await relay.close(); }; + + for (const ownerDials of [true, false]) { + it(`R2-11: the owner ${ownerDials ? 'dials (its client session waits at the door)' : 'is dialled (its server session waits at the door)'}: the grant-holder is admitted both ways`, async () => { + const sc = await scene({ ownerDials }); + const { O, G, X, intruderId } = sc; + try { + await O.start(); await G.start(); + const ok = await until(() => O._peers.has(G.nodeId) && G._peers.has(O.nodeId), 6000); + assert.ok(ok, `admitted both ways over the relay (owner session stats: ${JSON.stringify(O._sessionStats.refusedByReason)})`); + assert.ok(!O._sessionStats.refusedByReason['other-session'], 'no sealed frame of a confirmed session was refused as another session\'s'); + await X.start(); + // The door still decides: a grant that binds another key admits nobody, and the refusal is + // the door's (the grant was read), not a timeout waiting for a room-join that never came. + await until(() => O._roomVerdicts && O._roomVerdicts.has(intruderId.nodeId), 4000); + const v = O._roomVerdicts && O._roomVerdicts.get(intruderId.nodeId); + assert.ok(v && v.admit === false, `the intruder is refused (${v && v.reason})`); + assert.doesNotMatch(String(v.reason), /no room-join grant presented/, `refused for its grant: ${v && v.reason}`); + assert.strictEqual(O._peers.has(intruderId.nodeId), false); + } finally { await stop(sc); } + }); + } +}); + +describe('R2-18: a peer-chosen name never reaches a log line as given', () => { + it('R2-18: a hello name carrying a newline and an escape sequence is logged escaped, on one line', async () => { + const lines = []; + const orig = console.log; + const b = new SymNode({ name: uniq('r218'), discovery: new NullDiscovery(), room: 'r1' }); + try { + console.log = (...a) => { lines.push(a.join(' ')); }; + await b.start(); + const evil = identity('x'); + const NAME = 'mallory\n[sym-security] FORGED: operator, run sym keys resolve --accept 0000\x1b[2K\u202e'; + const [tc, ts] = memoryPipe(); + b.connectTransport(ts, { role: 'server' }); + const cs = new PeerSession({ role: 'client', transport: tc, kind: 'bonjour', local: { nodeId: evil.nodeId, name: NAME, publicKey: evil.publicKey, privateKey: evil.privateKey }, room: 'r1', extensions: ['cmb-encrypted-v2'], implementation: { name: 'evil', version: '1' } }); + tc.on('message', (f) => cs.receiveWire(f)); + cs.start(); + await until(() => b._peers.has(evil.nodeId), 3000); + await until(() => lines.some((l) => l.includes('mallory')), 1000); + const named = lines.filter((l) => l.includes('mallory')); + assert.ok(named.length > 0, 'the peer was logged'); + for (const l of named) { + // eslint-disable-next-line no-control-regex + assert.doesNotMatch(l, /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/, `no control character reaches the line: ${JSON.stringify(l)}`); + assert.match(l, /mallory\\n\[sym-security\] FORGED/, 'the newline is shown as an escape'); + assert.match(l, /\\x1b\[2K/, 'the escape sequence is shown, not run'); + } + } finally { + console.log = orig; + await b.stop(); + } + }); +}); + +describe('R2-20: the Bonjour reconnect cache is bounded', () => { + const advertise = (d, nodeId, i, extra = []) => d._onServiceUp({ rawTxt: [Buffer.from(`node-id=${nodeId}`), Buffer.from('mmp=2.0'), Buffer.from('room=default'), ...extra.map((x) => Buffer.from(x))], addresses: [`10.0.${(i >> 8) & 255}.${i & 255}`], port: 1 + (i % 65000) }); + + it('R2-20: a flood of advertisements holds at most the cap; the least recently announced goes first, and a re-announced peer stays', () => { + const d = new BonjourDiscovery({ mdns: false }); + d._makeServiceHandlers({ nodeId: 'self' }); + let found = 0; + d.on('peer-found', () => found++); + const honest = uuid7(); + advertise(d, honest, 0); + const ids = []; + for (let i = 1; i <= 5000; i++) { + const id = `${crypto.randomBytes(4).toString('hex')}-0000-7000-8000-${crypto.randomBytes(6).toString('hex')}`; + ids.push(id); + advertise(d, id, i); + if (i % 100 === 0) advertise(d, honest, 0); // a live peer, announced again at every browser restart + } + assert.ok(d._bonjourPeerCache.size <= 512, `cache entries: ${d._bonjourPeerCache.size} (until 0.14.3: 5001)`); + assert.ok(d._bonjourPeerCache.has(honest), 'the re-announced peer is kept'); + assert.strictEqual(d._bonjourPeerCache.has(ids[0]), false, 'the least recently announced went'); + assert.ok(d._bonjourPeerCache.has(ids[ids.length - 1]), 'the newest is held'); + assert.ok(d._cachedPeersToReoffer().length <= 512, 'and so is what the 15 s tick re-offers'); + assert.strictEqual(found, 5051, 'every advertisement still reaches the node, which decides'); + }); + + it('R2-20: an advertisement the node never dials (no canonical nodeId, or no mmp=2.0) is not cached', () => { + const d = new BonjourDiscovery({ mdns: false }); + d._makeServiceHandlers({ nodeId: 'self' }); + for (let i = 0; i < 300; i++) advertise(d, 'x'.repeat(200) + i, i); + d._onServiceUp({ rawTxt: [Buffer.from(`node-id=${uuid7()}`), Buffer.from('room=default')], addresses: ['10.0.0.1'], port: 9 }); + assert.strictEqual(d._bonjourPeerCache.size, 0, `cached: ${d._bonjourPeerCache.size} (until 0.14.3: 301)`); + }); +}); From 7d9304444b659de473419287949c9b607e984d38 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:00:33 +0100 Subject: [PATCH 13/20] Security 0.14.3: sym-attest-v1 findings R2-4, R2-7, R2-8, R2-15, R2-16, R2-17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-4 The signature checks a node made grew with its sessions: each lane was spent before verification and the global ceiling only after. Every new gossiped statement now also takes a token of one global budget before its signature is checked, through the peer's fair share of it (rate and burst divided by the peers that sent gossip in the last 10 s), so the checks are bounded whatever the number of sessions and a flooding peer spends only its own share. The post-verification ceiling is gone (it could no longer bind). R2-7 An attester holding equivocation evidence is never evicted from the checkpoint index; when every place holds evidence a newcomer is refused (counted). Own-witness memory is never forgotten: a set leaving the LRU (or a position past the newest 256) folds into a per-attester watermark, persisted on the own-witnessed line; past 65,536 watermarks a new attester is not witnessed (counted). R2-8 aggregateAttestations weighs only attestations naming the held record's assertionId (or opts.assertionId); the rest are excluded. R2-15 A checkpoint that starts right after a held one must name its root as prev, and one ending right before a held one must have the root that one names: otherwise malformed-link, never stored or witnessed. A §5.2 conflicting copy is still kept as evidence. R2-16 A second attestation at a held (attester, seq) is refused as a fork and kept once as evidence (persisted, re-derived at restart); the attester counts as equivocated. R2-17 A witness contradicting a checkpoint of an attester known to have equivocated is refused, not muted; a witness muted for contradicting an attester is unmuted once that attester's equivocation is known. Tests that encoded the old behaviour are updated: two attestation-store cases (same-seq replacement), the gossip-lanes ceiling cases, a gossip-budget fixture with unlinked consecutive checkpoints, and the aggregate tests (which now name their assertion). --- SECURITY.md | 12 +- lib/attestation-store.js | 178 ++++++++++++++-- lib/node.js | 150 +++++++++---- tests/attestation-store.test.js | 14 +- tests/earned-authority-node.test.js | 10 +- tests/gossip-budget.test.js | 4 +- tests/gossip-lanes.test.js | 21 +- tests/security-0.14.3-attest.test.js | 306 +++++++++++++++++++++++++++ 8 files changed, 612 insertions(+), 83 deletions(-) create mode 100644 tests/security-0.14.3-attest.test.js diff --git a/SECURITY.md b/SECURITY.md index 677fc0a4..60e20bbd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -215,10 +215,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 diff --git a/lib/attestation-store.js b/lib/attestation-store.js index 954b67f0..787ac017 100644 --- a/lib/attestation-store.js +++ b/lib/attestation-store.js @@ -84,6 +84,8 @@ const ATT_LINE = 720; const CP_LINE = 448; const WIT_LINE = 448; const OWN_LINE = 2560; +/** An own-witness line that carries only a watermark (an attester whose positions were folded). */ +const WATERMARK_LINE = 192; /** archive/ keeps at most this many bytes per log unless configured (0 keeps every archive). */ const ARCHIVE_MAX_DEFAULT = 128 * 1024 * 1024; /** A rotation writes the held records in chunks of about this many characters. */ @@ -227,7 +229,26 @@ class AttestationStore { this._maxWitnessesPerPosition = opts.maxWitnessesPerPosition || 256; this._pending = new Map(); // witnessKey -> witness whose checkpoint has not arrived yet (memory only) this._maxPending = opts.maxPending || 2048; - this._ownWitnessed = new Map(); // attester -> Set(upto_seq) this node has witnessed itself + this._ownWitnessed = new Map(); // attester -> Set(upto_seq) this node has witnessed itself, most recently marked last + // OWN-WITNESS MEMORY IS NEVER FORGOTTEN (security review R2-7): a position this node witnessed and + // then forgot could be shown to it again with another root, and it would sign a second, contradicting + // witness. A set that leaves `_ownWitnessed` (its attester least recently marked, or a position past + // the newest 256) is folded into a watermark: every position at or below it counts as witnessed. + // Watermarks are never evicted; when they are full, a NEW attester is not witnessed (counted). + this._ownWatermark = new Map(); // attester -> the highest position folded out of its set + this._maxOwnWatermarks = opts.maxOwnWatermarks || 65536; + this._ownRefused = 0; // witnesses not signed because own-witness memory was full + // EQUIVOCATION EVIDENCE IS NEVER EVICTED (R2-7): an attester this node holds two conflicting + // checkpoints of keeps its place in the checkpoint index, so a flood of fresh identities cannot push + // the evidence out and have this node witness a third history. When every place is held by such an + // attester, a new attester's checkpoint is refused (`full`, counted). + this._checkpointsRefusedFull = 0; + // Two different attestations from one attester at one seq (R2-16): a fork of its hash chain, signed by + // the attester. The first held stays; the other is kept once per attester as evidence (never + // evicted, at most maxAttesters attesters; past that it is refused and counted, not kept). + this._attConflicts = new Map(); // by -> { seq, kept, other } + this._attEvidence = new Set(); // sigKey of each evidence copy (a repeat is a duplicate) + this._attEvidenceRefused = 0; this._witnessConflicts = new Map(); // witnessKey -> the other root, already reported; bounded this._fromLog = new WeakSet(); // waiting witnesses read from the live log (already in it) this._rateCalls = 0; @@ -259,7 +280,7 @@ class AttestationStore { [ATT_FILE]: this._max * ATT_LINE, // Each held position may also hold the copy that conflicted with it. [CP_FILE]: this._maxCheckpointsPerAttester * this._maxAttesters * CP_LINE * 2, - [WIT_FILE]: this._maxWitnesses * WIT_LINE + this._maxAttesters * OWN_LINE, + [WIT_FILE]: this._maxWitnesses * WIT_LINE + this._maxAttesters * OWN_LINE + this._maxOwnWatermarks * WATERMARK_LINE, }; const wholeLog = (f) => capBytes[f] + Math.max(this._rotateBytes, capBytes[f]); this._readBudget = { @@ -288,7 +309,7 @@ class AttestationStore { return { stored: false, reason: 'malformed' }; } const key = sigKey(att.sig); - if (this._seen.has(key)) return { stored: false, reason: 'duplicate' }; + if (this._seen.has(key) || this._attEvidence.has(key)) return { stored: false, reason: 'duplicate' }; // One form for everything: the signature is stored as its signer wrote it, so the chain link // (`prev` is the hash of the previous signature as written) and the checkpoint's Merkle root // are computed over that, as dedup is. Gossip refuses another spelling; a log written before @@ -297,6 +318,11 @@ class AttestationStore { att = { ...att, sig: key }; if (this._loading) this._respelled++; } + // A second, different attestation at a seq this attester's chain holds is a fork of the chain, never + // a replacement (security review R2-16): the first held stays the position's, and this one is the + // evidence, kept once per attester. + const at = this._byAttester.get(att.by)?.get(att.seq); + if (at) return this._attestationConflict(att, at, key); if (opts.ingested && !this._allowIngest(att.of, att.by, opts.now ?? Date.now())) { return { stored: false, reason: 'rate-limited' }; } @@ -332,6 +358,25 @@ class AttestationStore { return { stored: true }; } + /** @private Two attestations at one (attester, seq): keep the first, hold the other once as evidence + * (appended, so a restart re-derives it). Never evicted; past maxAttesters attesters with evidence a + * new one is refused and counted. @returns {{ stored: false, reason: 'conflict', first: boolean, kept: object }} */ + _attestationConflict(att, kept, key) { + if (this._attConflicts.has(att.by)) return { stored: false, reason: 'conflict', first: false, kept }; + if (this._attConflicts.size >= this._maxAttesters) { this._attEvidenceRefused++; return { stored: false, reason: 'conflict', first: false, kept }; } + this._attConflicts.set(att.by, { seq: att.seq, kept, other: att }); + this._attEvidence.add(key); + this._keep(ATT_FILE, att); + this._maybeRotate(ATT_FILE); + return { stored: false, reason: 'conflict', first: true, kept }; + } + + /** The attestation-chain fork held as evidence against `by` ({ seq, kept, other }), or null. */ + attestationConflictOf(by) { + const c = this._attConflicts.get(by); + return c ? { seq: c.seq, kept: c.kept, other: c.other } : null; + } + /** Audit trail for a gated CMB: every recorded attestation about it. */ byCmb(of) { const m = this._byCmb.get(of); @@ -344,8 +389,8 @@ class AttestationStore { return chain ? [...chain.values()].sort((a, b) => a.seq - b.seq) : []; } - /** Whether an attestation with this signature is held, however the signature is spelled. */ - has(sig) { return this._seen.has(sigKey(sig)); } + /** Whether an attestation with this signature is held (or kept as evidence), however it is spelled. */ + has(sig) { const k = sigKey(sig); return this._seen.has(k) || this._attEvidence.has(k); } size() { return this._seen.size; } /** @@ -394,8 +439,9 @@ class AttestationStore { if (existing && sameCheckpoint(existing, cp)) return { stored: false, reason: 'duplicate' }; const ev = this._conflicted.get(cp.by); if (ev && [...ev.values()].some((c) => sameCheckpoint(c, cp))) return { stored: false, reason: 'duplicate' }; - // After a conflict this attester's further checkpoints are dropped (which bounds the evidence). - if (ev) return { stored: false, reason: 'attester-equivocated' }; + // After a conflict this attester's further checkpoints are dropped (which bounds the evidence). A + // fork of its attestation chain (R2-16) is evidence it signed two histories too. + if (ev || this._attConflicts.has(cp.by)) return { stored: false, reason: 'attester-equivocated' }; // Two checkpoints from one attester whose ranges overlap, or that name the same prev, prove two // histories, whatever boundaries each was cut at. The first held stays; this one is the evidence. if (m) { @@ -427,15 +473,18 @@ class AttestationStore { dropped = true; } if (!m) { - m = new Map(); if (this._checkpoints.size >= this._maxAttesters) { - const [victim, held] = this._checkpoints.entries().next().value; // the attester updated least recently + // The attester updated least recently gives up its place, unless it holds equivocation evidence + // (R2-7): that is never evicted, and when every place holds some, the newcomer is refused. Its + // own-witness memory stays (it is never forgotten: see _ownWatermark). + const victim = this._evictableAttester(); + if (victim === undefined) { this._checkpointsRefusedFull++; return { stored: false, reason: 'full' }; } + const held = this._checkpoints.get(victim); for (const [seq, old] of held) { this._unkeep(CP_FILE, old); this._dropWitnessesOf(victim, seq); } this._checkpoints.delete(victim); - for (const seq of [...(this._conflicted.get(victim)?.keys() || [])]) this._dropConflict(victim, seq); - this._ownWitnessed.delete(victim); dropped = true; } + m = new Map(); } m.set(cp.upto_seq, cp); this._checkpoints.delete(cp.by); @@ -462,7 +511,7 @@ class AttestationStore { // This node's own witness is remembered before anything else is decided, so one that waits or // is refused still counts as signed (witness once, across restarts). const own = !!this._selfId && w.by === this._selfId; - const ownKnown = own && !!this._ownWitnessed.get(w.attester)?.has(w.upto_seq); + const ownKnown = own && this._ownKnown(w.attester, w.upto_seq); if (own) this._markOwnWitness(w.attester, w.upto_seq); const existing = this.witnessAt(w.attester, w.upto_seq, w.by); if (existing) { @@ -476,6 +525,10 @@ class AttestationStore { // (`lead`: refused, and said), not proof of anything. if (w.from_seq !== undefined) { const a = this.witnessAssessment(w); + // An attester this node holds equivocation evidence against signed more than one history, so a + // witness of another root may well have held an attester-signed checkpoint this node does not + // (security review R2-17): it is refused, never counted against the witness. + if (a.evidenceAgainstWitness && this.attesterEquivocated(w.attester)) return { stored: false, reason: 'attester-equivocated', keptRoot: a.kept.root, first: this._noteWitnessConflict(w) }; if (a.evidenceAgainstWitness) return { stored: false, reason: 'disagrees', keptRoot: a.kept.root, first: this._noteWitnessConflict(w) }; if (a.lead) return { stored: false, reason: 'lead', first: this._noteWitnessConflict(w) }; } @@ -557,12 +610,29 @@ class AttestationStore { if (byPos.size === 0) this._witnesses.delete(attester); } - /** @private Remember a checkpoint this node witnessed itself; kept per attester, newest 256. */ + /** @private The attester updated least recently that holds no equivocation evidence, or undefined. */ + _evictableAttester() { + for (const by of this._checkpoints.keys()) if (!this._conflicted.has(by) && !this._attConflicts.has(by)) return by; + return undefined; + } + + /** + * @private Remember a checkpoint this node witnessed itself: per attester the newest 256 positions, + * for at most maxAttesters attesters, and what leaves that window is folded into the attester's + * watermark, never forgotten (R2-7). @returns {boolean} false when the memory is full and `attester` is + * new to it: nothing was remembered, and this node must not sign that witness. + */ _markOwnWitness(attester, upto_seq) { + if (this._ownKnown(attester, upto_seq)) return true; let set = this._ownWitnessed.get(attester); if (!set) { + if (!this._ownRoom(attester)) { this._ownRefused++; return false; } + if (this._ownWitnessed.size >= this._maxAttesters) { + const [old, oldSet] = this._ownWitnessed.entries().next().value; // least recently marked + if (oldSet.size) this._raiseWatermark(old, Math.max(...oldSet)); + this._ownWitnessed.delete(old); + } set = new Set(); - if (this._ownWitnessed.size >= this._maxAttesters) this._ownWitnessed.delete(this._ownWitnessed.keys().next().value); } this._ownWitnessed.delete(attester); this._ownWitnessed.set(attester, set); // most recently marked last @@ -571,7 +641,49 @@ class AttestationStore { let oldest = Infinity; for (const k of set) if (k < oldest) oldest = k; set.delete(oldest); + this._raiseWatermark(attester, oldest); } + return true; + } + + /** @private Whether own-witness memory can take `attester`: it is known to it, or a set or a + * watermark is free (a set leaving to make room needs a watermark place, unless it has one). */ + _ownRoom(attester) { + if (this._ownWitnessed.has(attester) || this._ownWatermark.has(attester)) return true; + if (this._ownWatermark.size < this._maxOwnWatermarks) return true; + if (this._ownWitnessed.size < this._maxAttesters) return true; + // Both full: the set that would leave must already have a watermark to fold into. + return this._ownWatermark.has(this._ownWitnessed.keys().next().value); + } + + /** @private Every position of `attester` at or below `upto` counts as witnessed by this node. */ + _raiseWatermark(attester, upto) { + const w = this._ownWatermark.get(attester); + if (w === undefined || upto > w) this._ownWatermark.set(attester, upto); + } + + /** @private Whether this node's own-witness memory holds (attester, upto_seq). */ + _ownKnown(attester, upto_seq) { + if (this._ownWitnessed.get(attester)?.has(upto_seq)) return true; + const w = this._ownWatermark.get(attester); + return w !== undefined && upto_seq <= w; + } + + /** + * Before this node signs a witness for `attester`: whether its own-witness memory, which is never + * forgotten, can remember it. @returns {boolean} false when the memory is full and the attester new + * to it (counted): then this node signs nothing for it. + */ + canRememberOwnWitness(attester) { + if (this._ownRoom(attester)) return true; + this._ownRefused++; + return false; + } + + /** What the never-evicted memories refused since start: checkpoints of new attesters (every place + * held evidence), witnesses this node did not sign (own memory full), attestation forks not kept. */ + refusedReport() { + return { checkpointsFull: this._checkpointsRefusedFull, ownWitnessFull: this._ownRefused, attestationEvidenceFull: this._attEvidenceRefused }; } /** @private Remember that a second root was refused for a held position (by, upto_seq), with the @@ -601,17 +713,31 @@ class AttestationStore { /** Whether this node holds evidence that `by` signed two histories (sym-attest-v1 §5.2): it then * witnesses none of its checkpoints and drops its further ones unverified. */ - attesterEquivocated(by) { return this._conflicted.has(by); } + attesterEquivocated(by) { return this._conflicted.has(by) || this._attConflicts.has(by); } /** The link checks of a chained checkpoint (§5.2) against what is held: not reversed, prev genesis * exactly when fromSeq is 1, and when the checkpoint its prev names is held, starting right after - * it. A failure is malformed, never evidence. */ + * it. And it must fit the chain held (security review R2-15): one starting right after a held + * checkpoint names that checkpoint's root as its prev, and one ending right before a held checkpoint + * has the root that checkpoint names as its prev. A checkpoint that conflicts with one held (§5.2: + * overlapping ranges, or the same prev) is not judged by that rule: it is the evidence, and is never + * discarded as malformed. A failure is malformed, never evidence. */ linkValid(cp) { if (!isChained(cp)) return true; const m = this._checkpoints.get(cp.by); let prevCp = null; - if (m && cp.prev !== 'genesis') for (const h of m.values()) if (isChained(h) && h.root === cp.prev) { prevCp = h; break; } - return checkpointLinkValid(cp, prevCp); + let conflicts = false; + let unlinked = false; + if (m) { + for (const h of m.values()) { + if (!isChained(h)) continue; + if (!prevCp && cp.prev !== 'genesis' && h.root === cp.prev) prevCp = h; + if (checkpointConflict(h, cp).length) { conflicts = true; continue; } + if ((h.upto_seq + 1 === cp.from_seq && h.root !== cp.prev) || (cp.upto_seq + 1 === h.from_seq && h.prev !== cp.root)) unlinked = true; + } + } + if (!checkpointLinkValid(cp, prevCp)) return false; + return conflicts || !unlinked; } /** @@ -669,7 +795,7 @@ class AttestationStore { /** Whether `by` has witnessed (attester, upto_seq) already, in any copy. For this node itself it * also counts a witness its index has since dropped. */ hasWitnessed(attester, upto_seq, by) { - if (this._selfId && by === this._selfId && this._ownWitnessed.get(attester)?.has(upto_seq)) return true; + if (this._selfId && by === this._selfId && this._ownKnown(attester, upto_seq)) return true; return this.witnessSeen(attester, upto_seq, by) !== null; } @@ -748,6 +874,7 @@ class AttestationStore { * not verified, and a forged one can only stop this node from signing a witness. */ _restoreOwn(o) { if (!this._selfId || o.by !== this._selfId || !o.attester || !Array.isArray(o.positions)) return; + if (isPosition(o.upto) && (this._ownWatermark.has(o.attester) || this._ownWatermark.size < this._maxOwnWatermarks)) this._raiseWatermark(o.attester, o.upto); for (const p of o.positions.slice(-256)) if (isPosition(p)) this._markOwnWitness(o.attester, p); this._held[WIT_FILE] += this._replayBytes; } @@ -829,6 +956,12 @@ class AttestationStore { * _heldRecords(file) { if (file === ATT_FILE) { for (const [sig, { of }] of this._order) { const a = this._byCmb.get(of)?.get(sig); if (a) yield a; } + // Each chain fork held as evidence, after every held attestation: the kept copy first when it is no + // longer held, so a replay keeps it and re-derives the conflict from the other. + for (const c of this._attConflicts.values()) { + if (!this._seen.has(sigKey(c.kept.sig))) yield c.kept; + yield c.other; + } } else if (file === CP_FILE) { // Attesters least recently updated first; each conflicting copy after every kept one, so a // replay keeps the first and re-derives the conflict. @@ -877,7 +1010,12 @@ class AttestationStore { // This node's own-witness memory, one line per attester, so a restart knows what it signed // even when the witness itself is no longer held. for (const [attester, set] of this._ownWitnessed) { - add(JSON.stringify({ type: OWN_WITNESSED, by: this._selfId, attester, positions: [...set].sort((x, y) => x - y) }) + '\n'); + const upto = this._ownWatermark.get(attester); + add(JSON.stringify({ type: OWN_WITNESSED, by: this._selfId, attester, positions: [...set].sort((x, y) => x - y), ...(upto !== undefined ? { upto } : {}) }) + '\n'); + } + // The watermarks of attesters whose sets left (R2-7): a 0.14.2 reader takes no position from them. + for (const [attester, upto] of this._ownWatermark) { + if (!this._ownWitnessed.has(attester)) add(JSON.stringify({ type: OWN_WITNESSED, by: this._selfId, attester, positions: [], upto }) + '\n'); } } if (chunk) written += writeAll(fd, Buffer.from(chunk)); diff --git a/lib/node.js b/lib/node.js index c9d60b80..2cfa7da4 100644 --- a/lib/node.js +++ b/lib/node.js @@ -135,13 +135,25 @@ const GOSSIP_BURST = 10000; * Until 0.14 it was kept per connection lane, because the id a sender declared was free to claim; * a proven id costs a completed handshake, and the per-connection lanes — an identity workaround — * are gone. A peer seen for the first time starts with GOSSIP_NEW_LANE tokens and earns the rest at - * the rate, so minting identities buys a hundred checks each, not a burst each. And every peer draws - * on one ceiling as well, GOSSIP_GLOBAL_PER_SECOND after a burst of GOSSIP_GLOBAL_BURST, so many ids - * together buy no more than that: 4,000 a second is six times the busy room's 640 new statements a - * second in all. Under a flood from many ids at once the ceiling can drop honest frames too (a copy - * may still come by another peer later); what it cannot do is spend more than the ceiling's checks. + * the rate, so minting identities buys a hundred checks each, not a burst each. + * + * ONE GLOBAL WORK BUDGET, SPENT BEFORE THE CHECK, SHARED FAIRLY (security review R2-4). Each lane was + * spent before verification and the ceiling over all of them only after, so the signature checks a + * node made grew with the number of sessions: K sessions bought K × 2,000 a second. Now every new + * statement also takes a token of one global bucket, GOSSIP_GLOBAL_PER_SECOND after a burst of + * GOSSIP_GLOBAL_BURST, BEFORE its signature is checked, so the checks are bounded whatever the number of + * sessions: 4,000 a second is six times the busy room's 640 new statements a second in all. So that no + * peer, forging or not, can spend the others' part of it, each peer takes it only through its fair + * share: a bucket refilled at GOSSIP_GLOBAL_PER_SECOND / n and holding GOSSIP_GLOBAL_BURST / n (at least + * GOSSIP_SHARE_MIN_BURST), n being the peers that sent gossip in the last GOSSIP_ACTIVE_MS. The shares' + * rates add up to the global rate, so a flooding peer spends its own share and nobody else's; a peer + * minting identities buys a share per identity it proves, never more than the global bucket. */ const GOSSIP_NEW_LANE = 100; +/** The window in which a peer that sent gossip counts as one of the n the global budget is shared by. */ +const GOSSIP_ACTIVE_MS = 10_000; +/** A fair share holds at least this much, so the costliest statement (STATEMENT_COST_MAX, 33) fits. */ +const GOSSIP_SHARE_MIN_BURST = 64; /** A signature longer than this is not one (an Ed25519 signature is 86 characters of base64url): it is * refused as malformed before anything decodes or hashes it. */ const SIG_MAX_CHARS = 128; @@ -509,6 +521,8 @@ class SymNode extends EventEmitter { this._gossipGlobalBurst = gb.globalBurst || GOSSIP_GLOBAL_BURST; this._gossipBuckets = new Map(); // proven peer nodeId -> { tokens, at }, least recently active first this._gossipGlobal = { tokens: this._gossipGlobalBurst, at: Date.now() }; + this._gossipShares = new Map(); // proven peer nodeId -> its fair share of the global bucket (R2-4) + this._gossipActive = new Map(); // proven peer nodeId -> when it last sent gossip, least recent first // Each attester's rate of new checkpoints taken (see CHECKPOINTS_PER_SECOND); `opts.checkpointRate` // ({ perSecond, burst }) changes it. this._checkpointRate = (opts.checkpointRate && opts.checkpointRate.perSecond) || CHECKPOINTS_PER_SECOND; @@ -1296,11 +1310,23 @@ class SymNode extends EventEmitter { * validator 2, admin and anchor 4): everyone counts, earned rank counts more. The * overall and per-CAT7-category verdicts are weighted tallies; `confidence` is the dominant * verdict's share of total weight. + * + * ONE ASSERTION, NOT ONE COGNITION KEY (security review R2-8; sym-attest-v1 §6: a receiver that weighs + * an attestation finds the attested record by `assertionId`). Records of different authors, or of one + * author with other signed application bytes, share a cognition key; only attestations naming the + * assertion are weighed: the held record's under `cmbKey`, or `opts.assertionId`. Any other is + * excluded (`other-assertion`), and with no assertion to match, every one is (`assertion-unknown`). * @param {string} cmbKey — the gated CMB key (`of`) - * @returns {{ of: string, total: number, weight: number, byRole: object, overall: object, - * categories: object, mismatches: object[], excluded: object[] }} + * @param {{ assertionId?: string }} [opts] — the assertion to weigh, when this node does not hold the + * record (or to weigh another assertion under the same key) + * @returns {{ of: string, assertionId: string|null, total: number, weight: number, byRole: object, + * overall: object, categories: object, mismatches: object[], excluded: object[] }} */ - aggregateAttestations(cmbKey) { + aggregateAttestations(cmbKey, opts = {}) { + let assertionId = typeof opts.assertionId === 'string' && opts.assertionId ? opts.assertionId : null; + if (!assertionId) { + try { const e = this._store.get(cmbKey); const a = e && e.cmb && e.cmb.metadata && e.cmb.metadata.assertionId; if (typeof a === 'string' && a) assertionId = a; } catch { /* none held */ } + } const atts = this._attestations.byCmb(cmbKey); const overall = Object.create(null); // verdict -> summed weight const categories = Object.create(null); // cat7 category -> { verdict -> weight } @@ -1311,6 +1337,9 @@ class SymNode extends EventEmitter { let weight = 0; for (const att of atts) { + // (0) the assertion: an attestation about another record under this key is not a vote on this one. + if (!assertionId) { excluded.push({ by: att.by, reason: 'assertion-unknown' }); continue; } + if (att.assertionId !== assertionId) { excluded.push({ by: att.by, reason: 'other-assertion' }); continue; } // (1) signature gate — an attestation we cannot verify is evidence, not a vote. const key = this._identityKey(att.by); if (!key) { excluded.push({ by: att.by, reason: 'unknown-key' }); continue; } @@ -1333,6 +1362,7 @@ class SymNode extends EventEmitter { return { of: cmbKey, + assertionId, total, weight, byRole, @@ -1467,9 +1497,9 @@ class SymNode extends EventEmitter { const key = this._identityKey(cp.by); if (!key) return { ok: false, reason: 'unknown-attester-key' }; if (this._relayMuted(fromPeerId, cp.by)) return { ok: false, reason: 'relayed-signer-muted' }; - if (!this._gossipBudget(fromPeerId, 'checkpoint', cp.by)) return { ok: false, reason: 'over-budget' }; + const spent = this._gossipAdmit(fromPeerId, 'checkpoint', cp.by); + if (spent) return { ok: false, reason: spent }; if (!verifyCheckpoint(cp, key).valid) { this._penaliseForgery(session, 'checkpoint', { author: cp.by, key }); return { ok: false, reason: 'bad-signature' }; } - if (!this._gossipCeiling(fromPeerId, 'checkpoint', cp.by)) return { ok: false, reason: 'over-ceiling' }; // A verified gossip statement does not make its signer's binding durable (security review D: // only an admitted record, a grant in effect or a pin does; a fresh identity signs statements free). // A new position is taken at most at its attester's rate, whichever peer brings it: past that it is @@ -1487,6 +1517,8 @@ class SymNode extends EventEmitter { // The conflicting copy is relayed once, as evidence, so the room learns of the equivocation (#27); // a witness this node already signed for the first root stands. this._gossipToRoster({ type: ATTEST_FRAME.checkpoint, checkpoint: toWireCheckpoint(cp) }, fromPeerId); + // The equivocator is the attester: a witness muted for contradicting its checkpoint is heard again. + this._unmuteWitnessesOf(cp.by); } if (r.stored) { this._gossipToRoster({ type: ATTEST_FRAME.checkpoint, checkpoint: toWireCheckpoint(cp) }, fromPeerId); // relay-once @@ -1497,9 +1529,9 @@ class SymNode extends EventEmitter { /** * Spend one token of the PROVEN peer a NEW gossiped statement arrived from, before its signature is - * checked (see GOSSIP_PER_SECOND and GOSSIP_NEW_LANE). Only the peer's own lane is spent before the - * check (security review D: no shared budget is spent before verification), so a peer flooding - * forgeries spends its own budget and nobody else's. A forgery in its own name ends its session + * checked (see GOSSIP_PER_SECOND and GOSSIP_NEW_LANE), with its fair share of the global budget + * (R2-4: `_gossipAdmit`), so a peer flooding forgeries spends its own lane and share and nobody + * else's, and the checks in all are bounded whatever the number of sessions. A forgery in its own name ends its session * (`_penaliseForgery`); a statement it relays for another signer that fails here is dropped without * blaming it (its binding for that signer may differ), and past RELAY_FAILURES_MAX in a minute its * statements for that signer are dropped unverified (`_relayMuted`). The lane refills, so a peer that stops flooding is heard again within @@ -1510,11 +1542,45 @@ class SymNode extends EventEmitter { * @private */ _gossipBudget(peerId, type, author, cost = 1) { + return this._gossipAdmit(peerId, type, author, cost) === null; + } + + /** + * The budget a new statement spends before its signature is checked: `cost` of the peer's lane, of + * its fair share of the global bucket, and of the global bucket (R2-4: see GOSSIP_NEW_LANE). All or + * nothing: a statement refused spends nothing. O(1) amortised. + * @returns {null|'over-budget'|'over-ceiling'} null when the statement may be verified + * @private + */ + _gossipAdmit(peerId, type, author, cost = 1) { const now = this._gossipClock(); const b = bucketOf(this._gossipBuckets, peerId, this._gossipRate, this._gossipBurst, this._gossipNewLane, now, GOSSIP_MAX_PEERS); - if (b.tokens >= cost) { b.tokens -= cost; return true; } - this._noteDrop('gossip-over-budget', peerId, { frame: type, author }); - return false; + if (b.tokens < cost) { this._noteDrop('gossip-over-budget', peerId, { frame: type, author }); return 'over-budget'; } + const n = this._gossipActivePeers(peerId, now); + const burst = Math.max(GOSSIP_SHARE_MIN_BURST, this._gossipGlobalBurst / n); + const share = bucketOf(this._gossipShares, peerId, this._gossipGlobalRate / n, burst, Math.min(this._gossipNewLane, burst), now, GOSSIP_MAX_PEERS); + const all = refill(this._gossipGlobal, this._gossipGlobalRate, this._gossipGlobalBurst, now); + if (share.tokens < cost || all.tokens < cost) { + // Said in one report for all peers, not one per id: ids are free to mint. + this._noteDrop('gossip-over-ceiling', '*', { frame: type, author, peer: peerId }); + return 'over-ceiling'; + } + b.tokens -= cost; + share.tokens -= cost; + all.tokens -= cost; + return null; + } + + /** @private The peers that sent gossip in the last GOSSIP_ACTIVE_MS, `peerId` (now) included. */ + _gossipActivePeers(peerId, now) { + const a = this._gossipActive; + a.delete(peerId); + a.set(peerId, now); + for (const [id, at] of a) { + if (now - at <= GOSSIP_ACTIVE_MS && a.size <= GOSSIP_MAX_PEERS) break; + a.delete(id); + } + return a.size; } /** @@ -1552,22 +1618,6 @@ class SymNode extends EventEmitter { b.tokens -= amount; } - /** - * The shared ceiling over every peer, spent only AFTER a statement's signature verified (security - * review D): what it bounds is the work verified statements cause (storing, relaying, witnessing), - * which only signers whose keys this node holds can buy, never the signature checks forgeries cost. - * Past it the verified statement is dropped unstored and unmarked, so a later copy is taken. - * @returns {boolean} whether the verified statement may be taken - * @private - */ - _gossipCeiling(peerId, type, author) { - const all = refill(this._gossipGlobal, this._gossipGlobalRate, this._gossipGlobalBurst, this._gossipClock()); - if (all.tokens >= 1) { all.tokens -= 1; return true; } - // Said in one report for all peers, not one per id: ids are free to mint. - this._noteDrop('gossip-over-ceiling', '*', { frame: type, author, peer: peerId }); - return false; - } - /** * A frame on a proven session carried a signature that does not verify (security review D). Whose * fault that is depends on who the statement names as its signer, because verification uses THIS @@ -1776,15 +1826,26 @@ class SymNode extends EventEmitter { if (!until) return false; if (Date.now() < until) return true; this._mutedWitnesses.delete(by); + if (this._muteCause) this._muteCause.delete(by); return false; } - /** @private Mute the witnesses `by` signs for WITNESS_MUTE_MS; at most 1,024 witnesses, oldest first out. */ - _muteWitness(by) { + /** @private Mute the witnesses `by` signs for WITNESS_MUTE_MS, for contradicting a checkpoint of + * `attester`; at most 1,024 witnesses, oldest first out. */ + _muteWitness(by, attester = null) { if (!this._mutedWitnesses) this._mutedWitnesses = new Map(); + if (!this._muteCause) this._muteCause = new Map(); this._mutedWitnesses.delete(by); - if (this._mutedWitnesses.size >= 1024) this._mutedWitnesses.delete(this._mutedWitnesses.keys().next().value); + if (this._mutedWitnesses.size >= 1024) { const old = this._mutedWitnesses.keys().next().value; this._mutedWitnesses.delete(old); this._muteCause.delete(old); } this._mutedWitnesses.set(by, Date.now() + WITNESS_MUTE_MS); + this._muteCause.set(by, attester); + } + + /** @private `attester` is now known to have signed two histories (R2-17): a witness muted for + * contradicting one of its checkpoints may have held the other, so its mute is lifted. */ + _unmuteWitnessesOf(attester) { + if (!this._muteCause) return; + for (const [by, a] of [...this._muteCause]) if (a === attester) { this._muteCause.delete(by); this._mutedWitnesses?.delete(by); } } /** Countersign a checkpoint (witness) and gossip the witness. @private */ @@ -1796,6 +1857,9 @@ class SymNode extends EventEmitter { // Once per checkpoint, across restarts: a second signing is a second copy of the same // statement, and two copies in circulation were relayed back and forth without end. if (this._attestations.hasWitnessed(cp.by, cp.upto_seq, this.nodeId)) return; + // What this node witnessed is never forgotten (R2-7: a forgotten witness could be signed again for + // another root); when that memory is full, a new attester is not witnessed at all. + if (!this._attestations.canRememberOwnWitness(cp.by)) { this._noteDrop('own-witness-memory-full', '*', { frame: 'witness', author: cp.by }); return; } const w = { type: 'witness', attester: cp.by, @@ -1838,9 +1902,9 @@ class SymNode extends EventEmitter { const key = this._identityKey(w.by); if (!key) return { ok: false, reason: 'unknown-witness-key' }; if (this._relayMuted(fromPeerId, w.by)) return { ok: false, reason: 'relayed-signer-muted' }; - if (!this._gossipBudget(fromPeerId, 'witness', w.by)) return { ok: false, reason: 'over-budget' }; + const spent = this._gossipAdmit(fromPeerId, 'witness', w.by); + if (spent) return { ok: false, reason: spent }; if (!verifyWitness(w, key).valid) { this._penaliseForgery(session, 'witness', { author: w.by, key }); return { ok: false, reason: 'bad-signature' }; } - if (!this._gossipCeiling(fromPeerId, 'witness', w.by)) return { ok: false, reason: 'over-ceiling' }; // A verified gossip statement does not make its signer's binding durable (security review D: // only an admitted record, a grant in effect or a pin does; a fresh identity signs statements free). const r = this._attestations.recordWitness(w); @@ -1855,7 +1919,7 @@ class SymNode extends EventEmitter { // A verified witness for another range or root than the attester-signed checkpoint held at that // position counts against the witness, never the attester (sym-attest-v1 §5.3, update 1): it is // said once, counted, and the witness is muted for WITNESS_MUTE_MS. - this._muteWitness(w.by); + this._muteWitness(w.by, w.attester); if (r.first) { this._log(`[sym-security] witness ${String(w.by).slice(0, 8)} for ${String(w.attester).slice(0, 8)} names ${w.from_seq}..${w.upto_seq} root ${String(w.root).slice(0, 12)}, not the attester's checkpoint held there (root ${String(r.keptRoot).slice(0, 12)}): its witnesses are dropped for ${WITNESS_MUTE_MS / 60000} minutes`); this.emit('metric', { type: 'witness-contradicts-checkpoint', attester: w.attester, upto_seq: w.upto_seq, witness: w.by, keptRoot: r.keptRoot, otherRoot: w.root }); @@ -1945,17 +2009,23 @@ class SymNode extends EventEmitter { const attesterKey = this._identityKey(att.by); if (!attesterKey) return { ok: false, reason: 'unknown-attester-key' }; if (this._relayMuted(fromPeerId, att.by)) return { ok: false, reason: 'relayed-signer-muted' }; - if (!this._gossipBudget(fromPeerId, 'attestation', att.by)) return { ok: false, reason: 'over-budget' }; + const spent = this._gossipAdmit(fromPeerId, 'attestation', att.by); + if (spent) return { ok: false, reason: spent }; const v = verifyAttestation(att, attesterKey); if (!v.valid) { if (!v.error || v.error === 'bad-signature') this._penaliseForgery(session, 'attestation', { author: att.by, key: attesterKey }); return { ok: false, reason: v.error || 'bad-signature' }; } - if (!this._gossipCeiling(fromPeerId, 'attestation', att.by)) return { ok: false, reason: 'over-ceiling' }; // A verified gossip statement does not make its signer's binding durable (security review D: // only an admitted record, a grant in effect or a pin does; a fresh identity signs statements free). // Record (rate-limited as ingested). Relay-once only on first sight. const r = this._attestations.record(att, { ingested: true }); + if (r.reason === 'conflict' && r.first) { + // Two attestations signed at one seq of this attester's chain (R2-16): a fork, kept as evidence. + this._log(`[sym-security] conflicting attestation from ${String(att.by).slice(0, 8)} at seq ${att.seq}: the first held stays, this one is kept as evidence; this attester is no longer witnessed`); + this.emit('metric', { type: 'attestation-conflict', kind: 'attestation', attester: att.by, seq: att.seq }); + this._unmuteWitnessesOf(att.by); + } if (r.stored) { // Never relayed when the record it attests is a directed one this node holds (#27): a peer that // signed it should not have; this node does not spread it. diff --git a/tests/attestation-store.test.js b/tests/attestation-store.test.js index 1d5dee5f..8f401a4b 100644 --- a/tests/attestation-store.test.js +++ b/tests/attestation-store.test.js @@ -95,7 +95,8 @@ describe('AttestationStore — index by CMB + by attester chain', () => { assert.strictEqual(st.record({ of: 'c', by: 'A', seq: 2, prev: 'x', sig: 'i2' }, { ingested: true, now }).stored, true); assert.deepStrictEqual(st.record({ of: 'c', by: 'A', seq: 3, prev: 'y', sig: 'i3' }, { ingested: true, now }), { stored: false, reason: 'rate-limited' }); // a different (of,by) is independent - assert.strictEqual(st.record({ of: 'c2', by: 'A', seq: 1, prev: 'genesis', sig: 'i4' }, { ingested: true, now }).stored, true); + // (its own seq: two attestations at one seq of one chain are a fork, never stored side by side: R2-16) + assert.strictEqual(st.record({ of: 'c2', by: 'A', seq: 10, prev: 'v', sig: 'i4' }, { ingested: true, now }).stored, true); // own (non-ingested) output is never rate-limited assert.strictEqual(st.record({ of: 'c', by: 'A', seq: 9, prev: 'z', sig: 'own1' }, { ingested: false, now }).stored, true); // window slides: after it elapses, ingest is allowed again @@ -457,11 +458,14 @@ describe('AttestationStore — the cap is shared out by attester', () => { assert.strictEqual(st.size(), 4); }); - it('two attestations at one position: dropping the older leaves the newer in the chain', () => { + it('two attestations at one position: the first stays, the other is evidence of a fork, never a replacement (R2-16)', () => { const st = new AttestationStore({ max: 2 }); st.record({ of: 'c1', by: 'A', seq: 1, prev: 'genesis', sig: 'old' }); - st.record({ of: 'c2', by: 'A', seq: 1, prev: 'genesis', sig: 'restarted' }); - st.record({ of: 'c3', by: 'A', seq: 2, prev: chainHash('restarted'), sig: 's2' }); // drops 'old' - assert.deepStrictEqual(st.chainOf('A').map((a) => a.sig), ['restarted', 's2']); + assert.deepStrictEqual(st.record({ of: 'c2', by: 'A', seq: 1, prev: 'genesis', sig: 'restarted' }).reason, 'conflict'); + st.record({ of: 'c3', by: 'A', seq: 2, prev: chainHash('old'), sig: 's2' }); + assert.deepStrictEqual(st.chainOf('A').map((a) => a.sig), ['old', 's2']); + assert.strictEqual(st.attestationConflictOf('A').other.sig, 'restarted'); + assert.strictEqual(st.attesterEquivocated('A'), true); + assert.strictEqual(st.has('restarted'), true, 'a repeat of the evidence is a duplicate'); }); }); diff --git a/tests/earned-authority-node.test.js b/tests/earned-authority-node.test.js index 7ec2505e..d98921b6 100644 --- a/tests/earned-authority-node.test.js +++ b/tests/earned-authority-node.test.js @@ -27,8 +27,10 @@ function kp() { pub: publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('base64url'), }; } +/** The assertion the attestations below name (an aggregate weighs one assertion, never a cognition key: R2-8). */ +const AGG_ASSERTION = `asrt-${'a'.repeat(64)}`; function att(by, role, verdict, categories, priv) { - const a = { of: 'cmb-agg', by, at: Date.now(), roster: 'g', method: 'heuristic', verdict, categories, role, seq: 1, prev: null }; + const a = { of: 'cmb-agg', assertionId: AGG_ASSERTION, by, at: Date.now(), roster: 'g', method: 'heuristic', verdict, categories, role, seq: 1, prev: null }; return signAttestation(a, priv); } const uniq = (b) => `${b}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; @@ -176,7 +178,7 @@ describe('aggregateAttestations: weights judged against the in-force set when ap node._attestations.record(att(O.nodeId, 'anchor', 'rejected', { focus: 'reject' }, O.priv)); node._attestations.record(att(U.nodeId, 'participant', 'aligned', { focus: 'admit' }, U.priv)); - const agg = node.aggregateAttestations('cmb-agg'); + const agg = node.aggregateAttestations('cmb-agg', { assertionId: AGG_ASSERTION }); assert.strictEqual(agg.total, 4); assert.strictEqual(agg.weight, 8, '4 + 2 + 1 + 1'); assert.deepStrictEqual(agg.byRole, { participant: 2, issuer: 0, validator: 1, admin: 0, anchor: 1 }); @@ -188,7 +190,7 @@ describe('aggregateAttestations: weights judged against the in-force set when ap // The validator's grant revoked: the same attestations weigh as the set stands now. node.revoke([statementId(gV)]); - const after = node.aggregateAttestations('cmb-agg'); + const after = node.aggregateAttestations('cmb-agg', { assertionId: AGG_ASSERTION }); assert.strictEqual(after.weight, 7, '4 + 1 + 1 + 1: no role "at the time" is kept'); assert.strictEqual(after.mismatches.length, 2, 'the validator\'s stamp is now an over-claim'); } finally { cleanup(name); } @@ -202,7 +204,7 @@ describe('aggregateAttestations: weights judged against the in-force set when ap const a = att(P.nodeId, 'participant', 'aligned', { focus: 'admit' }, P.priv); a.verdict = 'rejected'; node._attestations.record(a); - const agg = node.aggregateAttestations('cmb-agg'); + const agg = node.aggregateAttestations('cmb-agg', { assertionId: AGG_ASSERTION }); assert.strictEqual(agg.total, 0); assert.deepStrictEqual(agg.excluded, [{ by: P.nodeId, reason: 'bad-signature' }]); } finally { cleanup(name); } diff --git a/tests/gossip-budget.test.js b/tests/gossip-budget.test.js index 9bcca9c2..cdf08e7f 100644 --- a/tests/gossip-budget.test.js +++ b/tests/gossip-budget.test.js @@ -176,7 +176,9 @@ describe('gossip budget — only new statements spend it', () => { it('a checkpoint older than every position held is dropped before it', () => { withNode(1, ({ node, keys: [A] }) => { - for (let n = 1; n <= 32; n++) ingest(node, 'p', { type: 'checkpoint', checkpoint: signed({ type: 'checkpoint', by: A.id, roster: ROOM, from_seq: 100 + n, upto_seq: 100 + n, prev: (100 + n) === 1 ? 'genesis' : `p${100 + n}`, root: `r${n}`, at: n }, A.priv, signCheckpoint) }); + // One chain (each prev the root before it: an unlinked successor is refused, R2-15). + for (let n = 1; n <= 32; n++) ingest(node, 'p', { type: 'checkpoint', checkpoint: signed({ type: 'checkpoint', by: A.id, roster: ROOM, from_seq: 100 + n, upto_seq: 100 + n, prev: n === 1 ? 'p101' : `r${n - 1}`, root: `r${n}`, at: n }, A.priv, signCheckpoint) }); + assert.strictEqual(node._attestations.checkpointsOf(A.id).length, 32); const spent = node._gossipNewLane - node._gossipBuckets.get('p').tokens; for (let i = 0; i < 1000; i++) { const pos = (i % 99) + 1; diff --git a/tests/gossip-lanes.test.js b/tests/gossip-lanes.test.js index 68b7e045..176668c2 100644 --- a/tests/gossip-lanes.test.js +++ b/tests/gossip-lanes.test.js @@ -162,17 +162,20 @@ describe('the budget is kept per PROVEN peer (F2; Core Secure design D1)', () => }); }); - it('forgeries spend only their own peer\'s lane, never the shared ceiling, and a forgery in a session\'s own name ends it (security review D, p7-ceiling; re-review N1)', () => { - withNode({}, ({ node, metrics }) => { + it('forgeries spend their own peer\'s lane and fair share of the global budget, which bounds the checks whatever the number of peers, and a forgery in a session\'s own name ends it (R2-4; security review D, p7-ceiling; re-review N1)', () => { + withNode({}, ({ node, metrics, clock }) => { const A = kp('att-A'); node._roster.bind(A.id, A.pub, 'proven'); let badSig = 0; - const ingest = (peer) => { if (node._ingestAttestation(forged(A), peer, peer).reason === 'bad-signature') badSig++; }; - // 300 peers, 101 forgeries each: each peer's own 100, and the ceiling untouched. + const perPeer = new Map(); + const ingest = (peer) => { if (node._ingestAttestation(forged(A), peer, peer).reason === 'bad-signature') { badSig++; perPeer.set(peer, (perPeer.get(peer) || 0) + 1); } }; + // 300 peers, 101 forgeries each: until 0.14.3 each peer's own 100 (30,000 checks, growing with the + // peers, R2-4); now the global budget, spent before the check, bounds them all. for (let i = 0; i < 300; i++) for (let j = 0; j < 101; j++) ingest(`id-${i}`); - assert.strictEqual(badSig, 300 * 100, 'each peer checks its own 100'); - assert.strictEqual(node._gossipGlobal.tokens, node._gossipGlobalBurst, 'no forgery spent the shared ceiling'); - assert.strictEqual(metrics.filter((m) => m.type === 'gossip-over-ceiling').length, 0); + assert.strictEqual(badSig, node._gossipGlobalBurst, 'at most the global burst is checked, however many peers there are'); + assert.ok([...perPeer.values()].every((c) => c <= 100), 'and no peer more than its lane'); + assert.ok(metrics.some((m) => m.type === 'gossip-over-ceiling'), 'the rest is dropped unverified, and said'); + clock.t += 60_000; // the budget refills // A relayed statement that fails here is dropped, never charged to the relayer (0.14.0 re-review // N1): its binding for the signer may differ from this node's. const s = admitAs(node, { nodeId: 'relayer-1' }); @@ -190,14 +193,14 @@ describe('the budget is kept per PROVEN peer (F2; Core Secure design D1)', () => }); }); - it('the ceiling bounds VERIFIED statements, spent after the signature checks out', () => { + it('the global budget bounds the statements checked, spent before the signature is (R2-4)', () => { withNode({ gossipBudget: { globalBurst: 5, globalPerSecond: 1 } }, ({ node, metrics }) => { const A = kp('att-A'); node._roster.bind(A.id, A.pub, 'proven'); const real = (i) => signed({ of: `cmb-${crypto.randomBytes(32).toString('hex')}`, by: A.id, at: 1, roster: ROOM, verdict: 'aligned', categories: {}, seq: 1000 + i, prev: 'p' }, A.priv, signAttestation); const reasons = {}; for (let i = 0; i < 8; i++) { const r = node._ingestAttestation(real(i), `peer-${i}`, `peer-${i}`); reasons[r.reason || 'stored'] = (reasons[r.reason || 'stored'] || 0) + 1; } - assert.strictEqual(reasons['over-ceiling'], 3, 'past the ceiling, verified statements are dropped unstored'); + assert.strictEqual(reasons['over-ceiling'], 3, 'past the global budget, statements are dropped unverified and unstored'); assert.ok(metrics.some((m) => m.type === 'gossip-over-ceiling')); }); }); diff --git a/tests/security-0.14.3-attest.test.js b/tests/security-0.14.3-attest.test.js new file mode 100644 index 00000000..6df0a128 --- /dev/null +++ b/tests/security-0.14.3-attest.test.js @@ -0,0 +1,306 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME before lib/config loads +process.env.SYM_SEMANTIC_ENCODER = process.env.SYM_SEMANTIC_ENCODER || 'off'; + +/** + * 0.14.3 security review R2, the sym-attest-v1 findings: each a regression test that fails on 632b95d + * for its own reason. + * + * R2-4 the signature checks a node makes grow with the number of sessions (the global ceiling was + * spent only after verification): one global budget, spent before, shared fairly + * R2-7 a flood of free identities evicts equivocation evidence and own-witness memory, and the node + * then witnesses a third history + * R2-8 aggregateAttestations weighs every attestation under a cognition key, of any assertion + * R2-15 a successor checkpoint whose prev is not the root of the checkpoint held before it is stored + * and witnessed + * R2-16 a second attestation at one (attester, seq) silently replaces the first + * R2-17 an honest witness of an equivocator's other checkpoint is muted, its other witnesses dropped + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const core = require('../lib/core'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDirById } = require('../lib/config'); +const { AttestationStore } = require('../lib/attestation-store'); +const A = require('../lib/core/attestation'); +const { identity, admitAs } = require('./_core-secure'); + +const ROOM = 'team-room'; +const hex = () => crypto.randomBytes(32).toString('hex'); +const CATS = { focus: 'admit', issue: 'admit', intent: 'admit', motivation: 'admit', commitment: 'admit', perspective: 'admit', mood: 'admit' }; +const nodes = []; +function mk(opts = {}) { + const n = new SymNode({ name: `sec-attest-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, silent: true, discovery: new NullDiscovery(), room: ROOM, checkpointInterval: 0, ...opts }); + nodes.push(n); + return n; +} +async function stopAll() { + for (const n of nodes.splice(0)) { + for (const r of n._dropReports?.values() || []) clearTimeout(r.timer); + try { await n.stop(); } catch { /* not started */ } + try { fs.rmSync(nodeDirById(n.nodeId), { recursive: true, force: true }); } catch { /* */ } + } +} +function cp(id, { from = 1, upto = 1, prev = 'genesis', root = hex() } = {}) { + const c = { by: id.nodeId, roster: ROOM, from_seq: from, upto_seq: upto, prev, root, at: Date.now() }; + core.signCheckpoint(c, id.privateKey); + return c; +} +const cpFrame = (c) => ({ type: 'sym-attest-checkpoint', checkpoint: core.toWireCheckpoint(c) }); +function witness(by, c) { + const w = { attester: c.by, roster: ROOM, from_seq: c.from_seq, upto_seq: c.upto_seq, root: c.root, by: by.nodeId, role: 'participant', at: Date.now() }; + core.signWitness(w, by.privateKey); + return w; +} +const witFrame = (w) => ({ type: 'sym-attest-witness', witness: core.toWireWitness(w) }); +function attestation(by, seq, extra = {}) { + const a = { of: `cmb-${hex()}`, assertionId: `asrt-${hex()}`, by: by.nodeId, at: 1, roster: ROOM, method: 'neural', verdict: 'aligned', categories: CATS, role: 'participant', seq, prev: 'genesis', ...extra }; + core.signAttestation(a, by.privateKey); + return a; +} +const forgedAttestation = (by, seq) => ({ ...attestation(by, seq), sig: crypto.randomBytes(64).toString('base64url') }); + +describe('R2-4: one global budget of signature checks, spent before verification, shared fairly', () => { + it('R2-4: the checks made are bounded by the global budget whatever the number of sessions', async () => { + try { + const V = mk({ gossipBudget: { globalPerSecond: 200, globalBurst: 200 } }); + let now = 1_000_000; V._gossipClock = () => now; + const K = 10; + const peers = Array.from({ length: K }, (_, i) => identity(`p${i}`)); + for (const p of peers) V._roster.bind(p.nodeId, p.publicKey, 'proven'); + let checks = 0; + // One simulated second: every peer sends one forged attestation in its own name a millisecond, well + // inside its own lane (2,000 a second). Each that reaches the signature check fails it. + for (let ms = 0; ms < 1000; ms++) { + now += 1; + for (const p of peers) if (V._ingestAttestation(forgedAttestation(p, ms + 1), p.nodeId, p.name).reason === 'bad-signature') checks++; + } + // 632b95d: each lane bought its own checks, K × 1,000 = 10,000 (the ceiling counted only after). + assert.ok(checks <= 200 + 200 + K, `checks ${checks} stay within the global budget (burst 200 + 200 a second)`); + } finally { await stopAll(); } + }); + + it('R2-4: a flooding peer spends its own share; an honest peer\'s statements are still checked and taken', async () => { + try { + const V = mk({ gossipBudget: { globalPerSecond: 100, globalBurst: 100 } }); + let now = 1_000_000; V._gossipClock = () => now; + const F = identity('flooder'); const H = identity('honest'); + for (const p of [F, H]) V._roster.bind(p.nodeId, p.publicKey, 'proven'); + const flood = Array.from({ length: 2000 }, (_, i) => attestation(F, i + 1)); + const honest = Array.from({ length: 20 }, (_, i) => attestation(H, i + 1)); + let taken = 0; + for (let ms = 0; ms < 2000; ms++) { + now += 1; + V._ingestAttestation(flood[ms], F.nodeId, F.name); + if (ms % 100 === 0 && V._ingestAttestation(honest[ms / 100], H.nodeId, H.name).ok) taken++; + } + // 632b95d: the flooder's verified statements spent the shared ceiling, and the honest peer's were + // then checked and dropped over it. + assert.strictEqual(taken, 20, `every honest statement taken (${taken})`); + } finally { await stopAll(); } + }); +}); + +describe('R2-7: equivocation evidence and own-witness memory are never evicted', () => { + it('R2-7: a flood of fresh attesters leaves the evidence held, and the node witnesses no further history of the equivocator', async () => { + try { + const V = mk(); + const E = identity('equivocator'); const sE = admitAs(V, E); + const first = cp(E); + V._receiveSessionFrame(sE, cpFrame(first)); + V._receiveSessionFrame(sE, cpFrame(cp(E))); // a second root for the same position: the evidence + assert.strictEqual(V._attestations.attesterEquivocated(E.nodeId), true); + for (let i = 0; i < 1100; i++) { + const S = identity(`s${i}`); const s = admitAs(V, S); + V._receiveSessionFrame(s, cpFrame(cp(S))); + s.close(); V._peers.delete(S.nodeId); V._sessionKeys.delete(S.nodeId); + } + // 632b95d: the flood evicted E (least recently updated) with its evidence and own-witness memory. + assert.strictEqual(V._attestations.attesterEquivocated(E.nodeId), true, 'the evidence is still held'); + const third = cp(E); + V._receiveSessionFrame(sE, cpFrame(third)); + assert.strictEqual(V._attestations.checkpointAt(E.nodeId, 1).root, first.root, 'the first copy stays the position\'s'); + const roots = new Set(sE.sent.filter((f) => f.type === 'sym-attest-witness' && f.witness.attester === E.nodeId && f.witness.by === V.nodeId).map((f) => f.witness.root)); + assert.deepStrictEqual([...roots], [first.root], 'this node countersigned one root for E@1, never a third'); + } finally { await stopAll(); } + }); + + it('R2-7 (store): an attester holding evidence keeps its place; when every place holds evidence a newcomer is refused, counted', () => { + const st = new AttestationStore({ maxAttesters: 2 }); + const c = (by, root, from = 1, upto = 1, prev = 'genesis') => ({ by, from_seq: from, upto_seq: upto, prev, root, sig: `${by}-${root}` }); + st.recordCheckpoint(c('E', 'r1')); + assert.strictEqual(st.recordCheckpoint(c('E', 'r2')).reason, 'conflict'); + for (const by of ['B', 'C', 'D']) assert.strictEqual(st.recordCheckpoint(c(by, 'r')).stored, true, `${by} takes the evictable place`); + assert.strictEqual(st.attesterEquivocated('E'), true); + assert.deepStrictEqual([...st._checkpoints.keys()].sort(), ['D', 'E']); + st.recordCheckpoint(c('D', 'x')); // D equivocates too: now every place holds evidence + assert.strictEqual(st.recordCheckpoint(c('N', 'r')).reason, 'full'); + assert.strictEqual(st.refusedReport().checkpointsFull, 1); + assert.strictEqual(st.attesterEquivocated('D') && st.attesterEquivocated('E'), true); + }); + + it('R2-7 (store): what this node witnessed is remembered past the attester cap (watermark), and across a rotation and restart', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-own-')); + try { + const opts = { dir, selfId: 'me', maxAttesters: 2, maxOwnWatermarks: 3, rotateBytes: 256, log: () => {} }; + const st = new AttestationStore(opts); + for (const a of ['A', 'B', 'C', 'D']) { + st.recordCheckpoint({ by: a, upto_seq: 7, root: 'r', sig: `c${a}` }); + st.recordWitness({ attester: a, upto_seq: 7, root: 'r', by: 'me', sig: `w${a}` }); + } + // 632b95d: A's and B's own-witness memory left with the cap of 2 attesters. + for (const a of ['A', 'B', 'C', 'D']) assert.strictEqual(st.hasWitnessed(a, 7, 'me'), true, `${a}@7 still known as witnessed`); + assert.strictEqual(st.hasWitnessed('A', 3, 'me'), true, 'a position below the watermark counts as witnessed (never signed again)'); + for (let i = 0; i < 40; i++) st.recordCheckpoint({ by: 'D', upto_seq: 100 + i, root: `r${i}`, sig: `cd${i}` }); + assert.strictEqual(st._rotate('witnesses.jsonl'), true, 'a rotation writes own-witness memory as its lines'); + const again = new AttestationStore(opts); + for (const a of ['A', 'B', 'C', 'D']) assert.strictEqual(again.hasWitnessed(a, 7, 'me'), true, `${a}@7 known after a restart`); + // Full: a new attester cannot be remembered, so it is not witnessed (counted). + assert.strictEqual(again.canRememberOwnWitness('E'), true, 'one watermark place left'); + again.recordWitness({ attester: 'E', upto_seq: 1, root: 'r', by: 'me', sig: 'wE' }); + again.recordWitness({ attester: 'F', upto_seq: 1, root: 'r', by: 'me', sig: 'wF' }); + assert.strictEqual(again.canRememberOwnWitness('G'), false); + assert.ok(again.refusedReport().ownWitnessFull >= 1); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +}); + +describe('R2-8: aggregateAttestations weighs only the held assertion', () => { + it('R2-8: attestations of another assertion under the same cognition key are excluded; one naming the held assertion counts', async () => { + try { + const V = mk(); + const r = V.remember({ focus: 'deploy plan', issue: 'i', intent: 'n', motivation: 'm', commitment: 'c', perspective: 'p', mood: { text: 'calm' } }); + const key = r.key; + const held = V._store.get(key).cmb.metadata.assertionId; + for (let i = 0; i < 2; i++) { + const T = identity(`t${i}`); const s = admitAs(V, T); + V._receiveSessionFrame(s, { type: 'sym-attest-attestation', attestation: core.toWireAttestation(attestation(T, 1, { of: key, verdict: 'rejected' })) }); + } + assert.strictEqual(V.attestationsFor(key).length, 2, 'the audit trail holds both'); + const agg = V.aggregateAttestations(key); + // 632b95d: total 2, overall 'rejected' with confidence 1, about another record. + assert.strictEqual(agg.total, 0); + assert.deepStrictEqual(agg.excluded.map((e) => e.reason), ['other-assertion', 'other-assertion']); + const T = identity('t-held'); const s = admitAs(V, T); + V._receiveSessionFrame(s, { type: 'sym-attest-attestation', attestation: core.toWireAttestation(attestation(T, 1, { of: key, assertionId: held })) }); + const again = V.aggregateAttestations(key); + assert.strictEqual(again.total, 1); + assert.strictEqual(again.assertionId, held); + assert.strictEqual(again.overall.dominant, 'aligned'); + assert.strictEqual(V.aggregateAttestations('cmb-not-held').total, 0, 'no record held, no assertion named: nothing weighed'); + } finally { await stopAll(); } + }); +}); + +describe('R2-15: a checkpoint must fit the chain held', () => { + it('R2-15: a successor whose prev is not the root of the checkpoint held right before it is refused, never stored or witnessed (either order)', async () => { + try { + const V = mk(); + const E = identity('e'); const sE = admitAs(V, E); + const a = cp(E, { from: 1, upto: 8 }); + const b = cp(E, { from: 9, upto: 16, prev: hex() }); // right after a, chained to another root + assert.deepStrictEqual(core.checkpointConflict(a, b), [], 'no §5.2 conflict: not evidence'); + V._receiveSessionFrame(sE, cpFrame(a)); + assert.strictEqual(V._ingestCheckpoint(A.fromWireCheckpoint(core.toWireCheckpoint(b)), E.nodeId, sE).reason, 'malformed-link'); + // 632b95d: b held (prev X) and witnessed. + assert.deepStrictEqual(V._attestations.checkpointsOf(E.nodeId).map((c) => c.upto_seq), [8]); + assert.strictEqual(V._attestations.witnessAt(E.nodeId, 16, V.nodeId), null, 'not witnessed'); + const good = cp(E, { from: 9, upto: 16, prev: a.root }); + V._receiveSessionFrame(sE, cpFrame(good)); + assert.deepStrictEqual(V._attestations.checkpointsOf(E.nodeId).map((c) => c.upto_seq), [8, 16], 'the linked successor is taken'); + // The other order: a predecessor whose root is not the prev of the held successor. + const G = identity('g'); const sG = admitAs(V, G); + const succ = cp(G, { from: 9, upto: 16, prev: hex() }); + V._receiveSessionFrame(sG, cpFrame(succ)); + const pred = cp(G, { from: 1, upto: 8 }); + assert.strictEqual(V._ingestCheckpoint(A.fromWireCheckpoint(core.toWireCheckpoint(pred)), G.nodeId, sG).reason, 'malformed-link'); + assert.strictEqual(V._attestations.checkpointAt(G.nodeId, 8), null); + // A conflicting copy is still the evidence, whatever the link rule says. + assert.strictEqual(V._ingestCheckpoint(A.fromWireCheckpoint(core.toWireCheckpoint(cp(E, { from: 1, upto: 8 }))), E.nodeId, sE).reason, 'conflict'); + assert.strictEqual(V._attestations.attesterEquivocated(E.nodeId), true); + } finally { await stopAll(); } + }); +}); + +describe('R2-16: one attestation per (attester, seq)', () => { + const v = require('./fixtures/sym-attest-v1.json'); + const main = v.attestations.map((x) => A.fromWireAttestation(x.attestation)); + const fork = v.forkAttestations.map((x) => A.fromWireAttestation(x.attestation)); + + it('R2-16: a second, different attestation at a held seq is refused as a fork and kept as evidence, never a replacement; a restart re-derives it', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sec-fork-')); + try { + const st = new AttestationStore({ dir, log: () => {} }); + for (const a of main) st.record(a, { ingested: true }); + const by = main[0].by; + const before = st.chainOf(by).map((a) => a.sig); + const r5 = st.record(fork.find((x) => x.seq === 5), { ingested: true }); + assert.strictEqual(r5.reason, 'conflict'); + assert.strictEqual(r5.first, true); + assert.strictEqual(st.record(fork.find((x) => x.seq === 6), { ingested: true }).reason, 'conflict'); + // 632b95d: seq 5 and 6 were replaced by the fork's copies, silently. + assert.deepStrictEqual(st.chainOf(by).map((a) => a.sig), before, 'the chain held is unchanged'); + assert.strictEqual(st.attesterEquivocated(by), true, 'the fork is evidence'); + assert.strictEqual(st.has(fork.find((x) => x.seq === 5).sig), true, 'a repeat of the evidence is a duplicate'); + const again = new AttestationStore({ dir, log: () => {} }); + assert.deepStrictEqual(again.chainOf(by).map((a) => a.sig), before); + assert.strictEqual(again.attesterEquivocated(by), true, 're-derived after a restart'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + it('R2-16 (node): the fork is said once, and that attester\'s checkpoints are no longer taken', async () => { + try { + const V = mk(); + const T = identity('t'); const s = admitAs(V, T); + const metrics = []; V.on('metric', (m) => metrics.push(m)); + V._receiveSessionFrame(s, { type: 'sym-attest-attestation', attestation: core.toWireAttestation(attestation(T, 1)) }); + V._receiveSessionFrame(s, { type: 'sym-attest-attestation', attestation: core.toWireAttestation(attestation(T, 1)) }); + assert.ok(metrics.some((m) => m.type === 'attestation-conflict' && m.kind === 'attestation' && m.attester === T.nodeId)); + assert.strictEqual(V._attestations.chainOf(T.nodeId).length, 1); + V._receiveSessionFrame(s, cpFrame(cp(T))); + assert.strictEqual(V._attestations.checkpointsOf(T.nodeId).length, 0, 'an equivocator\'s checkpoint is not taken or witnessed'); + } finally { await stopAll(); } + }); +}); + +describe('R2-17: only the equivocator is held to account', () => { + it('R2-17: an honest witness of the equivocator\'s other checkpoint is not muted, and its witnesses of others are taken', async () => { + try { + const V = mk(); + const E = identity('equivocator'); const sE = admitAs(V, E); + const W = identity('honest-witness'); const sW = admitAs(V, W); + const ca = cp(E); const cb = cp(E); + V._receiveSessionFrame(sE, cpFrame(ca)); + V._receiveSessionFrame(sW, cpFrame(cb)); // W was shown the other history and relays it + assert.strictEqual(V._attestations.attesterEquivocated(E.nodeId), true); + V._receiveSessionFrame(sW, witFrame(witness(W, cb))); + // 632b95d: W was muted for ten minutes as a witness that contradicts a held checkpoint. + assert.strictEqual(V._witnessMuted(W.nodeId), false); + const O = identity('other'); const sO = admitAs(V, O); + const co = cp(O); + V._receiveSessionFrame(sO, cpFrame(co)); + assert.strictEqual(V._ingestWitness(A.fromWireWitness(core.toWireWitness(witness(W, co))), W.nodeId, sW).ok, true); + } finally { await stopAll(); } + }); + + it('R2-17: a witness muted before the equivocation was known is heard again once it is', async () => { + try { + const V = mk(); + const E = identity('equivocator'); const sE = admitAs(V, E); + const W = identity('honest-witness'); const sW = admitAs(V, W); + const ca = cp(E); const cb = cp(E); + V._receiveSessionFrame(sE, cpFrame(ca)); + V._receiveSessionFrame(sW, witFrame(witness(W, cb))); // W's witness of B arrives before B + assert.strictEqual(V._witnessMuted(W.nodeId), true, 'until B is seen, W contradicts a held checkpoint'); + V._receiveSessionFrame(sW, cpFrame(cb)); // B: E signed two histories + assert.strictEqual(V._witnessMuted(W.nodeId), false, 'the equivocator was E'); + } finally { await stopAll(); } + }); +}); From b1e6cd34138016b67931a8e5d03d967a358b9dab Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:04:01 +0100 Subject: [PATCH 14/20] Invite: a parse error never repeats the URL, which can carry a relay token (R2-21) parseInvite echoed the first 120 characters of a URL it could not read, so a mistyped team invite put its relay token into every log and error shown. --- lib/invite.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/invite.js b/lib/invite.js index bdc2bc62..170f6445 100644 --- a/lib/invite.js +++ b/lib/invite.js @@ -61,7 +61,9 @@ function buildInvite({ room, kind, relay, token, issuer, scheme = 'sym' } = {}) */ function parseInvite(url) { const m = typeof url === 'string' ? INVITE_URL_RE.exec(url.trim()) : null; - if (!m) return { error: `not an invite URL: ${String(url).slice(0, 120)}` }; + // The URL is never repeated in the error (R2-21): what was meant as an invite may carry a relay token, + // and an error is logged and shown. + if (!m) return { error: 'not an invite URL (expected ://room/ or ://team/?relay=…&token=…); the URL is not repeated here, since an invite can carry a secret' }; const appScheme = m[1].toLowerCase(); let rawId, rawName, query; try { From 8b03a4d2fe3427910178f81f76b83165301ad589 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:04:01 +0100 Subject: [PATCH 15/20] Public verifyCMB checks the record's own bytes; one string, one key, one signature, one spelling (R2-12, R2-13, R2-14) R2-12. @sym-bot/sym/core verifyCMB is now core's verifyRecord: for a v2.0 record, MMP 8.8.5 in order on the signed projection (closed schema and 8.8.6 bounds, so any member the signature does not cover is unsigned-member; the assertionId recomputed before the signature; the strict Ed25519 rule; the content address). A valid one says coreSecure: true and carries its assertionId and the frozen projection. A top-level payload is allowed only when it is the JSON of the signed application section (the copy sym keeps). A record under the 0.13 suite (mmp-sig-v2) reports suite and coreSecure: false; one whose sections carry what 0.13 never minted there (an application, a node id, an assertionId, an eighth category) is invalid, and a genuine one stays valid unless the caller passes { coreSecure: true }. Core's own verifyCMB, the signature step on an already projected record, is unchanged for the node's ingress. R2-13. A lone surrogate encodes as U+FFFD, so one signature verified two strings. The projection refuses a signed string that is not well formed, the signing preimages refuse one, core verifyCMB refuses a category text that is not, and createCMB mints a lone surrogate as U+FFFD. R2-14. Keys and signatures are taken only in canonical unpadded base64url (exact length, unused bits zero): verifyStrict's string input, the record projection, isIdentityKey, the room-join grant, the handshake proof and the attestation frames. --- README.md | 23 ++- docs/reference.md | 32 ++++ lib/core/attestation.js | 2 +- lib/core/cmb-encoder.js | 27 +++- lib/core/cmb-signing.js | 186 +++++++++++++++++++++-- lib/core/ed25519.js | 19 ++- lib/core/record-canonical.js | 22 ++- lib/core/room-grant.js | 4 +- lib/public/core.js | 7 +- lib/roster-keys.js | 3 +- lib/session.js | 2 +- tests/public-surface.test.js | 2 +- tests/security-0.14.3-verify.test.js | 216 +++++++++++++++++++++++++++ 13 files changed, 507 insertions(+), 38 deletions(-) create mode 100644 tests/security-0.14.3-verify.test.js diff --git a/README.md b/README.md index 992bcd0e..bc3a4630 100644 --- a/README.md +++ b/README.md @@ -242,13 +242,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 `/log.jsonl`; `record(entry)` appends one plain diff --git a/docs/reference.md b/docs/reference.md index 2f997ece..bf7f8182 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -382,6 +382,38 @@ store.get(key).anchorWeight; // 0.5 files. It refuses only a caller that states none. Open a store only while no node holds it. A running node keeps its own copy in memory and writes its own files, so a second writer is not coordinated with it. +### Verifying a record read from a store + +A record a node delivered was verified before the host heard of it (the `verified-record` event). One read from a +file was not: `verifyCMB` of `@sym-bot/sym/core` checks it. + +```javascript +const { verifyCMB } = require('@sym-bot/sym/core'); +const v = verifyCMB(entry.cmb, keyBoundFor(entry.cmb.metadata.createdByNodeId), { coreSecure: true }); +if (v.valid) act(v.record); // the signed projection, frozen: nothing the signature does not cover +``` + +Since 0.14.3 it is the whole of MMP §8.8.5's checks on the record's own bytes (the audience and whose key it is are +the caller's): any member the signature does not cover makes a record invalid (`unsigned-member`), and a wrong +`assertionId` does too (`assertion-id-mismatch`, checked before the signature). The result names the suite that +signed it: `suite: 'mmp-sig-v2.0'` with `coreSecure: true` for a valid Core Secure record, or `suite: 'mmp-sig-v2'` +with `coreSecure: false` for a record a 0.13 node minted. The 0.13 suite does not sign the author's node id or an +application section, so a 0.13 record carrying either is invalid; a genuine one is still `valid: true` by default, +so a store of 0.13 history keeps reading as it did. What a 0.13 node kept beside a record's two sections +(`payload`, `provenance`, `tether`, `admission`) was never signed: it is not judged, and not to be trusted. Pass `{ coreSecure: true }` wherever the result decides +anything (a lifecycle change, an action): it refuses every record that is not v2.0 (`not-core-secure`). + +| `error` | Meaning | +|---|---| +| `unsigned-member` | the record carries a member its signature does not cover (`detail` names it) | +| `malformed-record` | it is not a well-formed v2.0 record: a type, a bound, NFC or well-formed Unicode (`detail`) | +| `assertion-id-mismatch` | its `assertionId` is not the hash of its signed preimage | +| `bad-signature` | the signature does not verify under the key given | +| `content-mismatch` | a category key or the record's address is not that of its text | +| `not-core-secure` | `{ coreSecure: true }` was asked and the record is under the 0.13 suite | +| `unknown-signature-suite` | it names a suite this release does not know | +| `cannot-verify: …` | no readable container, or one still encrypted: could not check, which is not forged | + ### Host options of `SymNode` | Option | Default | Notes | diff --git a/lib/core/attestation.js b/lib/core/attestation.js index 7ee8a43c..45d1952c 100644 --- a/lib/core/attestation.js +++ b/lib/core/attestation.js @@ -299,7 +299,7 @@ const ROLE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; /** The members of an attestation, exactly: the object is closed. */ const ATTESTATION_MEMBERS = new Set(['of', 'assertionId', 'by', 'at', 'room', 'method', 'verdict', 'categories', 'role', 'seq', 'prev', 'sigAlg', 'sig']); const HEX64 = /^[0-9a-f]{64}$/; -const SIG_RE = /^[A-Za-z0-9_-]{86}$/; +const SIG_RE = /^[A-Za-z0-9_-]{85}[AQgw]$/; // canonical unpadded base64url, one spelling (R2-14) const isId = (v) => typeof v === 'string' && ID_RE.test(v); // A room is a §5.8 identifier (sym-attest-frame.schema.json `room`, MMP 2.0 update 1). const { isRoomId: isRoom } = require('./room-id'); diff --git a/lib/core/cmb-encoder.js b/lib/core/cmb-encoder.js index f62fdf8b..b881990b 100644 --- a/lib/core/cmb-encoder.js +++ b/lib/core/cmb-encoder.js @@ -53,6 +53,23 @@ const { resolveCategories, unreadableReason } = require('./record-shape'); // portability hazard. Vectors are excluded (local, encoder-specific artifacts). const CMB_KEY_DOMAIN = 'mmp-cmb-v1\n'; +/** + * Whether `s` is well-formed Unicode: no lone surrogate (security review R2-13). UTF-8 encodes a lone + * surrogate as U+FFFD, so "\ud800" and "\ufffd" hash and sign as the same bytes: one signature would + * verify two different strings. A receiver refuses a signed string that is not well formed (record-canonical, + * the signing preimages), and an emitter mints only well-formed text (toWellFormedText). + * @param {string} s + * @returns {boolean} + */ +const isWellFormedText = typeof String.prototype.isWellFormed === 'function' + ? (s) => typeof s === 'string' && s.isWellFormed() + : (s) => typeof s === 'string' && !/\p{Cs}/u.test(s); + +/** `s` with each lone surrogate replaced by U+FFFD: the text UTF-8 would carry anyway, now spelled once. */ +const toWellFormedText = typeof String.prototype.toWellFormed === 'function' + ? (s) => s.toWellFormed() + : (s) => s.replace(/\p{Cs}/gu, '\ufffd'); + /** Netstring length-prefix: `:`. Injection-proof * (a delimiter inside the text cannot shift a category boundary) and trivially * identical across languages. */ @@ -583,7 +600,9 @@ function mintLineage(lineage) { */ function mintCategoryText(value) { const text = typeof value === 'string' ? value : (value && typeof value.text === 'string' ? value.text : ''); - return (text || 'neutral').normalize('NFC'); + // Well formed, then NFC (R2-13): a receiver refuses a lone surrogate, so one is minted as the U+FFFD + // its UTF-8 encoding carried anyway (a host that cut a string inside a surrogate pair keeps working). + return toWellFormedText(text || 'neutral').normalize('NFC'); } function createCMB(opts = {}) { @@ -593,7 +612,7 @@ function createCMB(opts = {}) { // defect class this boundary exists to delete, so it fails closed. // Every signed string is minted NFC (§8.8.5 step 1): a receiver refuses one that is not, and never // normalises it, so the emitter does. - const createdBy = typeof opts.createdBy === 'string' ? opts.createdBy.normalize('NFC') : opts.createdBy; + const createdBy = typeof opts.createdBy === 'string' ? toWellFormedText(opts.createdBy).normalize('NFC') : opts.createdBy; if (typeof createdBy !== 'string' || createdBy.length === 0) { throw new Error('createCMB requires createdBy — the author\'s agent id (§7.2); it is signature-bound and cannot be defaulted'); } @@ -707,7 +726,7 @@ function createCMB(opts = {}) { metadata.createdByNodeId = opts.createdByNodeId; // application.schema is signed as NFC (§8.8.3) and refused otherwise on receipt: minted NFC. const app = opts.application ?? null; - metadata.application = app && typeof app === 'object' && typeof app.schema === 'string' ? { ...app, schema: app.schema.normalize('NFC') } : app; + metadata.application = app && typeof app === 'object' && typeof app.schema === 'string' ? { ...app, schema: toWellFormedText(app.schema).normalize('NFC') } : app; } // A §5.8 room identifier, or nothing is minted: every receiver refuses a record signed for any other. if (minting && !isRoomId(metadata.room)) throw new RangeError(`createCMB: room ${JSON.stringify(String(metadata.room).slice(0, 80))} is not a §5.8 room identifier ([a-z0-9._-], 1 to 64 characters)`); @@ -809,6 +828,8 @@ module.exports = { assertAgentId, assertRecordSendable, mintCategoryText, + isWellFormedText, + toWellFormedText, recordBytes, textBytes, DEFAULT_ROOM, diff --git a/lib/core/cmb-signing.js b/lib/core/cmb-signing.js index 27184809..6b041897 100644 --- a/lib/core/cmb-signing.js +++ b/lib/core/cmb-signing.js @@ -24,8 +24,20 @@ const { resolveCategories, unreadableReason } = require('./record-shape.js'); const crypto = require('crypto'); const { verifyStrict } = require('./ed25519'); -const { lp, keyScheme, sameKey, blockKeyV2 } = require('./cmb-encoder'); +const { lp, keyScheme, sameKey, blockKeyV2, categoryKeyV1, isWellFormedText } = require('./cmb-encoder'); const { CAT7_CATEGORIES } = require('./cmb'); +const { canonicalRecordV2_0 } = require('./record-canonical'); + +/** + * A string that enters a signing preimage is well-formed Unicode, or nothing is signed or verified over it + * (sym 0.14.3, R2-13): UTF-8 writes a lone surrogate as U+FFFD, so "\ud800" and "\ufffd" would sign as the + * same bytes and one signature would verify both. Returns the string. + */ +function wellFormed(v, what, suite) { + const str = String(v); + if (!isWellFormedText(str)) throw new Error(`${suite}: ${what} is not well-formed Unicode (a lone surrogate)`); + return str; +} // Fixed ASN.1 DER prefixes for raw 32-byte Ed25519 keys (RFC 8410). The node // identity stores keys as raw 32-byte values (base64url); Node's crypto needs @@ -61,7 +73,7 @@ function decimal(n) { /** Bytewise sort — descents and parents are SETS, so their order must not change the bytes. * Buffer.compare is remixKeyV1's precedent and the one Swift mirrors. */ function sortedBytewise(list) { - return [...list].map(String).sort((a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))); + return [...list].map((r) => wellFormed(r, 'a parent reference', 'mmp-sig')).sort((a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))); } /** @@ -139,10 +151,10 @@ function signingPayloadV2(cmb) { const parts = [ Buffer.from('mmp-sig-v2\n', 'utf8'), lp(key), - lp(m.createdBy.normalize('NFC')), + lp(wellFormed(m.createdBy, 'createdBy', 'mmp-sig-v2').normalize('NFC')), lp(decimal(m.createdTimestamp)), - lp(String(m.room ?? '').normalize('NFC')), - lp(String(m.to ?? '').normalize('NFC')), + lp(wellFormed(m.room ?? '', 'room', 'mmp-sig-v2').normalize('NFC')), + lp(wellFormed(m.to ?? '', 'to', 'mmp-sig-v2').normalize('NFC')), lp(decimal(parents.length)), ...parents.map((p) => lp(p)), lp(categoryParentsCommitment(cmb?.categories)), @@ -187,7 +199,7 @@ function applicationCommitmentV1(application) { if (!/^[a-z0-9][a-z0-9!#$&^_.+/-]*$/.test(application.mediaType) || application.mediaType.includes(';')) { throw new Error('mmp-app-v1: mediaType must be canonical lowercase ASCII without parameters'); } - const schema = String(application.schema ?? '').normalize('NFC'); + const schema = wellFormed(application.schema ?? '', 'application schema', 'mmp-app-v1').normalize('NFC'); return crypto.createHash('sha256').update(Buffer.concat([ domain, lp('1'), lp(application.mediaType), lp(schema), lp('base64url'), lp(decimal(application.byteLength)), lp(application.digest), @@ -224,11 +236,11 @@ function signingPayloadV2_0(cmb) { lp(MMP_PROTOCOL_VERSION), lp(MMP_ADDRESS_SCHEME), lp(m.key), - lp(m.createdByNodeId), - lp(String(m.createdBy).normalize('NFC')), + lp(wellFormed(m.createdByNodeId, 'createdByNodeId', 'mmp-sig-v2.0')), + lp(wellFormed(m.createdBy, 'createdBy', 'mmp-sig-v2.0').normalize('NFC')), lp(decimal(m.createdTimestamp)), - lp(String(m.room).normalize('NFC')), - lp(m.to ?? ''), + lp(wellFormed(m.room, 'room', 'mmp-sig-v2.0').normalize('NFC')), + lp(wellFormed(m.to ?? '', 'to', 'mmp-sig-v2.0')), lp(decimal(parents.length)), ...parents.map((p) => lp(p)), lp(categoryParentsCommitment(cmb.categories)), @@ -397,6 +409,13 @@ function verifyCMB(cmb, publicKeyB64url) { // and telling the caller which one is the whole value of the message. return { signed: true, valid: false, error: unreadableReason(cmb) }; } + // A text that is not well-formed Unicode addresses as its U+FFFD spelling (R2-13): refused, so one + // signature verifies one text. + for (const f of CAT7_CATEGORIES) { + const c = cats[f]; + const t = typeof c === 'string' ? c : (c && c.text); + if (typeof t === 'string' && !isWellFormedText(t)) return { signed: true, valid: false, error: 'malformed-string' }; + } const expected = blockKeyV2(cats); if (!sameKey(expected, m.key)) return { signed: true, valid: false, error: 'content-mismatch' }; return { signed: true, valid: true }; @@ -438,4 +457,149 @@ function verifyCMB(cmb, publicKeyB64url) { return { signed: false, valid: false }; } -module.exports = { signingPayloadV2, signingPayloadV2_0, applicationCommitmentV1, assertionIdV2_0, categoryParentsCommitment, sigDigestV2, signCMB, verifyCMB, checkAudience, privateKeyObject, publicKeyObject }; +// ── The public verifier (@sym-bot/sym/core `verifyCMB`) ───────────────────────────────────── + +/** The internal suite of sym 0.13 (no `signatureSuite` member): its signature does not cover the node id. */ +const LEGACY_SIGNATURE_SUITE = 'mmp-sig-v2'; +/** What a 0.13 node minted in a record's metadata, all of it signed but `lineage.method`. Nothing signs any + * other member of a record under that suite. */ +const LEGACY_METADATA_MEMBERS = new Set(['key', 'createdBy', 'createdTimestamp', 'lineage', 'room', 'to', 'sig', 'sigAlg']); + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); +const deepFreeze = (v) => { + if (v && typeof v === 'object' && !Object.isFrozen(v)) { for (const k of Object.keys(v)) deepFreeze(v[k]); Object.freeze(v); } + return v; +}; + +/** + * Whether a record's top-level `payload` is the JSON its signed application section carries (the copy + * sym keeps beside a record it minted or delivered, remember({ payload })): then it says nothing the + * signature does not, and is allowed beside the record. + */ +function payloadIsSigned(cmb) { + const app = cmb.metadata && cmb.metadata.application; + if (!isPlainObject(app) || app.mediaType !== 'application/json' || typeof app.data !== 'string') return false; + try { return JSON.stringify(JSON.parse(Buffer.from(app.data, 'base64url').toString('utf8'))) === JSON.stringify(cmb.payload); } catch { return false; } +} + +/** + * Why a record under the 0.13 suite carries, in its two sections, something its signature does not cover, + * or null. What sits beside the two sections (a 0.13 node kept `payload`, `provenance`, `tether` and + * `admission` there) is a store annotation, never signed and never part of the record: it is not judged, + * so a 0.13 store reads as it did, and `coreSecure: false` says that nothing beside the sections is signed. + */ +function legacyUnsigned(cmb) { + const m = cmb.metadata; + if (!isPlainObject(m)) return 'metadata is not an object'; + for (const k of Object.keys(m)) if (!LEGACY_METADATA_MEMBERS.has(k)) return `metadata.${String(k).slice(0, 32)}, which the 0.13 suite does not sign`; + // Every signed value is the type it is signed as: the preimage turns a number into its decimal string, so + // a value of another type would be a second spelling of one assertion. + for (const k of ['createdBy', 'room', 'to']) if (m[k] != null && typeof m[k] !== 'string') return `metadata.${k} is not a string`; + if (m.lineage != null) { + if (!isPlainObject(m.lineage)) return 'lineage is not an object'; + for (const k of Object.keys(m.lineage)) if (k !== 'parents' && k !== 'method') return `lineage.${String(k).slice(0, 32)}, which nothing signs`; + if (m.lineage.parents != null && (!Array.isArray(m.lineage.parents) || m.lineage.parents.some((r) => typeof r !== 'string'))) return 'lineage.parents is not a list of references'; + } + const c = cmb.categories; + if (!isPlainObject(c)) return null; // no readable container: verifyCMB says so (cannot-verify) + for (const f of Object.keys(c)) { + if (!CAT7_CATEGORIES.includes(f)) return `category ${String(f).slice(0, 32)}, which the content address does not cover`; + const v = c[f]; + if (typeof v === 'string') continue; + if (!isPlainObject(v)) return `category ${f} is not text or an object`; + const allowed = f === 'mood' ? ['text', 'meta', 'valence', 'arousal'] : ['text', 'meta']; + for (const k of Object.keys(v)) if (!allowed.includes(k)) return `category ${f}.${String(k).slice(0, 32)}, which nothing signs`; + if (v.text != null && typeof v.text !== 'string') return `category ${f} text is not a string`; + if (v.meta != null) { + if (!isPlainObject(v.meta)) return `category ${f} meta is not an object`; + for (const k of Object.keys(v.meta)) if (k !== 'key' && k !== 'parents') return `category ${f} meta.${String(k).slice(0, 32)}, which nothing signs`; + if (v.meta.parents != null && (!Array.isArray(v.meta.parents) || v.meta.parents.some((r) => typeof r !== 'string'))) return `category ${f} parents is not a list of references`; + // The carried category key is derived from the text; one that is not its recomputation is not signed. + if (v.meta.key != null && v.meta.key !== categoryKeyV1(f, typeof v.text === 'string' ? v.text : '')) return 'content-mismatch'; + } + } + return null; +} + +/** @private The v2.0 suite: MMP §8.8.5 steps 1 to 5 in order, on the signed projection. */ +function verifyRecordV2_0(cmb, publicKeyB64url) { + const suite = MMP_SIGNATURE_SUITE; + const no = (error, detail) => ({ signed: true, valid: false, error, ...(detail ? { detail } : {}), suite, coreSecure: false }); + if (!publicKeyB64url) return no('no-public-key'); + for (const k of Object.keys(cmb)) { + if (k === 'categories' || k === 'metadata' || (k === 'payload' && payloadIsSigned(cmb))) continue; + return no('unsigned-member', `a member outside categories and metadata (${String(k).slice(0, 32)})`); + } + // "Could not check" stays distinct from "forged": a container that is missing or still encrypted. + if (!resolveCategories(cmb)) return no(unreadableReason(cmb)); + // Step 1: the schema, closed, every value typed, NFC and well formed, within the §8.8.6 bounds; the + // record is its projection from here on. + let p; + try { + p = canonicalRecordV2_0({ categories: cmb.categories, metadata: cmb.metadata }); + } catch (e) { + const reason = (e && (e.reason || e.message)) || 'malformed'; + if (reason === 'content-mismatch') return no('content-mismatch'); + return no(/unknown member|member outside/.test(reason) ? 'unsigned-member' : 'malformed-record', reason); + } + let preimage; + try { preimage = signingPayloadV2_0(p); } catch (e) { return no('malformed-record', e.message); } + // The assertion identity before the signature (§8.8.5): a record naming another assertion is refused + // whatever its signature says. + const assertionId = `asrt-${crypto.createHash('sha256').update(preimage).digest('hex')}`; + if (p.metadata.assertionId !== assertionId) return no('assertion-id-mismatch'); + if (!verifyStrict(preimage, publicKeyB64url, p.metadata.sig)) return no('bad-signature'); + if (!sameKey(blockKeyV2(p.categories), p.metadata.key)) return no('content-mismatch'); + return { signed: true, valid: true, suite, coreSecure: true, assertionId, record: deepFreeze(p) }; +} + +/** + * Verify a record: the public `verifyCMB` of `@sym-bot/sym/core` (sym 0.14.3, security review R2-12). + * + * A v2.0 record (`signatureSuite: "mmp-sig-v2.0"`) is valid only when MMP §8.8.5's checks on its own bytes + * all pass, in order: the closed schema and the §8.8.6 bounds (any member the signature does not cover + * refuses it, except the unsigned members §8.8.4 lets it carry: mood `valence` and `arousal`, + * `lineage.method`), the `assertionId` recomputed from the preimage, the Ed25519 signature under the one + * strict rule (§18.3.2), and the content address. Beside the record, only a top-level `payload` equal to the + * JSON of its signed application section is allowed (the copy sym keeps). The result then says + * `coreSecure: true` and carries `assertionId` and `record`, the signed projection (frozen): what the + * signature covers and nothing else. + * + * A record under sym 0.13's internal suite (`mmp-sig-v2`, no `signatureSuite`) is reported as such: + * `suite: "mmp-sig-v2"`, `coreSecure: false`. Its signature covers neither the author's node id nor an + * application section, so a record under it whose sections carry either (or any member a 0.13 node did not + * mint there) is invalid (`unsigned-member`). A genuine 0.13 record stays valid, as it was, store + * annotations beside its sections included (a 0.13 `payload` is one: nothing signs it), unless the caller + * passes `{ coreSecure: true }`, which refuses every record that is not v2.0 (`not-core-secure`). + * + * It does not decide whose key `publicKey` is, nor check the audience: resolve the key from a binding you + * trust for the record's `createdByNodeId`, never from the record. + * + * @param {object} cmb + * @param {string} publicKeyB64url - the author's raw Ed25519 public key, canonical unpadded base64url + * @param {{ coreSecure?: boolean }} [opts] - coreSecure: true refuses a record that is not v2.0 + * @returns {{ signed: boolean, valid: boolean, error?: string, detail?: string, suite: string|null, + * coreSecure: boolean, assertionId?: string, record?: object }} + * error: unsigned-member | malformed-record | assertion-id-mismatch | bad-signature | content-mismatch | + * not-core-secure | unknown-signature-suite | no-public-key | unverified-legacy | legacy-key-rejected | + * malformed-string | cannot-verify: … + */ +function verifyRecord(cmb, publicKeyB64url, opts = {}) { + const requireCoreSecure = !!(opts && opts.coreSecure === true); + if (!isPlainObject(cmb) || !isPlainObject(cmb.metadata)) { + // A pre-boundary block (or not a record at all): never valid here (unverified-legacy). + return { ...verifyCMB(cmb, publicKeyB64url), suite: null, coreSecure: false }; + } + const m = cmb.metadata; + const suite = m.signatureSuite === undefined ? LEGACY_SIGNATURE_SUITE : m.signatureSuite; + if (suite !== MMP_SIGNATURE_SUITE && suite !== LEGACY_SIGNATURE_SUITE) return { signed: !!m.sig, valid: false, error: 'unknown-signature-suite', suite: null, coreSecure: false }; + if (!m.sig || m.sigAlg !== 'ed25519') return { signed: false, valid: false, suite, coreSecure: false }; + if (suite === MMP_SIGNATURE_SUITE) return verifyRecordV2_0(cmb, publicKeyB64url); + if (requireCoreSecure) return { signed: true, valid: false, error: 'not-core-secure', suite, coreSecure: false }; + const why = legacyUnsigned(cmb); + if (why === 'content-mismatch') return { signed: true, valid: false, error: 'content-mismatch', suite, coreSecure: false }; + if (why) return { signed: true, valid: false, error: 'unsigned-member', detail: why, suite, coreSecure: false }; + return { ...verifyCMB(cmb, publicKeyB64url), suite, coreSecure: false }; +} + +module.exports = { signingPayloadV2, signingPayloadV2_0, applicationCommitmentV1, assertionIdV2_0, categoryParentsCommitment, sigDigestV2, signCMB, verifyCMB, verifyRecord, checkAudience, privateKeyObject, publicKeyObject }; diff --git a/lib/core/ed25519.js b/lib/core/ed25519.js index a4c63794..9bda4e78 100644 --- a/lib/core/ed25519.js +++ b/lib/core/ed25519.js @@ -136,12 +136,25 @@ function isKeyKnown(key) { return KEPT.has(k) || KEY_CACHE.has(k); } -/** Bytes of a Buffer or a base64url string, of exactly `n` bytes, or null. */ +/** + * A key (32 bytes) or signature (64 bytes) in canonical unpadded base64url (sym 0.14.3, R2-14): the exact + * length, and a last character whose unused bits are zero. Base64url decoding ignores padding, whitespace, + * the standard alphabet's + and /, and the unused bits, so without this one key or signature has many + * spellings that all verify, and each is a different string to every map keyed on it. + */ +const CANONICAL_KEY_B64URL = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/; +const CANONICAL_SIG_B64URL = /^[A-Za-z0-9_-]{85}[AQgw]$/; + +/** Bytes of a Buffer or a canonical base64url string, of exactly `n` bytes, or null. */ function toBytes(v, n) { let b = null; if (Buffer.isBuffer(v)) b = v; else if (v instanceof Uint8Array) b = Buffer.from(v); - else if (typeof v === 'string') { try { b = Buffer.from(v, 'base64url'); } catch { b = null; } } + else if (typeof v === 'string') { + const canonical = n === 32 ? CANONICAL_KEY_B64URL.test(v) : n === 64 ? CANONICAL_SIG_B64URL.test(v) : false; + if (!canonical) return null; + try { b = Buffer.from(v, 'base64url'); } catch { b = null; } + } return b && b.length === n ? b : null; } @@ -190,4 +203,4 @@ function precheckFailure(publicKey, signature) { /** @private Tests only: a smaller cache, to show what it keeps. Returns the previous size. */ function _setKeyCacheMax(n) { const was = KEY_CACHE_MAX; KEY_CACHE_MAX = n; while (KEY_CACHE.size > KEY_CACHE_MAX) KEY_CACHE.delete(KEY_CACHE.keys().next().value); return was; } -module.exports = { verifyStrict, isPrimeOrderKey, isKeyKnown, precheckFailure, L, _setKeyCacheMax }; +module.exports = { verifyStrict, isPrimeOrderKey, isKeyKnown, precheckFailure, L, CANONICAL_KEY_B64URL, CANONICAL_SIG_B64URL, _setKeyCacheMax }; diff --git a/lib/core/record-canonical.js b/lib/core/record-canonical.js index 9df9deb5..c20b6476 100644 --- a/lib/core/record-canonical.js +++ b/lib/core/record-canonical.js @@ -19,6 +19,9 @@ * - every signed string (each category's text, `createdBy`, `application.schema`) is already NFC: * a value that is not is refused, never normalised, as no type is coerced (MMP 2.0 update 1, * §8.8.5 step 1); `room` is a §5.8 room identifier, which is ASCII; + * - every string is well-formed Unicode (sym 0.14.3, R2-13): UTF-8 writes a lone surrogate as U+FFFD, + * so a record spelling one would verify under the signature of the record spelling U+FFFD; + * - `sig` is spelled canonically (R2-14): 86 base64url characters, the last one of A, Q, g, w; * - `createdBy` and each parent reference are at most 256 characters, counted as JSON Schema * counts them (code points), and a parents list holds at most 256 entries; * - the carried but unsigned members (§8.8.4) — the mood's `valence` and `arousal`, and @@ -42,7 +45,7 @@ */ const { CAT7_CATEGORIES } = require('./cmb'); -const { categoryKeyV1, textBytes, MAX_CATEGORY_BYTES, MAX_RECORD_TEXT_BYTES, MAX_RECORD_BYTES } = require('./cmb-encoder'); +const { categoryKeyV1, textBytes, isWellFormedText, MAX_CATEGORY_BYTES, MAX_RECORD_TEXT_BYTES, MAX_RECORD_BYTES } = require('./cmb-encoder'); const { isRoomId } = require('./room-id'); const LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -50,7 +53,9 @@ const LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 const LOWER_ID = /^[0-9a-z][0-9a-z._-]{0,127}$/; const CMB_KEY = /^cmb-[0-9a-f]{64}$/; const ASRT = /^asrt-[0-9a-f]{64}$/; -const SIG = /^[A-Za-z0-9_-]{86}$/; +// One spelling per signature (R2-14): 64 bytes are 86 base64url characters whose last carries 2 bits, so it is +// one of A, Q, g, w. A decoder ignores the 4 unused bits, so any other last character spells the same bytes. +const SIG = /^[A-Za-z0-9_-]{85}[AQgw]$/; const DIGEST = /^sha256-[0-9a-f]{64}$/; const B64URL = /^[A-Za-z0-9_-]*$/; const MEDIA = /^[a-z0-9][a-z0-9!#$&^_.+/-]*$/; @@ -79,6 +84,9 @@ function codePoints(s) { } /** A signed string is NFC already, or the record is refused (§8.8.5 step 1): never normalised. */ const isNFC = (s) => s === s.normalize('NFC'); +/** A signed string is well-formed Unicode, or the record is refused (R2-13): a lone surrogate encodes as + * U+FFFD, so one signature would verify two strings. */ +const isWellFormed = isWellFormedText; /** Parents are signed as a set sorted by their UTF-8 bytes (§8.8.4); the projection holds that order. */ const sortedBytewise = (list) => [...list].sort((a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))); @@ -95,7 +103,7 @@ function refs(list, what, pattern) { if (list.length > MAX_PARENTS) throw new RecordShapeError(`${what} has more than ${MAX_PARENTS} entries`); const seen = new Set(); for (const r of list) { - if (typeof r !== 'string' || r.length === 0 || codePoints(r) > MAX_REF || (pattern && !pattern.test(r))) throw new RecordShapeError(`${what} holds a value that is not a reference`); + if (typeof r !== 'string' || r.length === 0 || !isWellFormed(r) || codePoints(r) > MAX_REF || (pattern && !pattern.test(r))) throw new RecordShapeError(`${what} holds a value that is not a reference`); if (seen.has(r)) throw new RecordShapeError(`${what} repeats an entry`); seen.add(r); } @@ -107,6 +115,7 @@ function category(name, v) { const allowed = name === 'mood' ? new Set(['text', 'meta', 'valence', 'arousal']) : new Set(['text', 'meta']); for (const k of Object.keys(v)) if (!allowed.has(k)) throw new RecordShapeError(`category ${name} carries an unknown member`); if (typeof v.text !== 'string') throw new RecordShapeError(`category ${name} has no text`); + if (!isWellFormed(v.text)) throw new RecordShapeError(`category ${name} text is not well-formed Unicode`); if (!isNFC(v.text)) throw new RecordShapeError(`category ${name} text is not NFC`); if (name === 'mood') { // Validated as the schema types them, then dropped: unsigned (§8.8.4). @@ -131,7 +140,7 @@ function application(a) { if (!isPlain(a)) throw new RecordShapeError('application is not an object'); for (const k of Object.keys(a)) if (!APPLICATION_MEMBERS.includes(k)) throw new RecordShapeError('application carries an unknown member'); if (typeof a.mediaType !== 'string' || !MEDIA.test(a.mediaType)) throw new RecordShapeError('application mediaType is malformed'); - if (typeof a.schema !== 'string' || a.schema.length > 2048) throw new RecordShapeError('application schema is not text'); + if (typeof a.schema !== 'string' || a.schema.length > 2048 || !isWellFormed(a.schema)) throw new RecordShapeError('application schema is not text'); // Signed as NFC(schema) (§8.8.3), so a free spelling would be a second form of one assertion: it is // NFC already, or refused (§8.8.5 step 1, MMP 2.0 update 1). if (!isNFC(a.schema)) throw new RecordShapeError('application schema is not NFC'); @@ -190,6 +199,7 @@ function canonicalRecordV2_0(cmb) { if (m.signatureSuite !== 'mmp-sig-v2.0') throw new RecordShapeError('signatureSuite is not mmp-sig-v2.0'); if (!isLowerUuid(m.createdByNodeId)) throw new RecordShapeError('createdByNodeId is not a lowercase UUID'); if (typeof m.createdBy !== 'string' || m.createdBy.length === 0 || codePoints(m.createdBy) > 256) throw new RecordShapeError('createdBy is not a label'); + if (!isWellFormed(m.createdBy)) throw new RecordShapeError('createdBy is not well-formed Unicode'); if (!isNFC(m.createdBy)) throw new RecordShapeError('createdBy is not NFC'); if (!Number.isSafeInteger(m.createdTimestamp) || m.createdTimestamp < 0) throw new RecordShapeError('createdTimestamp is not an integer'); // A §5.8 room identifier (MMP 2.0 update 1: one room grammar everywhere). Being ASCII it is its own @@ -202,13 +212,13 @@ function canonicalRecordV2_0(cmb) { if (!isPlain(m.lineage)) throw new RecordShapeError('lineage is not an object'); for (const k of Object.keys(m.lineage)) if (k !== 'parents' && k !== 'method') throw new RecordShapeError('lineage carries an unknown member'); // Optional, unsigned and dropped (§8.8.4), but typed as the schema types it. - if (m.lineage.method !== undefined && (typeof m.lineage.method !== 'string' || m.lineage.method.length === 0)) throw new RecordShapeError('lineage.method is not text'); + if (m.lineage.method !== undefined && (typeof m.lineage.method !== 'string' || m.lineage.method.length === 0 || !isWellFormed(m.lineage.method))) throw new RecordShapeError('lineage.method is not text'); const parents = refs(m.lineage.parents, 'lineage.parents', CMB_KEY); // No parents signs as no lineage (§8.8.4), so it is held as none. lineage = parents.length > 0 ? { parents } : null; } if (m.sigAlg !== 'ed25519') throw new RecordShapeError('sigAlg is not ed25519'); - if (typeof m.sig !== 'string' || !SIG.test(m.sig)) throw new RecordShapeError('sig is not an unpadded Ed25519 signature'); + if (typeof m.sig !== 'string' || !SIG.test(m.sig)) throw new RecordShapeError('sig is not an Ed25519 signature in canonical unpadded base64url'); const metadata = { key: m.key, addressScheme: m.addressScheme, assertionId: m.assertionId, signatureSuite: m.signatureSuite, createdByNodeId: m.createdByNodeId, createdBy: m.createdBy, createdTimestamp: m.createdTimestamp, diff --git a/lib/core/room-grant.js b/lib/core/room-grant.js index fdbdd996..e17e73b6 100644 --- a/lib/core/room-grant.js +++ b/lib/core/room-grant.js @@ -112,12 +112,12 @@ function grantShapeReason(g) { if (g.type !== 'room-join') return 'type'; if (typeof g.room !== 'string' || !/^[a-z0-9._-]{1,64}$/.test(g.room)) return 'room'; if (typeof g.grantee !== 'string' || !UUID.test(g.grantee)) return 'grantee is not a lowercase UUID'; - if (typeof g.granteeKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(g.granteeKey)) return 'granteeKey'; + if (typeof g.granteeKey !== 'string' || !/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(g.granteeKey)) return 'granteeKey'; // canonical (R2-14) if (typeof g.grantedBy !== 'string' || !UUID.test(g.grantedBy)) return 'grantedBy is not a lowercase UUID'; if (!isTime(g.grantedAt)) return 'grantedAt is not an integer'; if (!isTime(g.expiresAt)) return 'expiresAt is not an integer'; if (g.sigAlg !== 'ed25519') return 'sigAlg'; - if (typeof g.sig !== 'string' || !/^[A-Za-z0-9_-]{86}$/.test(g.sig)) return 'sig'; + if (typeof g.sig !== 'string' || !/^[A-Za-z0-9_-]{85}[AQgw]$/.test(g.sig)) return 'sig'; // canonical (R2-14) return null; } diff --git a/lib/public/core.js b/lib/public/core.js index 03cdbd45..070e8175 100644 --- a/lib/public/core.js +++ b/lib/public/core.js @@ -8,7 +8,10 @@ * * This file picks names from lib/core/index.js and adds nothing of its own, except that the two * constants are deep-frozen copies: they are shared values the node itself reads, and a host that wrote - * to the live ones would change every node's behaviour in its process. + * to the live ones would change every node's behaviour in its process; and that `verifyCMB` here is core's + * `verifyRecord` (sym 0.14.3, security review R2-12): the whole check of a record's own bytes — schema, + * bounds, assertion identity, signature, address — with the suite it was signed under and whether it is + * Core Secure. Core's own `verifyCMB` is the signature step a node runs on a record it already projected. * * @copyright 2026 SYM.BOT Ltd * @license Apache-2.0 @@ -54,5 +57,5 @@ exports.encodeCategory = core.encodeCategory; exports.encodeForSVAF = core.encodeForSVAF; exports.categoryKeyV1 = core.categoryKeyV1; exports.kernelId = core.kernelId; -exports.verifyCMB = core.verifyCMB; +exports.verifyCMB = core.verifyRecord; Object.freeze(module.exports); diff --git a/lib/roster-keys.js b/lib/roster-keys.js index fd7c07b8..edf93dc9 100644 --- a/lib/roster-keys.js +++ b/lib/roster-keys.js @@ -129,7 +129,8 @@ const MAX_CONFLICTS = 1024; /** An Ed25519 public key as the wire carries it: 32 raw bytes, unpadded base64url. */ function isIdentityKey(k) { - return typeof k === 'string' && /^[A-Za-z0-9_-]{43}$/.test(k) && Buffer.from(k, 'base64url').length === 32; + // Canonical only (R2-14): the last character's 2 unused bits are zero, so one key has one spelling. + return typeof k === 'string' && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(k) && Buffer.from(k, 'base64url').length === 32; } const isNodeId = (id) => typeof id === 'string' && id.length > 0 && id.length <= 256; diff --git a/lib/session.js b/lib/session.js index 1c70d9ef..07ddc7f2 100644 --- a/lib/session.js +++ b/lib/session.js @@ -77,7 +77,7 @@ const { MAX_SEALED_CHARS } = require('./core/cmb-encoder'); const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; // 32 bytes in canonical unpadded base64url (one spelling per key: the authority statements' PUBLIC_KEY). const { PUBLIC_KEY: B64_32 } = require('./core/authority'); -const SIG_RE = /^[A-Za-z0-9_-]{86}$/; +const SIG_RE = /^[A-Za-z0-9_-]{85}[AQgw]$/; // canonical unpadded base64url, one spelling (R2-14) const EXT_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/; let SEQ = 0; diff --git a/tests/public-surface.test.js b/tests/public-surface.test.js index 3f1e8f13..983c762a 100644 --- a/tests/public-surface.test.js +++ b/tests/public-surface.test.js @@ -145,7 +145,7 @@ describe('the public surface of the installed package', () => { assert.deepEqual(b.uniform, { focus: 1, issue: 1, intent: 1, motivation: 1, commitment: 1, perspective: 1, mood: 1 }); assert.deepEqual(b.encode, [32, 32, 32]); assert.match(b.categoryKey, /^[0-9a-f]{64}$/); - assert.deepEqual(b.verifyUnsigned, { signed: false, valid: false }); + assert.deepEqual(b.verifyUnsigned, { signed: false, valid: false, suite: 'mmp-sig-v2', coreSecure: false }); assert.equal(b.kernel, 'ngram-h32', 'with SYM_SEMANTIC_ENCODER=off the kernel is the n-gram one'); assert.deepEqual(b.semantic, ['h1', 'h2']); assert.equal(b.cannotWrite, 'refused', 'the frozen constants cannot be written'); diff --git a/tests/security-0.14.3-verify.test.js b/tests/security-0.14.3-verify.test.js new file mode 100644 index 00000000..8c1f652b --- /dev/null +++ b/tests/security-0.14.3-verify.test.js @@ -0,0 +1,216 @@ +'use strict'; + +require('./_isolate-home'); // redirect $HOME before lib/config loads + +/** + * sym 0.14.3 security review (R2): the record verifier's findings, each a regression test that fails on + * 632b95d for its own reason. + * + * R2-12 the public verifyCMB reported a record carrying unsigned members, a wrong assertionId, or a + * 0.13-suite record with an attached application, as valid:true + * R2-13 a lone surrogate encodes as U+FFFD, so one signature verified two strings + * R2-14 non-canonical base64url spellings of one key or signature were accepted + * R2-21 an invite parse error echoed the URL, relay token included + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const pub = require('../lib/public/core'); +const core = require('../lib/core'); +const { verifyStrict } = require('../lib/core/ed25519'); +const { canonicalRecordV2_0 } = require('../lib/core/record-canonical'); +const { isIdentityKey } = require('../lib/roster-keys'); +const { grantShapeReason } = require('../lib/core/room-grant'); +const { parseInvite } = require('../lib/invite'); +const { identity, signedRecord, applicationFor } = require('./_core-secure'); + +const alice = identity('alice'); +const copy = (o) => JSON.parse(JSON.stringify(o)); +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; +/** Every spelling of a base64url string whose last character carries `usedMask` bits, the canonical one first. */ +function spellings(s, usedMask) { + const i = B64.indexOf(s[s.length - 1]); + const out = [s]; + for (let j = 0; j < 64; j++) if (j !== i && (j & usedMask) === (i & usedMask)) out.push(s.slice(0, -1) + B64[j]); + return out; +} +/** A 0.13-suite (mmp-sig-v2) record, as a 0.13 node minted and signed it. */ +function legacyRecord(author = alice) { + const r = core.createCMB({ categories: { focus: 'deploy finished', mood: { text: 'calm', valence: 0.2 } }, createdBy: author.name, room: 'default' }); + core.signCMB(r, author.privateKey); + return r; +} + +describe('R2-12: the public verifyCMB checks the record\'s own bytes, and says which suite signed it', () => { + it('R2-12: a v2.0 record carrying a member its signature does not cover is invalid', () => { + const base = signedRecord(alice, { categories: { focus: 'v2.0 observation' } }); + assert.strictEqual(pub.verifyCMB(copy(base), alice.publicKey).valid, true, 'the untouched record verifies'); + const cases = { + 'metadata.approved': (r) => { r.metadata.approved = true; }, + 'top-level payload': (r) => { r.payload = { action: 'rm -rf' }; }, + 'a category member': (r) => { r.categories.focus.weight = 9; }, + 'a meta member': (r) => { r.categories.issue.meta.trusted = true; }, + 'a lineage member': (r) => { r.metadata.lineage = { parents: [], approvedBy: 'root' }; }, + }; + for (const [what, mutate] of Object.entries(cases)) { + const r = copy(base); mutate(r); + const v = pub.verifyCMB(r, alice.publicKey); + assert.strictEqual(v.valid, false, `${what}: ${JSON.stringify(v)}`); + assert.strictEqual(v.coreSecure, false, what); + } + }); + + it('R2-12: a v2.0 record naming another assertionId is invalid, checked before the signature', () => { + const r = copy(signedRecord(alice, { categories: { focus: 'v2.0 observation' } })); + r.metadata.assertionId = 'asrt-' + '0'.repeat(64); + const v = pub.verifyCMB(r, alice.publicKey); + assert.strictEqual(v.valid, false); + assert.strictEqual(v.error, 'assertion-id-mismatch'); + // Under the wrong key too: the assertion identity is refused first, never called a forgery. + assert.strictEqual(pub.verifyCMB(r, identity('other').publicKey).error, 'assertion-id-mismatch'); + }); + + it('R2-12: a genuine v2.0 record is valid and Core Secure, with its assertionId and its signed projection', () => { + const r = signedRecord(alice, { categories: { focus: 'v2.0 observation', mood: { text: 'calm' } } }); + const wire = copy(r); + wire.categories.mood.valence = -1; // carried, unsigned (§8.8.4): allowed, and not in the projection + const v = pub.verifyCMB(wire, alice.publicKey); + assert.strictEqual(v.valid, true, JSON.stringify(v)); + assert.strictEqual(v.signed, true); + assert.strictEqual(v.suite, 'mmp-sig-v2.0'); + assert.strictEqual(v.coreSecure, true); + assert.strictEqual(v.assertionId, r.metadata.assertionId); + assert.ok(Object.isFrozen(v.record) && Object.isFrozen(v.record.metadata), 'the projection is frozen'); + assert.strictEqual(v.record.categories.mood.valence, undefined, 'the projection holds only what is signed'); + assert.strictEqual(pub.verifyCMB(wire, alice.publicKey, { coreSecure: true }).valid, true); + const forged = copy(r); forged.metadata.createdTimestamp += 1; forged.metadata.assertionId = core.assertionIdV2_0(forged); + assert.strictEqual(pub.verifyCMB(forged, alice.publicKey).error, 'bad-signature'); + }); + + it('R2-12: the payload sym keeps beside a record it minted stays valid; a payload that is not the signed one is refused', () => { + const payload = { action: 'deploy', build: 42 }; + const r = signedRecord(alice, { categories: { focus: 'with a payload' }, application: applicationFor(Buffer.from(JSON.stringify(payload)), { schema: 'https://sym.bot/schema/payload-v1.json' }) }); + const stored = copy({ ...r, payload }); + assert.strictEqual(pub.verifyCMB(stored, alice.publicKey).valid, true, 'the decoded copy of the signed section is allowed'); + stored.payload.build = 43; + const v = pub.verifyCMB(stored, alice.publicKey); + assert.strictEqual(v.valid, false); + assert.strictEqual(v.error, 'unsigned-member'); + }); + + it('R2-12: a genuine 0.13 record stays valid, reported as the 0.13 suite and not Core Secure; { coreSecure: true } refuses it', () => { + const r = legacyRecord(); + const v = pub.verifyCMB(copy(r), alice.publicKey); + assert.strictEqual(v.valid, true, JSON.stringify(v)); + assert.strictEqual(v.suite, 'mmp-sig-v2'); + assert.strictEqual(v.coreSecure, false); + // A 0.13 store kept annotations beside the two sections: still what it was, never flipped. + const stored = { ...copy(r), payload: { a: 1 }, provenance: { via: 'x' }, tether: { verdict: 'tethered' } }; + assert.strictEqual(pub.verifyCMB(stored, alice.publicKey).valid, true, 'a 0.13 store reads as it did'); + const strict = pub.verifyCMB(copy(r), alice.publicKey, { coreSecure: true }); + assert.strictEqual(strict.valid, false); + assert.strictEqual(strict.error, 'not-core-secure'); + }); + + it('R2-12: a 0.13 record whose sections carry an application, a node id, or a member its suite does not sign is invalid', () => { + const data = Buffer.from(JSON.stringify({ action: 'transfer', to: 'mallory', amount: 1e6 })); + const cases = { + application: (r) => { r.metadata.application = applicationFor(data); }, + createdByNodeId: (r) => { r.metadata.createdByNodeId = alice.nodeId; }, + assertionId: (r) => { r.metadata.assertionId = 'asrt-' + '0'.repeat(64); }, + 'an eighth category': (r) => { r.categories.approval = { text: 'granted' }; }, + }; + for (const [what, mutate] of Object.entries(cases)) { + const r = copy(legacyRecord()); mutate(r); + const v = pub.verifyCMB(r, alice.publicKey); + assert.strictEqual(v.valid, false, `${what}: ${JSON.stringify(v)}`); + assert.strictEqual(v.error, 'unsigned-member', what); + assert.strictEqual(v.suite, 'mmp-sig-v2', what); + } + }); + + it('R2-12: an unsigned record says so, with its suite', () => { + const r = core.createCMB({ categories: { focus: 'unsigned' }, createdBy: 'probe' }); + assert.deepStrictEqual(pub.verifyCMB(r, alice.publicKey), { signed: false, valid: false, suite: 'mmp-sig-v2', coreSecure: false }); + }); +}); + +describe('R2-13: one signature verifies one string', () => { + it('R2-13: a text re-spelt with a lone surrogate does not verify under the signature of its U+FFFD spelling', () => { + const rec = signedRecord(alice, { categories: { focus: 'build � ok' } }); + assert.strictEqual(pub.verifyCMB(copy(rec), alice.publicKey).valid, true); + const forged = JSON.parse(JSON.stringify(rec).replace('�', '\\ud800')); + assert.notStrictEqual(forged.categories.focus.text, rec.categories.focus.text, 'the scene: two different strings'); + assert.throws(() => canonicalRecordV2_0(forged), (e) => e.code === 'ERECORDSHAPE' && /well-formed/.test(e.reason)); + assert.strictEqual(pub.verifyCMB(forged, alice.publicKey).valid, false); + assert.strictEqual(core.verifyCMB(forged, alice.publicKey).valid, false, 'core\'s signature step refuses it too'); + }); + + it('R2-13: a signed label re-spelt with a lone surrogate does not verify', () => { + const rec = signedRecord({ ...alice, name: 'ali�ce' }); + const forged = JSON.parse(JSON.stringify(rec).replace('�', '\\udfff')); + assert.throws(() => canonicalRecordV2_0(forged), (e) => e.code === 'ERECORDSHAPE'); + assert.strictEqual(core.verifyCMB(forged, alice.publicKey).valid, false); + assert.strictEqual(pub.verifyCMB(forged, alice.publicKey).valid, false); + const legacy = legacyRecord({ ...alice, name: 'ali�ce' }); + const legacyForged = JSON.parse(JSON.stringify(legacy).replace('�', '\\udfff')); + assert.strictEqual(core.verifyCMB(legacyForged, alice.publicKey).valid, false, 'the 0.13 suite too'); + }); + + it('R2-13: a record is minted well formed: a lone surrogate a host passes is minted as U+FFFD, and verifies', () => { + const r = core.createCMB({ categories: { focus: 'cut inside a pair \ud83d' }, createdBy: 'ag\udc00ent', createdByNodeId: alice.nodeId, emitV2: true, room: 'default' }); + assert.strictEqual(r.categories.focus.text, 'cut inside a pair �'); + assert.strictEqual(r.metadata.createdBy, 'ag�ent'); + r.metadata.assertionId = core.assertionIdV2_0(r); + core.signCMB(r, alice.privateKey); + assert.strictEqual(pub.verifyCMB(copy(r), alice.publicKey).valid, true); + }); +}); + +describe('R2-14: one key, one spelling; one signature, one spelling', () => { + it('R2-14: the projection takes only the canonical spelling of a record\'s signature', () => { + const rec = signedRecord(alice); + const all = spellings(rec.metadata.sig, 0x30); + assert.strictEqual(all.length, 16, 'the scene: 16 spellings decode to the same 64 bytes'); + const projected = all.filter((sig) => { try { canonicalRecordV2_0({ ...copy(rec), metadata: { ...copy(rec).metadata, sig } }); return true; } catch { return false; } }); + assert.deepStrictEqual(projected, [rec.metadata.sig]); + }); + + it('R2-14: verifyStrict takes only canonical base64url for keys and signatures', () => { + const rec = signedRecord(alice); + const msg = core.signingPayloadV2_0(rec); + const sig = rec.metadata.sig; + assert.strictEqual(verifyStrict(msg, alice.publicKey, sig), true); + for (const bad of [sig + '==', sig.slice(0, 40) + ' \n' + sig.slice(40), sig.replace(/-/g, '+').replace(/_/g, '/'), ...spellings(sig, 0x30).slice(1)]) { + if (bad === sig) continue; + assert.strictEqual(verifyStrict(msg, alice.publicKey, bad), false, JSON.stringify(bad)); + } + for (const k of spellings(alice.publicKey, 0x3c).slice(1)) assert.strictEqual(verifyStrict(msg, k, sig), false, k); + assert.strictEqual(verifyStrict(msg, Buffer.from(alice.publicKey, 'base64url'), Buffer.from(sig, 'base64url')), true, 'bytes are bytes'); + }); + + it('R2-14: the key registry and a room-join grant take one spelling per key and signature', () => { + const keys = spellings(alice.publicKey, 0x3c); + assert.strictEqual(keys.length, 4); + assert.deepStrictEqual(keys.filter(isIdentityKey), [alice.publicKey]); + const grant = { type: 'room-join', room: 'r1', grantee: alice.nodeId, granteeKey: alice.publicKey, grantedBy: identity('o').nodeId, grantedAt: 1, expiresAt: 2, sigAlg: 'ed25519', sig: signedRecord(alice).metadata.sig }; + assert.strictEqual(grantShapeReason(grant), null); + assert.strictEqual(grantShapeReason({ ...grant, granteeKey: keys[1] }), 'granteeKey'); + assert.strictEqual(grantShapeReason({ ...grant, sig: spellings(grant.sig, 0x30)[1] }), 'sig'); + }); +}); + +describe('R2-21: an invite error never repeats the invite', () => { + it('R2-21: a malformed team invite\'s error does not carry its relay token', () => { + const secret = 'SECRET-RELAY-TOKEN-abcdef0123456789'; + for (const url of [ + `sym://team/room1?relay=wss%3A%2F%2Fr.example&token=${secret}#frag x`, + `not-a-scheme team/room1?token=${secret}`, + `sym://team/room1?relay=wss%3A%2F%2Fr.example&token=${secret}&node=bad`, + ]) { + const r = parseInvite(url); + assert.ok(r.error, url); + assert.ok(!r.error.includes(secret) && !r.error.includes('SECRET'), r.error); + } + }); +}); From e94ccf1e3f729d990eca16154894fd2a7c1be6c4 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:14:47 +0100 Subject: [PATCH 16/20] CHANGELOG 0.14.3 Security; SECURITY.md: the narrowed grant source, no penalty for record signatures, size budgets, binding lifetimes --- CHANGELOG.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 30 ++++++++++++++++------- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f725f6a..9ad00dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,73 @@ or expose a key and the small ones beside them. The wire and the public surface - A pinned start leaves another agent's 0.13 directory (same name, different nodeId) unmigrated, as above; with `SYM_IDENTITY_DIR` set and that identity in the node directory it halts with the instructions given for that case. +### Security + +An independent security review (R2) of the 0.14 core reproduced each finding below; each fix has a test in +`tests/security-0.14.3*.test.js` that fails on 0.14.3's base (632b95d) and passes here. + +- **A grant binds a key only when it is mesh-wide and rooted in the anchor through admins (R2-1, high).** Any holder + of a delegating grant, even one scoped to one world, could bind any unbound nodeId to its own key at every node + holding the grant; the victim's own session was then refused as an identity conflict, and once the squatter + connected the binding was stored `proven` and outlived the revoke. Now only an unscoped grant whose chain is the + anchor and `admin` grants is a key-binding source; the grant view binds only a nodeId nothing else binds, so a + session proving another key is admitted on its own proof and the conflict recorded (never refused); a session + proving the key the grant names stores nothing (never promoted to `proven`), so a revoke removes what the grant + bound. This narrows MMP §6.6.9 (a spec erratum is proposed). +- **A relay can no longer get an honest peer closed and banned as a forger (R2-2, high).** The `cmb-encrypted` AAD + binds the key, assertionId, author nodeId, room and to; the signature, `createdBy`, `createdTimestamp`, lineage + and the application descriptor travel in clear. The receiver now recomputes the AAD-bound assertionId before the + signature (§8.8.5 order): a mismatch is altered clear metadata, dropped and counted (`cmb-clear-metadata-altered`). + A bad record signature is never a penalty (the signature is outside the AEAD): dropped and counted + (`cmb-signature-unattributable`); one relayed in another's name keeps the relayed path. The forgery penalty stays + for attestations, checkpoints and witnesses, which travel wholly sealed. +- **One admitted peer can no longer buy CPU, memory or disk without bound (R2-3, high).** The per-session + verification and record budgets are also charged by size (1 MiB/s burst 4 MiB before verification, 128 KiB/s + burst 1.5 MiB before SVAF); the SVAF kernel reads at most 16 KiB of a text; the local-state context reads at most + 2 KiB of an entry and 16 KiB in all, and is re-encoded at most once a second; the store's entry cache is an LRU + (2,048 entries, 32 M characters). The review's 24 records of 2 × 200 KB: 15 s CPU before, 0.4 s now. +- Gossip signature checks are bounded whatever the number of sessions: every new attestation, checkpoint, witness or + authority statement takes one global token before verification, through the sending peer's fair share. The ceiling + after verification is gone (R2-4). +- LAN client-hellos are rate-limited before any crypto: a token bucket per remote host (8/s, burst 32) and one over + all hosts (64/s, burst 256). A hello past either is closed unanswered (R2-5). +- Actionable delivery is de-duplicated by assertion identity (§8.8.2, R2-6): two room records with the same + categories and different signed application sections are both delivered, and one memory de-duplicates is still + delivered (`remixed: false`); a replay of either is not. An actionable room record signed more than 30 days ago is + refused as stale (its delivery mark is kept that long). +- Equivocation evidence and this node's own-witness memory are never evicted. When full, new attesters are refused + or not witnessed, and that is counted (R2-7). +- `aggregateAttestations` weighs only attestations naming the held record's `assertionId`, or `opts.assertionId`. The + result carries `assertionId` (R2-8). +- Earned key bindings age out (R2-9): a new one (everything it verified fell within a day) after 30 days unseen, and + new bindings hold at most a quarter of the registry; an established one after 180 days unseen. One directed record + from a fresh identity used to earn a binding that never expired. +- A gated room now admits its grant-holders over a relay: a sealed frame goes to any confirmed session for its relay + `from`, so the room-join reaches the door, which still decides (R2-11). +- Public `verifyCMB` (`@sym-bot/sym/core`) checks the record's own bytes: a member the signature does not cover, or a + wrong assertionId (checked before the signature), makes a record invalid. The result names the signing `suite` and + says `coreSecure`, and a valid v2.0 record comes with its frozen signed projection (`record`). A 0.13-suite record + whose sections carry an application, a node id or anything else 0.13 never minted is invalid. A genuine 0.13 record + stays valid unless the caller passes `{ coreSecure: true }` (R2-12). See docs/reference.md. +- One signature verifies one string: a signed string with a lone surrogate is refused, and createCMB mints such text + as U+FFFD (R2-13). +- Keys and signatures are accepted only in canonical unpadded base64url, one spelling each, in the strict verifier, + the record projection, the key registry, room-join grants, handshake proofs and attestations (R2-14). +- A checkpoint that does not link to the chain held, next to a held checkpoint, is refused as `malformed-link` and is + never stored or witnessed (R2-15). +- A second attestation at a held (attester, seq) is refused and kept as fork evidence, never a replacement (R2-16). +- An honest witness of an equivocator's other checkpoint is not muted, and a mute is lifted once the attester's + equivocation is known (R2-17). +- Peer-chosen text no longer reaches a log line or the terminal as given: control characters, line separators and + bidi overrides are written as escapes in every node log line, in mesh-agent output and in `sym listen` / + `sym peers` (R2-18). +- The Bonjour reconnect cache is bounded: at most 512 entries, only advertisements the node dials, the least recently + announced dropped first (R2-20). +- An invite parse error no longer repeats the URL, which can carry a relay token (R2-21). +- The delivery ledger is pruned (R2-22): each mark carries its expiry (a directed one the replay window past its + signed time, others 30 days), expired marks drop at load, the file is rewritten when it doubles, and past 200,000 + marks the author holding the most loses its oldest. A 0.14.2 ledger is read and rewritten with expiries. + ### Tests - `tests/identity-migration-by-id.test.js`: 51 tests (50 run on macOS; one real cross-filesystem test runs on Linux). diff --git a/SECURITY.md b/SECURITY.md index 60e20bbd..fbfb0c2e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. @@ -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 From 262509ac6f9baa71a4647c58d9e1ca7b5e2e40b4 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:16:57 +0100 Subject: [PATCH 17/20] acceptInvite: an invite pins a nodeId a grant view names (the view binds only what nothing else binds, R2-1); the conflict is said --- lib/node.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/node.js b/lib/node.js index 2cfa7da4..d45ee8d1 100644 --- a/lib/node.js +++ b/lib/node.js @@ -3857,8 +3857,11 @@ class SymNode extends EventEmitter { if (!parsed.issuer) return { pinned: false, reason: 'the invite names no issuer key', invite: parsed }; const { nodeId, publicKey } = parsed.issuer; if (nodeId === this.nodeId) return { pinned: false, reason: 'the invite is this node\'s own', invite: parsed }; - const before = this._roster.source(nodeId); + // Only a durable binding is "already bound": a grant view binds only what nothing else binds (R2-1), + // so the pin is made, and a grant naming another key is recorded as a conflict. + const before = this._roster.durable(nodeId) !== undefined ? this._roster.source(nodeId) : undefined; const b = this._roster.bind(nodeId, publicKey, 'pinned'); + if (b.bound && b.conflict === 'grant') this._log(`Invite from ${nodeId.slice(0, 8)} pinned; an in-force grant names another key for it (recorded: sym keys)`); if (!b.bound) { this._log(`Invite from ${nodeId.slice(0, 8)} not pinned: ${b.reason === 'conflict' ? `that nodeId is bound to another key (${before}); resolve with sym keys resolve` : b.reason}`); return { pinned: false, reason: b.reason, issuer: parsed.issuer, invite: parsed }; From 3c6172bcb0e164dd57ebd58c48b43df49d2bfb1b Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:18:37 +0100 Subject: [PATCH 18/20] R2-3 (inbox): the inbox is bounded by size, 32 M characters; read items make room, an unread one never does It was bounded by count only (500, up to 2,000 unread) while an item carries its record's text three times, up to 720 KiB a record, and the whole inbox is rewritten on every change: a peer's large records could make it gigabytes. A delivery that does not fit is refused like one past the unread limit (inbox-full-refused, reason size). --- lib/node.js | 35 +++++++++++++++++++++++++++++++++++ tests/security-0.14.3.test.js | 22 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/lib/node.js b/lib/node.js index d45ee8d1..cfc2cb3e 100644 --- a/lib/node.js +++ b/lib/node.js @@ -192,6 +192,22 @@ const RELAY_FAILURE_WINDOW_MS = 60_000; * evicted, and past this a new delivery gets no inbox id (it is counted and said). */ const INBOX_UNREAD_FACTOR = 4; +/** + * The inbox holds at most this much text in all (security review R2-3, 0.14.3): it was bounded by count + * only (500, and up to 2,000 unread), and an item carries its record's text three times (content, + * categories, record) at up to 720 KiB a record, so a peer's large records could make it gigabytes, + * rewritten whole on every change. Past it read items go first, oldest first; an unread one never + * does, and a delivery that does not fit is refused like one past the unread limit (counted, said). + */ +const INBOX_MAX_CHARS = 32 * 1024 * 1024; +const catsChars = (c) => { let n = 0; if (c && typeof c === 'object') for (const v of Object.values(c)) n += typeof v === 'string' ? v.length : (v && typeof v.text === 'string' ? v.text.length : 0); return n; }; +/** What an inbox item weighs against INBOX_MAX_CHARS: its texts, in characters. */ +function inboxChars(m) { + if (!m || typeof m !== 'object') return 0; + const app = m.record && m.record.metadata && m.record.metadata.application; + const data = app && typeof app.data === 'string' ? app.data.length : 0; + return 256 + (typeof m.content === 'string' ? m.content.length : 0) + catsChars(m.categories) + (m.record ? catsChars(m.record.categories) : 0) + data * (m.payload != null ? 2 : 1); +} /** inbox file -> the SymNode in this process that owns it (see the constructor). */ const INBOX_OWNERS = new Map(); /** A peer that delivered a forged signature is not admitted again for this long (security review D). */ @@ -2663,6 +2679,25 @@ class SymNode extends EventEmitter { key: recordKey(cmb) || entry.key || null, receivedAt: Date.now(), }; + // Bounded by size too (R2-3): read items make room, oldest first; an unread one never does. + const weight = inboxChars(item); + let total = 0; + for (const m of this._inbox) total += inboxChars(m); + if (total + weight > INBOX_MAX_CHARS) { + const keep = []; + for (const m of this._inbox) { + const read = m.seq <= this._inboxCursor || m.acked; + if (read && total + weight > INBOX_MAX_CHARS) { total -= inboxChars(m); continue; } + keep.push(m); + } + this._inbox = keep; + if (total + weight > INBOX_MAX_CHARS) { + this._metrics.inboxDropped = (this._metrics.inboxDropped ?? 0) + 1; + this._sayOncePerMinute('inbox-full-size', `INBOX FULL — ${Math.round(total / 1048576)} M characters unread and nothing draining this node: a new delivery is not put in the inbox (its record is still in memory if it was admitted)`); + this.emit('metric', { type: 'inbox-full-refused', reason: 'size', key: item.key, directed: item.directed }); + return; + } + } // The id is assigned only once the item is durably in the inbox: journalled first. A write that // fails assigns nothing, so nothing is announced that a fetch could not find. if (!this._journalInbox({ e: item })) { diff --git a/tests/security-0.14.3.test.js b/tests/security-0.14.3.test.js index fd01b5ec..b002015a 100644 --- a/tests/security-0.14.3.test.js +++ b/tests/security-0.14.3.test.js @@ -346,3 +346,25 @@ describe('R2-9: earned key bindings age out, and new ones hold a share of the re assert.strictEqual(r.expire(), 1, 'an earned binding no longer lives for ever'); }); }); + +describe('R2-3 (inbox): the inbox is bounded by size, never by dropping an unread delivery', () => { + const big = (n, seed) => { let t = ''; let j = 0; while (t.length < n) t += `inbox ${seed}_${j++} `; return t.slice(0, n); }; + + it('R2-3: large deliveries fill the inbox only up to its size; read ones make room, unread ones are kept', async () => { + try { + const node = mk('r23-inbox'); + const metrics = []; node.on('metric', (m) => { if (m.type === 'inbox-full-refused') metrics.push(m); }); + const text = big(700 * 1024, 'x'); + const deliver1 = (i) => node.emit('cmb-accepted', { key: `cmb-${String(i).padStart(64, '0')}`, content: text, cmb: { categories: { focus: { text } }, metadata: {} }, verified: false }); + for (let i = 0; i < 40; i++) deliver1(i); + const chars = node._inbox.reduce((n, m) => n + (m.content || '').length + JSON.stringify(m.categories || '').length, 0); + assert.ok(chars <= 34 * 1024 * 1024, `the inbox holds a bounded amount (${Math.round(chars / 1048576)} M)`); + assert.ok(metrics.length > 0, 'a delivery that does not fit is refused, counted'); + const unread = node._inbox.length; + node.inbox({ limit: 500 }); // read them all + deliver1(1000); + assert.ok(node._inbox.some((m) => m.key === `cmb-${String(1000).padStart(64, '0')}`), 'read items make room for a new delivery'); + assert.ok(node._inbox.length <= unread, 'what was read went'); + } finally { await stopAll(); } + }); +}); From 3731692ecc5cc160c0c544196fced5c36022d2b1 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:18:48 +0100 Subject: [PATCH 19/20] CHANGELOG: the inbox size bound, and the store's disk growth as it stands --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad00dac..c4c93dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,7 +130,10 @@ An independent security review (R2) of the 0.14 core reproduced each finding bel verification and record budgets are also charged by size (1 MiB/s burst 4 MiB before verification, 128 KiB/s burst 1.5 MiB before SVAF); the SVAF kernel reads at most 16 KiB of a text; the local-state context reads at most 2 KiB of an entry and 16 KiB in all, and is re-encoded at most once a second; the store's entry cache is an LRU - (2,048 entries, 32 M characters). The review's 24 records of 2 × 200 KB: 15 s CPU before, 0.4 s now. + (2,048 entries, 32 M characters); the inbox holds at most 32 M characters (read items make room, oldest first; an + unread one never goes, and a delivery that does not fit is refused and counted, `inbox-full-refused`). The review's + 24 records of 2 × 200 KB: 15 s CPU before, 0.4 s now. The store itself is still unbounded on disk by default + (retention is unlimited unless configured); the size budget bounds how fast one session can grow it. - Gossip signature checks are bounded whatever the number of sessions: every new attestation, checkpoint, witness or authority statement takes one global token before verification, through the sending peer's fair share. The ceiling after verification is gone (R2-4). From edc54a4eb655d64abb08a3553cb6a97452b79433 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 16:23:04 +0100 Subject: [PATCH 20/20] Test: roster-fill holds the new-binding share (R2-9), a quarter of the registry --- tests/roster-and-peer-table-bounds.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/roster-and-peer-table-bounds.test.js b/tests/roster-and-peer-table-bounds.test.js index 00ce033d..a9553f05 100644 --- a/tests/roster-and-peer-table-bounds.test.js +++ b/tests/roster-and-peer-table-bounds.test.js @@ -51,8 +51,9 @@ describe('the key registry cannot be locked out by identity churn (roster-fill.j r.bind(honest.nodeId, honest.publicKey, 'proven'); r.noteSeen(honest.nodeId); // seen once, verified nothing let full = 0; for (let i = 0; i < 5000; i++) { t++; if (r.bind(`fill-${i}`, identity().publicKey, 'proven').reason === 'full') full++; } - assert.strictEqual(r.size(), 1000, 'bounded'); - assert.strictEqual(full, 4001, 'past the bound a newcomer is refused a durable binding (held for its session only)'); + // New bindings hold at most a quarter of the registry (R2-9, 0.14.3); past it a newcomer is refused. + assert.strictEqual(r.size(), 1000 / 4, 'bounded'); + assert.strictEqual(full, 5000 - (1000 / 4 - 1), 'past the bound a newcomer is refused a durable binding (held for its session only)'); assert.strictEqual(r.get(honest.nodeId), honest.publicKey, 'the honest binding was never displaced'); assert.strictEqual(r.bind(honest.nodeId, identity('squatter').publicKey, 'proven').reason, 'conflict', 'and its nodeId cannot be squatted'); assert.strictEqual(r.evictedCount(), 0, 'nothing is evicted before it expires');