Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d7fa80a
Identity migration: an agent no longer loses its key to the 0.13 -> b…
sym-bot Oct 14, 2026
0e31e38
0.14.3
sym-bot Oct 14, 2026
f0e6905
Identity halt: say that the link has to go too
sym-bot Oct 14, 2026
6bd36ec
Identity migration: one migrator at a time (a stalled four-process ru…
sym-bot Oct 14, 2026
4f12407
Test: the waiting start is judged by when it returned, not by how lon…
sym-bot Oct 14, 2026
051f44b
CHANGELOG 0.14.3: the fuzzer's numbers as measured
sym-bot Oct 14, 2026
632b95d
A start pinned to a nodeId migrates the 0.13 directory too
sym-bot Oct 14, 2026
85ae65c
R2-1: only an unscoped anchor/admin-chain grant binds a key; a grant …
sym-bot Oct 14, 2026
84559b2
R2-2: a record whose signed bytes the AEAD did not authenticate is dr…
sym-bot Oct 14, 2026
e5b6d31
R2-3, R2-6, R2-22: size-charged budgets, bounded encoder/context/cach…
sym-bot Oct 14, 2026
34f94e2
R2-9: earned key bindings age out, and new ones hold a quarter of the…
sym-bot Oct 14, 2026
f843fac
Security R2-5/11/18/20: LAN hello rate, gated rooms over a relay, pee…
sym-bot Oct 14, 2026
7d93044
Security 0.14.3: sym-attest-v1 findings R2-4, R2-7, R2-8, R2-15, R2-1…
sym-bot Oct 14, 2026
b1e6cd3
Invite: a parse error never repeats the URL, which can carry a relay …
sym-bot Oct 14, 2026
8b03a4d
Public verifyCMB checks the record's own bytes; one string, one key, …
sym-bot Oct 14, 2026
e94ccf1
CHANGELOG 0.14.3 Security; SECURITY.md: the narrowed grant source, no…
sym-bot Oct 14, 2026
262509a
acceptInvite: an invite pins a nodeId a grant view names (the view bi…
sym-bot Oct 14, 2026
3c6172b
R2-3 (inbox): the inbox is bounded by size, 32 M characters; read ite…
sym-bot Oct 14, 2026
3731692
CHANGELOG: the inbox size bound, and the store's disk growth as it st…
sym-bot Oct 14, 2026
edc54a4
Test: roster-fill holds the new-binding share (R2-9), a quarter of th…
sym-bot Oct 14, 2026
File filter

Filter by extension

Filter by extension


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

Large diffs are not rendered by default.

23 changes: 16 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>/log.jsonl`; `record(entry)` appends one plain
Expand Down
42 changes: 29 additions & 13 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,17 @@ Report a vulnerability privately to info@sym.bot (the address in package.json).
binding is earned — an admitted verified record, a verified record its author signed to this
node and this node accepted for delivery (directed exchange is a relationship, so a squatter
cannot take over a conversation once its peer's sessions end), an out-of-band pin (invite, Legacy
Import route), or an in-force grant (MMP §6.6.9: a view over the in-force set, never stored, binding
only an unbound nodeId, and ending with the grant) — and never replaces it: a different key, from any source or
while a session holds the nodeId, is a recorded conflict an operator resolves (a session
proving it is closed with error 1009 `IDENTITY_CONFLICT`, sealed). A handshake, or a second one,
earns nothing; nothing is evicted before it expires; a `proven` binding that verified nothing
expires after 30 days unseen. This node's own nodeId is always its own key. Keys pinned before
Import route), or an in-force grant (MMP §6.6.9 as narrowed in 0.14.3: only an unscoped grant whose
chain is the anchor and `admin` grants; a view over the in-force set, never stored, binding only a
nodeId nothing else binds, and ending with the grant: a session proving the key it names stores
nothing, and a session proving another key is admitted on its own proof with the conflict recorded,
never refused) — and never replaces it: a different key, from any source or while a session holds
the nodeId, is a recorded conflict an operator resolves (a session proving it is closed with error
1009 `IDENTITY_CONFLICT`, sealed). A handshake, or a second one, earns nothing; nothing is evicted
before it expires; a `proven` binding that is new (it verified nothing, or only within one day)
expires after 30 days unseen, and new bindings hold at most a quarter of the registry; one that
verified records on occasions a day or more apart expires after 180 days unseen. A scoped grant
binds no key: it confers its role inside its scope only. This node's own nodeId is always its own key. Keys pinned before
0.14 — from unproven hellos, or from 0.13 grants never checked against their grantors — become
`legacy-claim`: expected, never trusted to verify, so a squatter racing the upgrade cannot take
an honest node's id.
Expand Down Expand Up @@ -118,9 +123,16 @@ Report a vulnerability privately to info@sym.bot (the address in package.json).
must be of prime order too. Tested with the published vectors (ed25519-strict-v2.json) and, at each
of those sites, with two signatures Node's `crypto.verify` accepts and the rule rejects (the
identity key's universal forgery, and R = identity with S = k·a).
- **A forgery in a session's own name ends the session.** A statement that names the session's own
peer as its signer and does not verify under the key that session proved closes it, and its
nodeId is not admitted again for 60 s. A statement the session relays for another signer is
- **A forgery in a session's own name ends the session — when the whole statement travelled sealed.**
An attestation, checkpoint or witness (sealed whole in `control-encrypted`) that names the session's
own peer as its signer and does not verify under the key that session proved closes it, and its
nodeId is not admitted again for 60 s. A RECORD is never charged this way (0.14.3): in `cmb-encrypted`
its signature, `createdBy`, `createdTimestamp`, lineage and application descriptor travel in clear,
outside the AEAD, so a relay or an on-path writer could break them on an honest record. The
receiver checks the AAD-bound assertionId first (§8.8.5) and drops and counts a record whose
assertionId or signature fails (`cmb-clear-metadata-altered`, `cmb-signature-unattributable`). A
verification lane also spends a record's size (1 MiB/s, burst 4 MiB), and the SVAF lane its size too
(128 KiB/s, burst 1.5 MiB). A statement the session relays for another signer is
verified under this node's binding for that signer, which may differ from the relayer's (a local
view); one that fails is dropped and counted (`relayed-signature-unverified`), and the relayer is
not charged. Not charging it does not make forgeries free: each session's records spend a
Expand Down Expand Up @@ -215,10 +227,14 @@ Every store a peer can feed has a fixed bound. Reaching one never stops the node
is never dropped for budget and never spends more than the lane holds. Resolving, which is linear
in the held set and not charged to any lane, is paced instead: a node resolves when it settles, at
most once per twice the time its last resolution took, and a pull's end is decided at the next
settle, never by a resolution forced for it. A ceiling of 4,000
verified statements a second in all (burst 20,000) is spent only after the check; checkpoints at most
4 a second per attester (burst 128); at most 1,024 attesters, 32 checkpoints each, 256 witnesses
per position.
settle, never by a resolution forced for it. One global budget of 4,000 checks a second in all
(burst 20,000) is spent before the check too, each peer through its fair share of it (4,000 / n a
second, n the peers that sent gossip in the last 10 s), so the checks made are bounded whatever the
number of sessions and no peer spends another's share (0.14.3); checkpoints at most 4 a second per
attester (burst 128); at most 1,024 attesters, 32 checkpoints each, 256 witnesses per position. An
attester this node holds equivocation evidence against is never evicted (when every place holds
one, a new attester is refused), and what this node witnessed is never forgotten (past 65,536
attesters, a new one is not witnessed).
- **Records** (MMP §8.8.6, MMP 2.0 update 1): a category's text at most 256 KiB after NFC, the seven
at most 512 KiB, the two-section record's RFC 8785 length at most 720 KiB, so every record fits
one sealed frame. This node mints nothing larger, and refuses a received record over any limit
Expand Down
13 changes: 7 additions & 6 deletions bin/sym.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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)}`);
}
}

Expand Down
32 changes: 32 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading