Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 43 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,33 @@ patch for one of those cases made another. R1 proved eleven by running them. 0.1
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).
- **Only the anchor certifies which key is a nodeId's, and a certification outranks a session (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 whole chain is unscoped `admin` grants up to the anchor is a key-binding source
(`AuthorityStore.bindsKey`): an anchor certification. A scoped grant, or one under an issuer or a validator,
confers its role inside its scope, binds no key and refuses no one.
- One precedence decides which key verifies a nodeId: the anchor pin and this node's own key, then an operator's
out-of-band pin, then an anchor certification, then trust on first proven use (a proven session, an earned
binding), then a legacy claim.
- While a certification of X -> K1 is in force, a session proving X with another key is refused (1009, recorded in
`roster-conflicts.jsonl`, shown in `sym status`, metric `identity-conflict`) and a live one is closed; a record
claiming X signed by another key is not verified; a stored binding of another key is superseded: kept on disk,
marked (`sym keys` lists it `superseded`), recorded, never used to verify, never expiring and outside the
new-binding share. A session proving K1 stores nothing on the grant's account.
- When the certification is revoked or expires, a nodeId nothing else binds is unbound again (the next session
binds it by first proven use); a superseded binding stays conflicted, failing closed (it verifies nothing and
every session for X is refused) until the operator resolves it (`sym keys resolve`) or a pin or a new
certification settles it. Certifications naming two keys for one nodeId certify neither.
- A pin outranks a certification, and their disagreement is recorded; a pin made while a session holds the nodeId
under another key closes that session. An invite still never repoints a stored binding the authority has not
superseded.
- A grant never binds this node's own nodeId, and gives its role only to what verifies under its subject key: an
attestation is weighed under the key that verified it. `grantRole` takes a certified key; `keyBindings()` lists
the key that decides each nodeId (the certified one, whatever is stored) and leaves a conflicted nodeId out.
This changes MMP §3.4 and §6.6.9, which say a binding is never replaced and a grant binds only an unbound nodeId;
the spec text follows once this is tested in release.
- **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
Expand Down Expand Up @@ -232,6 +251,19 @@ An independent security review (R2) of the 0.14 core reproduced each finding bel
identity; across volumes nothing is renamed and the source is kept aside; an interrupted migration leaves the 0.13
directory as it was; the migration-lock tests are replaced by no-wait tests; a live 0.13 holder refuses; a pin whose
identity is in the 0.13 directory halts instead of "absent".
- `tests/security-0.14.3-cert.test.js` (new): 22 tests, one or more per rule of the key-binding precedence (R2-1, the
certification design); 16 fail on 9f6847c, where the certification was the weakest source; the 6 that pass there pin
what already held (a scoped grant binds nothing, a nodeId only a certification bound is unbound by the revoke, a pin
outranks a certification in either order, a grant gives its role only to its subject key, this node's own nodeId).
Updated, each with its reason in the test: the registry rows "only a grant view binds the nodeId, a different key from
proven / pinned" and "nodeId bound (proven), a different key from grant" (a certification now refuses a session and
supersedes a stored binding; a pin still binds), the persistence row (a grant not in force records nothing now),
`R2-1: a grant view never refuses a proving session` (it now refuses it), and the authority-node §6.6.9 row (the
conflicting certification supersedes the proven binding). Mutation check: 28 mutants, one per new guard of the design
(the order of the decision, fail closed, two certified keys, the refusal, supersession, settling by a certification
or a pin, the recorded conflicts, the share and expiry, the credit, persistence and replay, the start offer, the
session enforcement on entry, exit and pin, `keyBindings`, the attestation key, `grantRole`, `bindsKey` in the
index): every one is caught.
- Mutation check: 27 mutants of `lib/config.js` and `lib/relocation.js`, one per guard of the new design (the verify
before the commit, the exclusive claim, ENOENT-only, the mint gate, the adoption guard, the live 0.13 holder, the
claim and the compare in the lock reclaim, the export order, the tombstone place, the import key place, junction
Expand All @@ -251,7 +283,9 @@ An independent security review (R2) of the 0.14 core reproduced each finding bel
the original agent with its whole store and the key on disk. Across volumes (disk image): 120 rounds, 60 killed
mid-run, every one the same (632b95d on the same image: 4 of 10 rounds started with store files missing).
- Full suite (`--test-concurrency=2`, macOS): 1568 tests, 1564 pass, 0 fail, 4 skipped; `npm run gate:stock-nodes` passed;
`npm audit`: 0 vulnerabilities. CI (Linux, Node 20 and 22) green, the real second-filesystem test included.
`npm audit`: 0 vulnerabilities. CI (Linux, Node 20 and 22) green, the real second-filesystem test included. With the
certification design (`--test-concurrency=1`, macOS, the machine shared): 1673 tests, 1669 pass, 0 fail, 4 skipped;
integration 6 of 6; the stock-nodes gate passed; `npm audit`: 0 vulnerabilities.

### Transport and node lifecycle

Expand Down
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,16 @@ participant.
roles — exists until both proofs validate. A discovery record or a relay roster entry only
decides whom to dial.
- **One key per nodeId, for life.** A session that proves a nodeId with a different key from the
one this node binds — durably, or for a session that is live — is a *conflict*: refused with
1009 IDENTITY_CONFLICT, recorded, and shown in `sym status` for you to resolve
(`sym keys <name> resolve`). No source overrides a different key. A handshake alone binds a
key only for its session; the binding becomes durable when it is earned (an admitted verified
record, a verified record its author signed to this node, a pin, an in-force grant), so
churning identities cannot fill the registry, and nothing is ever evicted to make room.
one this node binds — durably, by an anchor certification, or for a session that is live — is a
*conflict*: refused with 1009 IDENTITY_CONFLICT, recorded, and shown in `sym status` for you to
resolve (`sym keys <name> resolve`). One precedence decides which key verifies: the anchor and
this node's own key, then a pin you accepted, then an anchor certification (an unscoped grant
from the anchor or its admins), then the first proven session. Only a certification sets a
stored binding of another key aside: kept, recorded, and verifying nothing, also once the
certification goes, until you resolve it. A handshake alone binds a key only for its session;
the binding becomes durable when it is earned (an admitted verified record, a verified record
its author signed to this node, a pin), so churning identities cannot fill the registry, and
nothing is ever evicted to make room.
- **Every record is signed, and verified by its author's node id.** A record is accepted only
if it is a signed `mmp-sig-v2.0` record whose author key this node resolves by
`createdByNodeId` — a key it proved, pinned from an invite, or holds from an in-force grant.
Expand Down
43 changes: 29 additions & 14 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,37 @@ Report a vulnerability privately to info@sym.bot (the address in package.json).
session's room (a node's store is per node, not per room), whatever path offered it (a broadcast,
an anchor replay, a fetch answer, a queued frame), so its content is never disclosed to a peer that
would refuse it.
- **One key per nodeId.** Every confirmed session runs with a binding: the key it proved, held for
that nodeId while the session lives. The durable key registry binds a nodeId only when the
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 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
- **One key per nodeId.** A nodeId is self-chosen, so "nodeId X has key K" is a statement some
source makes, and one precedence decides which key verifies (0.14.3): the anchor pin and this
node's own key, then an out-of-band pin the operator accepted (invite, Legacy Import route, `sym
keys resolve`), then an anchor certification, then trust on first proven use, then a legacy
claim. Every confirmed session runs with a binding: the key it proved, held for that nodeId while
the session lives. The durable key registry binds a nodeId only when the 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), or an out-of-band pin. An ANCHOR CERTIFICATION is an in-force grant
that is unscoped and whose whole chain is unscoped `admin` grants up to the anchor (MMP §6.6.9 as
narrowed in 0.14.3); only the identity authority says which key is a nodeId's, a session proves only
that its peer holds some key. It is a view over the in-force set, never stored. While it certifies
X -> K1, a session proving X with another key is refused (a live one is closed), nothing verifies
as X but under K1, and a stored binding of another key is superseded: kept on disk, recorded, never
used to verify. A session proving K1 stores nothing, so a revoke removes what the certification
bound: a nodeId nothing else binds is unbound again, while a superseded binding stays conflicted
(it verifies nothing and every session for X is refused) until the operator resolves it or a pin or
a new certification settles it. A pin outranks a certification (the conflict is recorded);
certifications naming two keys for one nodeId certify neither. Otherwise a binding is never
replaced: 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), and an invite never repoints a stored binding the authority has not
superseded. The consequence for operators: an anchor or admin that certifies the wrong key for a
nodeId locks the real holder out at every node holding the grant until it is revoked and the
conflict resolved. 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
verified records on occasions a day or more apart expires after 180 days unseen; a superseded
binding never expires and is outside that quarter. A scoped grant, or one under an issuer or a
validator, binds no key: it confers its role inside its scope only, and a grant gives its role only
to what verifies under its subject key. 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
3 changes: 2 additions & 1 deletion bin/sym.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,8 @@ function cmdKeys() {
const conflicts = reg.conflicts();
if (jsonFlag) { console.log(JSON.stringify({ bindings: reg.entries(), conflicts }, null, 2)); return; }
if (sub !== 'conflicts') {
for (const e of reg.entries()) console.log(`${e.nodeId} ${e.source.padEnd(12)} ${keyFingerprint(e.key).slice(0, 23)}…`);
// A binding an anchor certification superseded is kept and verifies nothing: listed as such.
for (const e of reg.entries()) console.log(`${e.nodeId} ${(e.superseded ? `${e.source}, superseded` : e.source).padEnd(12)} ${keyFingerprint(e.key).slice(0, 23)}…`);
}
if (conflicts.length === 0) console.log('no key conflicts');
for (const c of conflicts) {
Expand Down
9 changes: 9 additions & 0 deletions docs/AUTHORITY-OPERATOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ anyNode.submitAuthority(whole); // then it spreads by
An admin grants and revokes below itself the same way (`node.grant`, `node.revoke`). Delegation is
at most 4 links from the anchor; a scoped grant can only narrow its parent's scope.

**An unscoped grant from the anchor, or under a chain of unscoped admins, also certifies its key**
for that nodeId at every node holding it (sym 0.14.3). While it is in force, a session proving another
key for that nodeId is refused (1009 `IDENTITY_CONFLICT`), nothing verifies as that nodeId but under the
certified key, and a binding of another key a node had stored is set aside (superseded) and recorded;
once the grant goes, that binding stays conflicted, and the nodeId is refused, until `sym keys resolve`.
A pin the operator accepted (an invite, `sym keys resolve`) outranks a certification. So name the key
the node really holds: `grantRole` takes it from a proven session, a pin or a certification. A scoped
grant, or one under an issuer or a validator, certifies nothing: it confers its role in its scope.

## 3. Re-pin

Change the pin on every node and restart it. Nothing is deleted: statements are kept in one file
Expand Down
63 changes: 50 additions & 13 deletions lib/authority-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,26 +371,63 @@ 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.
* The key the in-force CERTIFICATIONS name for `nodeId` (§6.6.9: the anchor's certification of which
* key is a nodeId's, a view over what is in force now): undefined when none does, or when two
* different keys do (see `certifiedKeys`).
*
* ONLY A MESH-WIDE GRANT ROOTED IN THE ANCHOR THROUGH ADMINS BINDS A KEY (security review R2-1,
* ONLY A MESH-WIDE GRANT ROOTED IN THE ANCHOR THROUGH ADMINS CERTIFIES 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.
* and the holder of a scoped delegating role must not be able to bind any nodeId in the mesh to a
* key of its choosing. A certification outranks a session's proof and any earned binding at the key
* registry (roster-keys.js): only the identity authority says WHICH key is a nodeId's.
*/
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 || !this.bindsKey(e)) continue;
if (found !== undefined && found !== e.subject.key) return undefined;
found = e.subject.key;
const keys = this.certifiedKeys(nodeId);
return keys.length === 1 ? keys[0] : undefined;
}

/**
* The distinct keys the in-force certifications (`bindsKey` grants) name for `nodeId`, in the order
* of their grants' ids: [] when none does. Two or more is a contradiction the key registry treats
* as conflicted (nothing verifies as that nodeId). Indexed once per resolution.
* @returns {string[]}
*/
certifiedKeys(nodeId) {
const keys = this._certIndex().get(nodeId);
return keys ? [...keys] : [];
}

/**
* Every in-force certification: [{ id, nodeId, key }] in ascending id (the node offers each to its
* key registry when it enters the in-force set, and every one at start).
*/
certifications() {
const r = this.view();
if (!r.certList) {
const out = [];
for (const id of [...r.inForce].sort()) {
const e = this._held.get(id);
if (e && e.kind === 'grant' && this.bindsKey(e)) out.push(Object.freeze({ id, nodeId: e.subject.nodeId, key: e.subject.key }));
}
r.certList = out; // once per resolution (the in-force set is fixed until the next)
}
return [...r.certList];
}

/** @private nodeId -> the keys its in-force certifications name, for the resolution queries read. */
_certIndex() {
const r = this.view();
if (r.certs) return r.certs;
const m = new Map();
for (const c of this.certifications()) {
let keys = m.get(c.nodeId);
if (!keys) { keys = []; m.set(c.nodeId, keys); }
if (!keys.includes(c.key)) keys.push(c.key);
}
return found;
r.certs = m;
return m;
}

/**
Expand Down
Loading
Loading