From d4d68eaa073291d8ff2806ed8b51db776a1e6b2e Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 18:39:31 +0100 Subject: [PATCH 1/2] Key binding: an anchor certification outranks a session and any earned binding A nodeId is self-chosen, so "X has key K" is a statement some source makes. The R2-1 fix made the in-force grant view the weakest source, so a squatter that reached a receiver first with its own key under a certified nodeId kept it. Only the identity authority says which key is a nodeId's; a session proves only that its peer holds some key. Precedence, strongest first: the anchor pin and this node's own key, an out-of-band pin, an anchor certification (an in-force grant that bindsKey: unscoped, its chain unscoped admins up to the anchor), trust on first proven use, a legacy claim. - RosterKeyRegistry.binding() is the one decision; get/source/expected read it. While a certification of X -> K1 is in force, a session or legacy claim naming another key is refused and recorded, and a stored binding of another key is superseded: kept on disk, marked (a persisted line, and a member of a compacted one), recorded with kind 'superseded', never used to verify, never expiring and outside the new-binding share. A session proving K1 stores nothing. - Revoked or expired, a nodeId nothing else binds is unbound again; a superseded binding stays conflicted (fail closed: it verifies nothing, and every source but a pin is refused) until the operator resolves it or a pin or a new certification settles it. - A pin outranks a certification (recorded 'pin-over-certification'); an invite still never repoints a binding the authority has not superseded. Certifications naming two keys for one nodeId certify neither ('certifications-disagree'). - A grant not in force supersedes and records nothing. - AuthorityStore.certifiedKeys/certifications, indexed once per resolution; the node offers every certification in force at start and each one that enters at a settle, and closes a live session whose key no longer decides its nodeId when a certification enters or leaves or a pin is made (1009, recorded, metric identity-conflict). - _onSessionConfirmed/_admitSession/_identityKey/_keySource/_earnBinding follow binding(); keyBindings() lists the key that decides each nodeId and leaves a conflicted one out; grantRole takes a certified key; an attestation's role is judged under the key that verified it. - sym keys lists a superseded binding as such. Tests: security-0.14.3-cert.test.js (new) has one or more per rule; the ones encoding "a session outranks the grant view" are updated, each with its reason. --- bin/sym.js | 3 +- lib/authority-store.js | 63 +++- lib/node-authority.js | 42 ++- lib/node.js | 154 ++++++---- lib/roster-keys.js | 459 ++++++++++++++++++++-------- tests/authority-node.test.js | 10 +- tests/roster-keys.test.js | 55 +++- tests/security-0.14.3-cert.test.js | 473 +++++++++++++++++++++++++++++ tests/security-0.14.3.test.js | 17 +- 9 files changed, 1038 insertions(+), 238 deletions(-) create mode 100644 tests/security-0.14.3-cert.test.js diff --git a/bin/sym.js b/bin/sym.js index 03bff582..593c9871 100755 --- a/bin/sym.js +++ b/bin/sym.js @@ -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) { diff --git a/lib/authority-store.js b/lib/authority-store.js index fe811efd..593d7ccd 100644 --- a/lib/authority-store.js +++ b/lib/authority-store.js @@ -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; } /** diff --git a/lib/node-authority.js b/lib/node-authority.js index 9edd34cc..26acfa60 100644 --- a/lib/node-authority.js +++ b/lib/node-authority.js @@ -106,6 +106,9 @@ function initAuthority(node, opts = {}) { const r = node._authority.loadReport(); if (r.held || r.pending || r.invalid) node._log(`Authority: ${r.held} statement(s) held, ${r.pending} pending, ${r.invalid} not valid under the pin`); node._authorityPrev = new Set(node._authority.resolve().inForce); + // nodeId -> the keys its in-force certifications name, at the last settle (to see what changed). + node._authorityCertsPrev = new Map(); + for (const c of node._authority.certifications()) node._authorityCertsPrev.set(c.nodeId, `${node._authorityCertsPrev.get(c.nodeId) || ''}${c.key},`); } const methods = { @@ -178,10 +181,12 @@ const methods = { /** * The role an attestation's signer counts with (§6.6.10): its highest role in force now whose * scope contains the attested CMB (an unscoped role always does), judged when the weight is - * applied. The attestation's own time plays no part. + * applied. The attestation's own time plays no part. `key` is the key its signature verified under: + * a grant gives its role only to that very key (§6.6.9), so a grant naming the signer's nodeId with + * another key gives it nothing. * @private */ - _attesterRole(att) { + _attesterRole(att, key) { if (!att) return 'participant'; let cmb = null; // The attested record is the one its assertion names: a cognition key is shared by every record @@ -191,7 +196,7 @@ const methods = { const e = this._store.get(att.of); cmb = e && e.cmb && e.cmb.metadata && att.assertionId && e.cmb.metadata.assertionId === att.assertionId ? e.cmb : null; } catch { cmb = null; } - return this.resolveRole(att.by, { cmb }); + return this.resolveRole(att.by, { cmb, key }); }, /** @private The origin role a received CMB's author counts with (§6.4): its lifecycle authority over it. */ @@ -306,12 +311,14 @@ const methods = { }, /** - * Grant a role to a peer whose key this node has proven (a confirmed session or an out-of-band pin), - * or `opts.granteeKey`. Returns the signed grant, or null (said in the log) when it cannot be made. + * Grant a role to a peer whose key this node has proven (a confirmed session), pinned out of band, + * or holds certified by the anchor (an in-force key-binding grant, which outranks a session's + * proof), or `opts.granteeKey`. Returns the signed grant, or null (said in the log) when it cannot + * be made. */ grantRole(granteeNodeId, role, opts = {}) { const source = this._keySource(granteeNodeId); - const key = opts.granteeKey || ((source === 'proven' || source === 'pinned' || source === 'session') ? this._identityKey(granteeNodeId) : undefined); + const key = opts.granteeKey || ((source === 'proven' || source === 'pinned' || source === 'session' || source === 'grant') ? this._identityKey(granteeNodeId) : undefined); if (!key) { this._log(`Grant for ${String(granteeNodeId).slice(0, 8)} not made: no proven key is known for it`); return null; } try { const r = this.grant({ nodeId: granteeNodeId, key }, role, opts); @@ -516,8 +523,10 @@ const methods = { /** * @private Resolve, and act on what changed: relay each statement that entered the in-force set, - * once (§6.6.8); tell every session the new root (at most once a second each); bind in-force grants - * as a key-registry view (§6.6.9) and report one naming this node with a foreign key. + * once (§6.6.8); tell every session the new root (at most once a second each); offer each anchor + * certification that entered to the key registry (§6.6.9: it supersedes a stored binding of another + * key) and report one naming this node with a foreign key; and, for every nodeId whose + * certifications changed, close the live sessions whose key no longer decides it. */ _authoritySettle() { const { inForce } = this._authority.resolve(); @@ -538,13 +547,26 @@ const methods = { this.emit('metric', { type: 'authority-foreign-self-key', id, key: s.subject.key }); } } 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 + // An anchor certification: the registry sets aside (supersedes) a stored binding of lower + // rank naming another key, and records a pin's disagreement (the pin holds). 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'); } } } + // The nodeIds whose certifications entered or left: a live session whose key no longer decides + // its nodeId is an identity conflict now (a certification of another key, or a binding it + // superseded and left conflicted when it went). + const certs = new Map(); + for (const c of this._authority.certifications()) certs.set(c.nodeId, `${certs.get(c.nodeId) || ''}${c.key},`); + const prevCerts = this._authorityCertsPrev || new Map(); + this._authorityCertsPrev = certs; + if (typeof this._enforceBinding === 'function') { + for (const nodeId of new Set([...certs.keys(), ...prevCerts.keys()])) { + if (nodeId !== this.nodeId && certs.get(nodeId) !== prevCerts.get(nodeId)) this._enforceBinding(nodeId); + } + } this._authorityOrigin.clear(); this._authorityPrev = new Set(inForce); // The relayed-once marks are kept for what is held: one for a statement the store has since diff --git a/lib/node.js b/lib/node.js index 67d0e6d0..a961991a 100644 --- a/lib/node.js +++ b/lib/node.js @@ -649,9 +649,12 @@ class SymNode extends EventEmitter { // the in-force set they resolve to — a function of the set, never of arrival order or any clock. // Persisted under the node dir and verified again at every load. authorityMixin.initAuthority(this, opts); - // A grant binding is a view over the in-force grants (§6.6.9): it binds an unbound nodeId only, - // never this node's own. - this._roster.setGrantView((nodeId) => (nodeId === this.nodeId ? undefined : this._authority.grantKey(nodeId))); + // An anchor certification is a view over the in-force grants (§6.6.9 as narrowed, R2-1): the keys + // the in-force key-binding grants name, never for this node's own nodeId. It outranks a session's + // proof and any earned binding (roster-keys.js): each one in force now is offered to the registry, + // which sets aside (supersedes) a stored binding of another key; the settle offers each later one. + this._roster.setGrantView((nodeId) => (nodeId === this.nodeId ? undefined : this._authority.certifiedKeys(nodeId))); + for (const c of this._authority.certifications()) if (c.nodeId !== this.nodeId) this._roster.bind(c.nodeId, c.key, 'grant'); // Strict mode: when set, reject UNSIGNED CMBs from peers whose identity key // is known (default off — unsigned peers are allowed for interop). this._requireSignedCmb = process.env.SYM_REQUIRE_SIGNED_CMB === '1' @@ -953,20 +956,19 @@ class SymNode extends EventEmitter { // ── Keys ─────────────────────────────────────────────────── /** - * The key that verifies `nodeId`'s signatures: its binding in the key registry (design D3) — - * the configured anchor, a proven session, an out-of-band pin, or an in-force grant (§6.6.9). - * Never a key a hello merely announced, and never a legacy claim. + * The key that verifies `nodeId`'s signatures, by precedence (design D3, the 0.14.3 certification + * design): the configured anchor or this node's own key, an out-of-band pin, an anchor + * certification (an in-force key-binding grant, §6.6.9), then trust on first proven use — a + * stored proven binding, or the key a live Core Secure session proved, for as long as it lasts. + * Never a key a hello merely announced, a legacy claim, or a binding a certification superseded. * @private */ _identityKey(nodeId) { - // 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). 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 + // The registry's decision, when it has one: a legacy claim or a conflicted nodeId verifies nothing. + const d = this._roster.binding(nodeId); + if (d) return d.verifies ? d.key : undefined; + // Nothing above a session binds it: the key its live session proved (the session-scoped binding). + return this._liveSessionKey(nodeId); } /** @private The key a live confirmed Core Secure session proved for `nodeId`, or undefined. */ @@ -975,11 +977,14 @@ class SymNode extends EventEmitter { 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. */ + /** + * Where the key that verifies `nodeId` comes from: a registry source (anchor, self, pinned, grant, + * proven, legacy-claim, or conflicted, which verifies nothing), 'session', or undefined. + */ _keySource(nodeId) { - if (this._roster.durable(nodeId) !== undefined) return this._roster.source(nodeId); - if (this._liveSessionKey(nodeId) !== undefined) return 'session'; - return this._roster.source(nodeId); + const d = this._roster.binding(nodeId); + if (d) return d.source; + return this._liveSessionKey(nodeId) !== undefined ? 'session' : undefined; } /** @@ -990,13 +995,13 @@ class SymNode extends EventEmitter { */ _earnBinding(nodeId, key) { if (!nodeId || !key) return; - if (this._roster.durable(nodeId) !== undefined) { - if (this._roster.get(nodeId) === key) this._roster.noteVerified(nodeId); + const d = this._roster.binding(nodeId); + if (d) { + // Bound already: a stored binding of this very key is credited (never one a certification + // superseded). A key only a certification binds is never stored (R2-1): it ends with the grant. + if (d.verifies && d.key === key) this._roster.noteVerified(nodeId, key); 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'); @@ -1380,7 +1385,7 @@ class SymNode extends EventEmitter { if (!verifyAttestation(att, key).valid) { excluded.push({ by: att.by, reason: 'bad-signature' }); continue; } // (2) the attester's role in force NOW over the attested CMB (§6.6.10: a weight is judged against // the in-force set at the moment it is applied; the attestation's own time plays no part). - const rr = verifyAttestationRole(att, () => this._attesterRole(att)); + const rr = verifyAttestationRole(att, () => this._attesterRole(att, key)); // the key that verified it (§6.6.9) if (!rr.matches) mismatches.push({ by: att.by, claimed: rr.claimed, resolved: rr.resolved }); const w = 2 ** authorityMixin.lifecycleRank(rr.resolved); // participant and issuer 1, validator 2, admin and anchor 4 @@ -2064,7 +2069,7 @@ class SymNode extends EventEmitter { // 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. if (!this._attestsDirected(att)) this._gossipAttestation(att, fromPeerId); - this._emitAttestationReceived(att, fromPeerId, fromPeerName); + this._emitAttestationReceived(att, fromPeerId, fromPeerName, attesterKey); } return { ok: r.stored, reason: r.reason }; } @@ -2106,7 +2111,7 @@ class SymNode extends EventEmitter { * dispatched the same isolated way rather than through emit(). * @private */ - _emitAttestationReceived(att, fromPeerId, fromPeerName) { + _emitAttestationReceived(att, fromPeerId, fromPeerName, key) { if (this.listenerCount('attestation-received') === 0) return; const why = (e) => { try { return (e && e.message) || String(e); } catch { return 'unprintable error'; } }; // A log sink that fails must not become the failure it is reporting. @@ -2126,7 +2131,7 @@ class SymNode extends EventEmitter { } // The claim as signed: a text role, or none (an absent or non-text role signs as empty and claims // 'participant'). - const role = verifyAttestationRole({ ...att, role: typeof att.role === 'string' && att.role ? att.role : undefined }, () => this._attesterRole(att)); + const role = verifyAttestationRole({ ...att, role: typeof att.role === 'string' && att.role ? att.role : undefined }, () => this._attesterRole(att, key)); const roleClaimed = String(role.claimed); const roleResolved = String(role.resolved); event = Object.freeze({ @@ -4021,22 +4026,29 @@ class SymNode extends EventEmitter { get fingerprint() { return keyFingerprint(this._identity.publicKey); } /** - * The key bindings this node holds, public keys only: the durable registry's (anchor, pinned, - * proven, legacy-claim) and the live sessions' (`session`). A key an in-force grant names is shown - * where it binds an unbound nodeId (§6.6.9). A host reads these; it never writes them. + * The key bindings this node holds, public keys only, one row per nodeId: the key that decides it + * here and its source — the registry's (anchor, pinned, grant, proven, legacy-claim) and the live + * sessions' (`session`). A nodeId an anchor certification binds is listed with the certified key, + * whatever is stored for it; a conflicted one (a superseded binding, or certifications naming two + * keys) is not listed, since no key verifies it (`status().coreSecure.keyConflicts` and `sym keys` + * show it). A host reads these; it never writes them. * @returns {{ nodeId: string, key: string, source: string }[]} */ keyBindings() { const out = []; const seen = new Set(); - for (const e of this._roster.entries()) { - seen.add(e.nodeId); - const source = this._roster.source(e.nodeId) || e.source; - out.push({ nodeId: e.nodeId, key: e.key, source }); - } + const row = (nodeId) => { + if (seen.has(nodeId)) return; + seen.add(nodeId); + const d = this._roster.binding(nodeId); + if (d && d.key) out.push({ nodeId, key: d.key, source: d.source }); + }; + for (const e of this._roster.entries()) row(e.nodeId); + for (const c of this._authority.certifications()) if (c.nodeId !== this.nodeId) row(c.nodeId); for (const [nodeId, held] of this._sessionKeys) { if (seen.has(nodeId) || ![...held.sessions].some((s) => s.confirmed && !s.closed)) continue; - out.push({ nodeId, key: held.key, source: 'session' }); + seen.add(nodeId); + if (this._roster.binding(nodeId) === undefined) out.push({ nodeId, key: held.key, source: 'session' }); } return out.map((e) => Object.freeze(e)); } @@ -4055,9 +4067,11 @@ class SymNode extends EventEmitter { } /** - * Accept an invite: pin its issuer's key at `pinned` ONLY when that nodeId is unbound here. A - * nodeId already bound to the same key keeps its (stronger) binding; one bound to a different key - * is a conflict, recorded for the operator — an invite never repoints a key (design D3/D5). + * Accept an invite: pin its issuer's key at `pinned` when that nodeId has no stored binding here, + * or only one an anchor certification superseded (the pin settles it). A nodeId already bound to + * the same key keeps its binding (made `pinned`); one bound to a different key is a conflict, + * recorded for the operator — an invite never repoints a key (design D3/D5). A pin outranks a + * certification: one naming another key is a recorded conflict the pin wins. * @param {string|object} invite - the URL, or what parseInvite returned * @returns {{ pinned: boolean, reason?: string, issuer?: object, invite?: object }} */ @@ -4068,16 +4082,17 @@ 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 }; - // 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. + // Only a stored binding is "already bound": a certification is a view, which a pin outranks. 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 && b.conflict === 'grant') this._log(`Invite from ${nodeId.slice(0, 8)} pinned; an in-force grant certifies another key for it: the pin holds (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 }; } - return { pinned: before === undefined, reason: before === undefined ? undefined : `already bound (${b.source})`, issuer: parsed.issuer, invite: parsed }; + if (b.created || b.settled) this._enforceBinding(nodeId); + const made = !!(b.created || b.settled); + return { pinned: made, reason: made ? undefined : `already bound (${b.source})`, issuer: parsed.issuer, invite: parsed }; } /** @@ -4147,19 +4162,17 @@ class SymNode extends EventEmitter { */ _onSessionConfirmed(session) { this._sessionStats.confirmed++; - // 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) { + // A binding above the session decides its nodeId's key, by precedence: the anchor, this node's + // own key, a pin, an anchor certification (an in-force key-binding grant: only the identity + // authority says WHICH key is a nodeId's, a session proves only that its peer holds SOME key), a + // binding a certification superseded (fail closed: no key), or a stored binding. A session + // proving another key is an identity conflict: refused, recorded, never silent. (Only grants + // that are key-binding sources certify, R2-1: a scoped one binds nothing and refuses no one.) + const d = this._roster.binding(session.nodeId); + if (d && d.key !== 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)`); + this._log(`Refused ${session.name} (${session.nodeId.slice(0, 8)}): it proved a key that is not the one bound to its nodeId (${d.source}) — a key conflict for the operator (sym keys resolve)`); + this.emit('metric', { type: 'identity-conflict', nodeId: session.nodeId, against: d.source }); this._refuseIdentityConflict(session); return; } @@ -4220,11 +4233,12 @@ class SymNode extends EventEmitter { // EVERY CONFIRMED SESSION RUNS WITH A BINDING (security review D, binding-squat): its proven key is // held for nodeId while the session lives (the session-scoped table, bounded by the session caps), // 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.durable(nodeId); // never the grant view (R2-1) - if (expected !== undefined && expected !== session.identityKey) { this._refuseIdentityConflict(session); return; } - if (expected === session.identityKey) { + // bindings — a pin, or an admitted verified record (_earnBinding) — and the key it already + // expects for nodeId (a legacy claim becomes `proven`; a pin stays pinned). A key only an anchor + // certification binds stores nothing on the grant's account: a revoke removes what it bound. + const d = this._roster.binding(nodeId); + if (d && d.key !== session.identityKey) { this._refuseIdentityConflict(session); return; } + if (d && d.source !== 'grant') { const b = this._roster.bind(nodeId, session.identityKey, 'proven'); if (!b.bound && b.reason === 'conflict') { this._refuseIdentityConflict(session); return; } // A session seen renews the binding's clock. A re-handshake proves only that the key is held, @@ -4509,6 +4523,26 @@ class SymNode extends EventEmitter { if (this._lastAnchorSent) this._lastAnchorSent.delete(nodeId); } + /** + * The binding that decides `nodeId` changed above its sessions (an anchor certification entered or + * left the in-force set, a pin was made): a live session proving a key other than the one that + * decides it now is an identity conflict, recorded and closed like one refused at its handshake. + * Records it signs no longer verify as that nodeId already (`_identityKey`); this ends the session. + * @private + */ + _enforceBinding(nodeId) { + const held = this._sessionKeys && this._sessionKeys.get(nodeId); + if (!held) return; + const d = this._roster.binding(nodeId); + if (!d || d.key === held.key) return; + const live = [...held.sessions].filter((s) => !s.closed); + if (!live.length) return; + this._roster.bind(nodeId, held.key, 'proven'); // records the conflict + this._log(`Closed ${live.length} session(s) of ${live[0].name} (${String(nodeId).slice(0, 8)}): the key they proved is not the one bound to its nodeId now (${d.source}) — a key conflict for the operator (sym keys resolve)`); + this.emit('metric', { type: 'identity-conflict', nodeId, against: d.source, live: true }); + for (const s of live) this._refuseIdentityConflict(s); + } + /** * A confirmed session proved a key other than the one bound to its nodeId (design D3): refused * with the error MMP draft spec PR meshcognition-website#21 defines, 1009 IDENTITY_CONFLICT, then diff --git a/lib/roster-keys.js b/lib/roster-keys.js index edf93dc9..38fb8107 100644 --- a/lib/roster-keys.js +++ b/lib/roster-keys.js @@ -5,50 +5,67 @@ * @description The key registry: the ONE place where a nodeId gets an identity key (design D3, * Core Secure identity, 2026-10-02). * - * A nodeId is a UUID v7 bound to an Ed25519 key through an authenticated transcript (MMP §18.3), - * and a node has one key for life (§3.4). So a binding, once made, is never replaced by a - * different key, whatever the source. The 0.13 rule "a strictly stronger source overrides" is - * gone: it let any later source that outranked the first repoint a key. + * A nodeId is self-chosen, so "nodeId X has key K" is always a statement some source makes. A node + * has one key for life (§3.4), so the registry holds one binding per nodeId, and where sources + * disagree, PRECEDENCE decides which key verifies (0.14.3, the certification design). Strongest first: * - * Sources, strongest first: - * anchor — the configured anchor (§6.5/§6.6). Read from configuration at every start and - * NEVER persisted to or replayed from the file, so re-pinning a fresh anchor out - * of band takes effect at the next start. - * proven — a confirmed §5.2 session proved possession of the key. - * pinned — an out-of-band binding: an accepted invite naming the issuer's key, a Legacy - * Import route's mandatory fingerprint, or an operator's resolution of a conflict. - * grant — named by an in-force grant (MMP §6.6.9: the grant names a nodeId and a key). - * NOT STORED (security review C): a grant binding is a view over the in-force set - * NOW (`opts.grantView`, the authority store's answer), so a revoke that removes - * 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. + * anchor, self — the configured anchor members (§6.6.1) and this node's own key: configuration, + * read at every start and NEVER persisted or replayed, so re-pinning a fresh anchor + * out of band takes effect at the next start. No source binds either to another key. + * pinned — an out-of-band binding the operator accepted: an invite naming its issuer's key, a + * Legacy Import route's mandatory fingerprint, or `sym keys resolve`. It outranks a + * certification (the operator's explicit local decision); their disagreement is + * recorded and shown. + * grant — an ANCHOR CERTIFICATION: an in-force grant that is a key-binding source (the + * authority store's `bindsKey`: unscoped, and its whole chain unscoped `admin` grants + * up to the anchor; security review R2-1). Only the identity authority certifies WHICH + * key is X's: a session proves only that its peer holds SOME key. A view over the + * in-force set NOW (`opts.grantView`), never stored, so a revoke ends it. While it + * certifies X -> K1: + * - a session proving X with another key is an identity conflict (refused, recorded); + * - nothing verifies as X but under K1; + * - a stored binding of lower rank naming another key is SUPERSEDED: kept on disk, + * marked, recorded as a conflict, and never used to verify; + * - a session proving K1 stores nothing on the grant's account, so a revoke removes + * everything the grant bound. + * Certifications naming two keys for one nodeId certify neither: it verifies nothing + * (fail closed). A grant never binds this node's own nodeId (`opts.self`). + * proven — trust on first proven use: a key a confirmed §5.2 session proved, made durable once + * it is EARNED (an admitted verified record), or a legacy claim a session proved. (A + * live session's key that is not stored is the node's session-scoped binding: the same + * rank, held while the session lasts.) * 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 * conflict, so a squatter racing the upgrade cannot take an honest node's id. * - * The conflict matrix: + * SUPERSEDED IS FAIL CLOSED. When the certification that superseded a binding is revoked or expires, + * the binding stays conflicted: it verifies nothing and every session for that nodeId is refused (the + * authority once said that key was not X's) until the operator resolves it (`sym keys resolve`) or a + * new source settles it: a pin of either key, or a new certification (one naming the superseded key + * clears the mark). A nodeId that only a certification bound is simply unbound again: a later session + * binds it by first proven use. * - * 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 - * the configured anchor → always its configured key + * The conflict matrix (`bind`): * - * Only `anchor`, `proven`, `pinned` and `grant` bindings verify anything (`get`). A `legacy-claim` - * is reported by `expected` and by `source`, never by `get`. This node's own nodeId is always its - * own key (`opts.self`, configuration like the anchor): no source binds it to another. + * nodeId unbound, nothing certifies it, any source → bind at that source + * certified K1; a session (proven) says K1 → nothing stored: the binding ends with the grant + * certified K1; a proven or legacy-claim source K2 → refused, recorded + * certified K1; stored proven / legacy-claim K2 → K2 superseded: kept, marked, recorded + * certified K1; pinned K2 (either order) → the pin binds and verifies; the conflict recorded + * superseded K2, nothing certifies; a pin (K1 or K2) → the pin settles it: bound pinned + * superseded K2, nothing certifies; any other source → refused, recorded (fail closed) + * nodeId bound, same key, any source → keep; record the stronger source + * nodeId bound (not superseded), DIFFERENT key, any + * source that is not a certification → conflict: refused, recorded (roster-conflicts.jsonl), + * shown in `sym status`; the operator resolves it + * (a pin, too: an invite never repoints a key the + * authority has not superseded, design D5) + * the configured anchor, this node's own nodeId → always its configured key + * + * `binding(nodeId)` is the decision; `get` is its key when it verifies. Only `anchor`, `self`, + * `pinned`, `grant` and `proven` verify; a `legacy-claim` is reported by `expected` and `source`, never + * by `get`, and a superseded binding (source `conflicted`) by `superseded` and `entries`. * * BINDING LIFETIME (design D3, 0.13.17 re-review). New keypairs are free, so a registry that only * grows can be filled by identity churn. A first-contact `proven` binding that has verified nothing @@ -60,7 +77,10 @@ * `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). + * registry; an established one after EARNED_TTL_MS (180 days) unseen (see EARNED_TTL_MS). A + * certification is never stored, so it is no earned binding and never ages out as one; a SUPERSEDED + * binding never expires (expiry would unbind the nodeId and let the next session take it) and is + * outside the new-binding share (only a certification, which the authority quotas bound, makes one). * * 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 @@ -76,7 +96,9 @@ * binding event. A 0.13 reader skips the marker as malformed and reads the rest. A file without the * marker is a 0.13 file: it is replayed by the 0.13 rules, its `handshake` entries become * `legacy-claim`, its `anchor` entries are dropped, and it is rewritten once, atomically, with the - * marker (idempotent: a marked file is never migrated again). + * marker (idempotent: a marked file is never migrated again). A supersession is a line + * `{nodeId, key, superseded: }` (`superseded: false` clears it), and a compacted + * binding line carries the same member; an earlier 0.14 reader skips the one and ignores the other. * * @copyright 2026 SYM.BOT. Apache 2.0 License. */ @@ -89,14 +111,19 @@ const KEYS_FILE = 'roster-keys.jsonl'; const CONFLICTS_FILE = 'roster-conflicts.jsonl'; const FORMAT_MARKER = Object.freeze({ v: 2, format: 'sym-roster-keys', note: 'sym 0.14 key registry; a 0.13 reader skips this line' }); -/** Strength, for "record the stronger source" on the same key. The anchor is configuration. */ -// `pinned` outranks `proven` (mesh-channel 0.11.0 defect report): an out-of-band binding the operator -// accepted is the stronger provenance, and a session proving the same key confirms it and keeps it -// pinned (and so never expiring), where it used to relabel it `proven`. -const SOURCE_RANK = Object.freeze({ 'legacy-claim': 1, grant: 2, proven: 3, pinned: 4, anchor: 5 }); -const VERIFYING = new Set(['anchor', 'proven', 'pinned']); +/** + * Precedence, strongest first (see the module header): the anchor (and this node's own key, `self`), + * then `pinned`, then `grant` (an anchor certification, a view), then `proven`, then `legacy-claim`. + * The anchor is configuration. `pinned` outranks `proven` (mesh-channel 0.11.0 defect report): a + * session proving a pinned key confirms it and keeps it pinned (and so never expiring). `grant` + * outranks `proven` (0.14.3): a certification supersedes a stored binding of another key. + */ +const SOURCE_RANK = Object.freeze({ 'legacy-claim': 1, proven: 2, grant: 3, pinned: 4, anchor: 5 }); const SOURCES = new Set(Object.keys(SOURCE_RANK)); const rankOf = (s) => SOURCE_RANK[s] ?? 0; +/** What `binding` says of a nodeId nothing may verify as: superseded, or certified two ways. */ +const CONFLICTED = Object.freeze({ key: null, source: 'conflicted', verifies: false }); +const NONE = Object.freeze([]); /** Bindings kept at most (new proven/legacy bindings are refused past it; pinned never are). */ const MAX_BINDINGS = 65536; @@ -195,38 +222,63 @@ class RosterKeyRegistry { // ── Reads ────────────────────────────────────────────────────────────────────── - /** The key that verifies `nodeId`'s signatures, or undefined. Never a legacy claim. */ - get(nodeId) { - if (this._anchors.has(nodeId)) return this._anchors.get(nodeId); - if (this._self && nodeId === this._self.nodeId) return this._self.publicKey; + /** + * The binding that decides `nodeId` now, by precedence: `{ key, source, verifies }`, or undefined + * when nothing binds it (a live session may, for as long as it lasts: that is the node's). + * anchor | self its configured key + * pinned the pin (it outranks a certification, whose disagreement is a recorded conflict) + * grant the one key the in-force certifications name; `proven` when a stored proven + * binding holds that very key (it is its own, and outlives the certification) + * conflicted `{ key: null }`: certifications naming two keys, or a stored binding a + * certification superseded while nothing certifies the nodeId now (fail closed) + * proven the stored proven binding + * legacy-claim the expected key, which verifies nothing (`verifies: false`) + * @returns {{ key: string|null, source: string, verifies: boolean }|undefined} + */ + binding(nodeId) { + if (this._anchors.has(nodeId)) return { key: this._anchors.get(nodeId), source: 'anchor', verifies: true }; + if (this._self && nodeId === this._self.nodeId) return { key: this._self.publicKey, source: 'self', verifies: true }; const b = this._byNode.get(nodeId); - if (!b) return this._viewKey(nodeId); - if (VERIFYING.has(b.source)) return b.key; - // A legacy claim verifies only while grants in effect vouch the very key it expects. - return this._viewKey(nodeId) === b.key ? b.key : undefined; + if (b && b.source === 'pinned') return { key: b.key, source: 'pinned', verifies: true }; + const certs = this._certKeys(nodeId); + if (certs.length > 1) return CONFLICTED; + if (certs.length === 1) { + const own = b && !b.superseded && b.key === certs[0] && b.source === 'proven'; + return { key: certs[0], source: own ? 'proven' : 'grant', verifies: true }; + } + if (!b) return undefined; + if (b.superseded) return CONFLICTED; + return { key: b.key, source: b.source, verifies: b.source !== 'legacy-claim' }; + } + + /** The key that verifies `nodeId`'s signatures, or undefined. Never a legacy claim or a superseded binding. */ + get(nodeId) { + const d = this.binding(nodeId); + return d && d.verifies ? d.key : undefined; } - /** The key bound to `nodeId` at any source, a legacy claim included, or undefined. */ + /** + * The key `nodeId` is bound to at any source, a legacy claim included, or undefined. For a + * conflicted nodeId: the key on file, or a certification's. + */ expected(nodeId) { - if (this._anchors.has(nodeId)) return this._anchors.get(nodeId); - if (this._self && nodeId === this._self.nodeId) return this._self.publicKey; + const d = this.binding(nodeId); + if (!d) return undefined; + if (d.key) return d.key; const b = this._byNode.get(nodeId); - return b ? b.key : this._viewKey(nodeId); + return b ? b.key : this._certKeys(nodeId)[0]; } - /** The source of `nodeId`'s binding: anchor | self | proven | pinned | grant | legacy-claim | undefined. */ + /** The source of `nodeId`'s binding: anchor | self | pinned | grant | proven | legacy-claim | conflicted | undefined. */ source(nodeId) { - if (this._anchors.has(nodeId)) return 'anchor'; - if (this._self && nodeId === this._self.nodeId) return 'self'; - const b = this._byNode.get(nodeId); - if (b) return b.source === 'legacy-claim' && this._viewKey(nodeId) === b.key ? 'grant' : b.source; - return this._viewKey(nodeId) !== undefined ? 'grant' : undefined; + const d = this.binding(nodeId); + return d ? d.source : 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). + * The key configured or stored for `nodeId` — the anchor, this node's own key, or a stored binding + * in any state (a legacy claim, a superseded one) — or undefined. Never the certification view; it + * decides nothing (`binding` does). */ durable(nodeId) { if (this._anchors.has(nodeId)) return this._anchors.get(nodeId); @@ -235,16 +287,34 @@ class RosterKeyRegistry { return b ? b.key : undefined; } - /** The key the in-force key-binding grants name for `nodeId` now (the view), or undefined. */ + /** A stored binding a certification superseded: `{ key, by }` (the certified key), or null. */ + superseded(nodeId) { + const b = this._byNode.get(nodeId); + return b && b.superseded ? { key: b.key, by: b.superseded } : null; + } + + /** The one key the in-force certifications 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). */ + /** @private The one key the certifications in force now name for `nodeId`, or undefined (none, or two). */ _viewKey(nodeId) { - if (!this._grantView || (this._self && nodeId === this._self.nodeId)) return undefined; - try { - const k = this._grantView(nodeId); - return isIdentityKey(k) ? k : undefined; - } catch { return undefined; } + const c = this._certKeys(nodeId); + return c.length === 1 ? c[0] : undefined; + } + + /** + * @private The distinct keys the certifications in force now name for `nodeId` (never this node's + * own). The view answers a key, or a list of them; nothing is read while the file loads. + */ + _certKeys(nodeId) { + if (!this._grantView || this._loading || (this._self && nodeId === this._self.nodeId)) return NONE; + let v; + try { v = this._grantView(nodeId); } catch { return NONE; } + if (v === undefined || v === null) return NONE; + const list = typeof v === 'string' ? [v] : (Array.isArray(v) || v instanceof Set) ? [...v] : NONE; + const keys = []; + for (const k of list) if (isIdentityKey(k) && !keys.includes(k)) keys.push(k); + return keys; } /** Set the grant view after construction (the authority store is built after this registry). */ @@ -265,9 +335,12 @@ class RosterKeyRegistry { /** What the first load of a 0.13 file did, or null when the file was already 0.14's. */ migration() { return this._migration ? { ...this._migration } : null; } - /** Every binding, for status and tests: [{ nodeId, key, source, at, seen, verified, vouched }]. */ + /** + * Every stored binding, for status and tests: [{ nodeId, key, source, at, seen, verified, vouched, + * superseded }] (`superseded`: the certified key that superseded it, or null). + */ entries() { - const out = [...this._byNode].map(([nodeId, b]) => ({ nodeId, key: b.key, source: b.source, at: b.at, seen: b.seenAt ?? null, verified: b.verifiedAt ?? null, vouched: !!b.vouched })); + const out = [...this._byNode].map(([nodeId, b]) => ({ nodeId, key: b.key, source: b.source, at: b.at, seen: b.seenAt ?? null, verified: b.verifiedAt ?? null, vouched: !!b.vouched, superseded: b.superseded || null })); for (const [nodeId, key] of [...this._anchors].reverse()) out.unshift({ nodeId, key, source: 'anchor', at: null }); return out; } @@ -298,11 +371,16 @@ class RosterKeyRegistry { // ── Writes ───────────────────────────────────────────────────────────────────── /** - * Bind `nodeId` to `key` from `source`, by the conflict matrix. + * Bind `nodeId` to `key` from `source`, by the conflict matrix (the module header). + * + * `grant` is a certification the node offers (its settle, for each key-binding grant that entered + * the in-force set, and its start, for every one in force): it acts only when the view names that + * key now, stores nothing, and supersedes a stored binding of lower rank naming another key. * @param {string} nodeId * @param {string} key - base64url Ed25519 public key * @param {'proven'|'pinned'|'grant'|'legacy-claim'} source - * @returns {{ bound: boolean, source?: string, reason?: 'malformed'|'conflict'|'full', had?: string }} + * @returns {{ bound: boolean, source?: string, reason?: 'malformed'|'conflict'|'full'|'not-in-effect', + * had?: string, created?: true, conflict?: 'grant', superseded?: true }} */ bind(nodeId, key, source, { at = Date.now() } = {}) { if (!isNodeId(nodeId) || !isIdentityKey(key) || !SOURCES.has(source) || source === 'anchor') { @@ -322,52 +400,98 @@ class RosterKeyRegistry { return { bound: false, reason: 'conflict', had: this._self.publicKey }; } const b = this._byNode.get(nodeId); - if (b) { - if (b.key === key) { - // A proof of the key bound (never of another: a squatter's conflict arms nothing). - if (source === 'proven') this.armFloor(nodeId); - if (source !== 'grant' && rankOf(source) > rankOf(b.source)) { - b.source = source; - this._track(nodeId, b); - this._persist({ nodeId, key, source }); - } else if (source === 'pinned' && b.source !== source && !b.vouched) { - // A pin naming the key a session proved: the binding is vouched for, and so never - // expires. Written as the source's own line, which replays as this. - b.vouched = true; - this._track(nodeId, b); - this._persist({ nodeId, key, source }); - } - // A grant naming the bound key changes nothing stored: while it is in effect the view keeps - // the binding from expiring (_expirable). - return { bound: true, source: b.source }; + const certs = this._certKeys(nodeId); + if (source === 'grant') return this._certify(nodeId, key, b, certs, at); + // A pin outranks every source but configuration: the same key confirms it, another is a conflict. + if (b && b.source === 'pinned') return this._sameOrConflict(nodeId, b, key, source, at); + if (source === 'pinned') { + if (b && !b.superseded && b.key !== key) { + // A pin never repoints a stored binding the authority has not superseded (design D5: an + // invite never repoints a key): the operator resolves it. + this._recordConflict(nodeId, b.key, b.source, key, source, at); + return { bound: false, reason: 'conflict', had: b.key }; } - this._recordConflict(nodeId, b.key, b.source, key, source, at); - return { bound: false, reason: 'conflict', had: b.key }; + // It outranks a certification: it binds, and a certification naming another key is a + // recorded conflict the pin wins (the operator's explicit local decision). + const other = certs.find((k) => k !== key); + if (other !== undefined) this._recordConflict(nodeId, key, 'pinned', other, 'grant', at, 'pin-over-certification'); + // The same key confirms (and settles a supersession of it); otherwise the pin binds, settling + // a supersession of another key. + const r = b && b.key === key ? this._sameKey(nodeId, b, key, source) : this._create(nodeId, key, source, at); + return other !== undefined && r.bound ? { ...r, conflict: 'grant', had: other } : r; + } + // A session's proof (proven) or a legacy claim: below a certification. + if (certs.length) { + if (b) this._settleCertified(nodeId, b, certs, at); + if (certs.length === 1 && certs[0] === key) { + if (b && !b.superseded && b.key === key) return this._sameKey(nodeId, b, key, source); + // A session proving the certified key stores nothing on the grant's account: a revoke + // removes everything the grant bound. (A first proof still arms the sticky floor.) + if (source === 'proven') { this.armFloor(nodeId); return { bound: true, source: 'grant' }; } + if (!b) return this._create(nodeId, key, source, at); // a legacy claim is its own expected key + return { bound: false, reason: 'conflict', had: b.key }; + } + // The certification decides: another key is an identity conflict, refused and recorded. + const had = certs.find((k) => k !== key); + this._recordConflict(nodeId, had, 'grant', key, source, at); + return { bound: false, reason: 'conflict', had }; + } + if (b && b.superseded) { + // FAIL CLOSED: a binding a certification superseded binds nothing, whatever the key, until the + // operator resolves it or a pin or a new certification settles it. + this._recordConflict(nodeId, b.key, 'conflicted', key, source, at); + return { bound: false, reason: 'conflict', had: b.key, superseded: true }; } - // Nothing stored: the grants in effect now may still vouch a key (the view). A grant is never - // 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' }; + if (b) return this._sameOrConflict(nodeId, b, key, source, at); + return this._create(nodeId, key, source, at); + } + + /** @private A stored binding (not superseded) and a source naming `key`: the same key, or a conflict. */ + _sameOrConflict(nodeId, b, key, source, at) { + if (b.key === key) return this._sameKey(nodeId, b, key, source); + this._recordConflict(nodeId, b.key, b.source, key, source, at); + return { bound: false, reason: 'conflict', had: b.key }; + } + + /** @private A source naming the key bound: keep it; record the stronger source. */ + _sameKey(nodeId, b, key, source) { + // A proof of the key bound (never of another: a squatter's conflict arms nothing). + if (source === 'proven') this.armFloor(nodeId); + if (b.superseded) { + // Only a pin reaches a superseded binding here (any other source is refused before): the + // operator's pin of that very key settles the supersession. + b.superseded = null; + b.source = source; + this._track(nodeId, b); + this._persist({ nodeId, key, source }); + return { bound: true, source: b.source, settled: true }; } - let conflict = null; - if (viewKey !== undefined && viewKey !== key) { - this._recordConflict(nodeId, viewKey, 'grant', key, source, at); - conflict = viewKey; + if (rankOf(source) > rankOf(b.source)) { + b.source = source; + this._track(nodeId, b); + this._persist({ nodeId, key, source }); + } else if (source === 'pinned' && b.source !== source && !b.vouched) { + // A pin naming the key a session proved: the binding is vouched for, and so never + // expires. Written as the source's own line, which replays as this. + b.vouched = true; + this._track(nodeId, b); + this._persist({ nodeId, key, source }); } + // A certification naming the bound key changes nothing stored: while it is in effect the view + // keeps the binding from expiring (expire). + return { bound: true, source: b.source }; + } + + /** + * @private A new binding (or a pin replacing a binding a certification superseded: it settles it). + * Full: what has expired goes, and nothing else (security review D): a binding is never evicted + * before it expires, so a flood of new identities cannot push an honest binding out and squat its + * nodeId. A newcomer refused here is held for its session only (the node verifies what it signs + * under the key its live session proved, for as long as that session lasts). + */ + _create(nodeId, key, source, at) { // A first proof arms the floor even when the registry is full and keeps no binding. if (source === 'proven') this.armFloor(nodeId); - // Full: what has expired goes, and nothing else (security review D): a binding is never evicted - // before it expires, so a flood of new identities cannot push an honest binding out and squat - // its nodeId. A newcomer refused here is held for its session only (the node verifies what it - // 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). @@ -376,11 +500,59 @@ class RosterKeyRegistry { 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 }; + const nb = { key, source, at, seenAt: this._loading ? null : now, verifiedAt: null, vouched: false, seenSaved: this._loading ? null : now, verifiedSaved: null, superseded: null }; this._byNode.set(nodeId, nb); if (!this._loading) this._track(nodeId, nb); this._persist({ nodeId, key, source, seen: now }); - return conflict ? { bound: true, source, created: true, conflict: 'grant', had: conflict } : { bound: true, source, created: true }; + return { bound: true, source, created: true }; + } + + /** + * @private A certification of `nodeId` -> `key` offered (source `grant`). It acts only when the view + * names that key now: a grant not in force says nothing, records nothing and supersedes nothing. + */ + _certify(nodeId, key, b, certs, at) { + if (!certs.includes(key)) { + if (b && !b.superseded && b.key === key) return { bound: true, source: b.source }; + return { bound: false, reason: 'not-in-effect' }; + } + if (b && b.source === 'pinned') { + // The pin wins (the operator's explicit local decision); the disagreement is recorded and shown. + if (b.key === key) return { bound: true, source: 'pinned' }; + this._recordConflict(nodeId, b.key, 'pinned', key, 'grant', at, 'pin-over-certification'); + return { bound: false, reason: 'conflict', had: b.key }; + } + const other = certs.find((k) => k !== key); + // Certifications naming two keys for one nodeId certify neither (fail closed), and are recorded. + if (other !== undefined) this._recordConflict(nodeId, other, 'grant', key, 'grant', at, 'certifications-disagree'); + if (b) this._settleCertified(nodeId, b, certs, at); + if (other !== undefined) return { bound: false, reason: 'conflict', had: other }; + if (b && !b.superseded && b.key === key) return { bound: true, source: b.source === 'proven' ? 'proven' : 'grant' }; + return { bound: true, source: 'grant' }; + } + + /** + * @private The certifications in force now name `certs` for `nodeId`, which has the stored binding + * `b` (not pinned). One naming another key supersedes it: kept on disk, marked, recorded as a + * conflict, never used to verify, and out of the expiry indexes (it never expires and is outside the + * new-binding share). A new certification naming the very key of a superseded binding settles it. + */ + _settleCertified(nodeId, b, certs, at) { + if (certs.length === 1 && b.key === certs[0]) { + if (b.superseded) { + b.superseded = null; + this._track(nodeId, b); + this._persist({ nodeId, key: b.key, superseded: false }); + this._log(`Key registry: ${String(nodeId).slice(0, 8)} — a certification in force names the key that was superseded; it is bound to it again`); + } + return; + } + if (b.superseded) return; // superseded already: it stays so (fail closed) + const by = certs.find((k) => k !== b.key); + b.superseded = by; + this._track(nodeId, b); + this._persist({ nodeId, key: b.key, superseded: by }); + this._recordConflict(nodeId, by, 'grant', b.key, b.source, at, 'superseded'); } /** @@ -400,11 +572,12 @@ class RosterKeyRegistry { /** * Something verified under `nodeId`'s binding: a record, a grant or an attestation it signed (never * a session re-proving the key: security review D). A binding that ever verified something never - * expires. + * expires. With `key`, only a stored binding of that very key is credited: what verified under a + * certification never credits a binding it superseded. */ - noteVerified(nodeId) { + noteVerified(nodeId, key) { const b = this._byNode.get(nodeId); - if (!b) return; + if (!b || b.superseded || (key !== undefined && b.key !== key)) return; const now = this._now(); const first = b.verifiedAt == null; if (b.firstVerifiedAt == null) b.firstVerifiedAt = now; @@ -426,10 +599,11 @@ class RosterKeyRegistry { /** * @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). + * EARNED_TTL_MS), or null (never expires: pinned, vouched, anchored, a legacy claim, or superseded: + * held for the operator, and outside the new-binding share). */ _class(b) { - if (!b || b.source !== 'proven' || b.vouched) return null; + if (!b || b.source !== 'proven' || b.vouched || b.superseded) return null; if (b.verifiedAt == null) return 'short'; const first = b.firstVerifiedAt ?? b.verifiedAt; return b.verifiedAt - first >= ESTABLISH_MS ? 'long' : 'short'; @@ -500,7 +674,8 @@ class RosterKeyRegistry { refusedShareCount() { return this._refusedShare || 0; } /** - * 0.13-compatible spelling of bind, for callers that pin a vouched key (`pin(id, key, 'grant')`). + * 0.13-compatible spelling of bind, for callers that pin a vouched key (`pin(id, key, 'grant')`: it + * acts only when an in-force certification names that key, as `bind` does). * @returns {{ pinned: boolean, reason?: string }} */ pin(nodeId, key, source = 'grant') { @@ -521,7 +696,7 @@ class RosterKeyRegistry { if (!isNodeId(nodeId) || !isIdentityKey(key)) return { resolved: false, reason: 'malformed' }; if (this._anchors.has(nodeId)) return { resolved: false, reason: 'anchor' }; 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._byNode.set(nodeId, { key, source: 'pinned', at: Date.now(), seenAt: this._now(), verifiedAt: null, vouched: false, seenSaved: null, verifiedSaved: null, superseded: null }); this._lru.delete(nodeId); this._lruLong.delete(nodeId); this._persist({ nodeId, key, source: 'pinned', operator: true }); @@ -549,15 +724,31 @@ class RosterKeyRegistry { // ── Internals ────────────────────────────────────────────────────────────────── - _recordConflict(nodeId, had, hadSource, got, gotSource, at) { + /** + * Record a conflict: `had` (from `hadSource`) is what holds, `got` (from `gotSource`) what was + * refused or set aside. `kind` names the certification cases: 'superseded' (a certification set a + * stored binding aside), 'pin-over-certification' (a pin holds against a certification), + * 'certifications-disagree' (two keys certified for one nodeId: neither verifies). + * @private + */ + _recordConflict(nodeId, had, hadSource, got, gotSource, at, kind) { this._conflictCount++; if (this._loading || this._readOnly) return; // a conflict in the file was recorded when it happened if (this._conflicts.some((c) => c.nodeId === nodeId && c.got === got)) return; if (this._conflicts.length >= MAX_CONFLICTS) return; if (this._conflicts.filter((c) => c.nodeId === nodeId).length >= MAX_CONFLICTS_PER_NODE) return; - const c = { nodeId, had, hadSource, got, gotSource, at }; + const c = { nodeId, had, hadSource, got, gotSource, at, ...(kind ? { kind } : {}) }; this._conflicts.push(c); - this._log(`Key conflict: ${String(nodeId).slice(0, 8)} is bound to ${keyFingerprint(had).slice(0, 19)} (${hadSource}); refused ${keyFingerprint(got).slice(0, 19)} (${gotSource}). Resolve with: sym keys resolve ${nodeId} `); + const id8 = String(nodeId).slice(0, 8); + const fp = (k) => keyFingerprint(k).slice(0, 19); + const said = kind === 'superseded' + ? `${id8} is certified to ${fp(had)} by an in-force grant; its stored ${fp(got)} (${gotSource}) is superseded: kept, and it verifies nothing` + : kind === 'pin-over-certification' + ? `${id8} is pinned to ${fp(had)}; an in-force grant certifies ${fp(got)}: the pin holds` + : kind === 'certifications-disagree' + ? `in-force grants certify both ${fp(had)} and ${fp(got)} for ${id8}: neither verifies` + : `${id8} is bound to ${fp(had)} (${hadSource}); refused ${fp(got)} (${gotSource})`; + this._log(`Key conflict: ${said}. Resolve with: sym keys resolve ${nodeId} `); if (!this._dir) return; try { fs.appendFileSync(path.join(this._dir, CONFLICTS_FILE), JSON.stringify(c) + '\n'); } catch { /* best effort */ } } @@ -621,6 +812,12 @@ class RosterKeyRegistry { if (r.floorReset === true) { this._floorReset.add(r.nodeId); return; } if (r.floorReset === false) { this._floorReset.delete(r.nodeId); return; } if (r.expired === true) { this._drop(r.nodeId); return; } + if (r.source === undefined && r.superseded !== undefined) { + // A supersession (a certification's key), or `false`: a certification or a pin settled it. + const b = this._byNode.get(r.nodeId); + if (b && b.key === r.key && b.source !== 'pinned') b.superseded = isIdentityKey(r.superseded) ? r.superseded : null; + return; + } if (r.key === undefined && (Number.isFinite(r.seen) || Number.isFinite(r.verified))) { const b = this._byNode.get(r.nodeId); if (!b) return; @@ -633,12 +830,13 @@ class RosterKeyRegistry { if (source === 'grant') return; // a view over grants, never stored (review C) if (!SOURCES.has(source) || !isIdentityKey(r.key)) return; if (this._self && r.nodeId === this._self.nodeId) return; // this node's own id is configuration - if (r.operator === true) { this._byNode.set(r.nodeId, { key: r.key, source: 'pinned', at: null, seenAt: null, verifiedAt: null, vouched: false, seenSaved: null, verifiedSaved: null }); return; } + if (r.operator === true) { this._byNode.set(r.nodeId, { key: r.key, source: 'pinned', at: null, seenAt: null, verifiedAt: null, vouched: false, seenSaved: null, verifiedSaved: null, superseded: null }); return; } this.bind(r.nodeId, r.key, source, { at: null }); const b = this._byNode.get(r.nodeId); if (b && b.key === r.key) { this._applyFacts(b, r); if (r.vouched === true) b.vouched = true; + if (isIdentityKey(r.superseded) && b.source !== 'pinned') b.superseded = r.superseded; // a compacted line } } @@ -682,7 +880,7 @@ class RosterKeyRegistry { // grant's key without checking that its grantor was authorised when it signed or still is, so // it is the EXPECTED key, never one that verifies. Authority now comes only from §6.6 statements, // whose in-force set is the view that binds. - this._byNode.set(nodeId, { key: b.key, source: 'legacy-claim', at: null, seenAt: null, verifiedAt: null, vouched: false, seenSaved: null, verifiedSaved: null }); + this._byNode.set(nodeId, { key: b.key, source: 'legacy-claim', at: null, seenAt: null, verifiedAt: null, vouched: false, seenSaved: null, verifiedSaved: null, superseded: null }); counts.bindings++; counts.legacyClaim++; if (b.source === 'grant') counts.grant++; @@ -700,6 +898,7 @@ class RosterKeyRegistry { 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; + if (b.superseded) line.superseded = b.superseded; lines.push(JSON.stringify(line)); } for (const nodeId of this._floorArmed) lines.push(JSON.stringify({ nodeId, floor: true })); diff --git a/tests/authority-node.test.js b/tests/authority-node.test.js index 6545b5e2..a5ae9ec4 100644 --- a/tests/authority-node.test.js +++ b/tests/authority-node.test.js @@ -302,7 +302,9 @@ describe('§6.6.8: the frames, their bounds and pacing', () => { }); describe('§6.6.9 and §6.6.10 on a node', () => { - it('an in-force grant is a binding source; a conflicting one leaves the binding as it was; one naming this node with a foreign key confers nothing and is reported', async () => { + // Changed in 0.14.3 (the certification design): an anchor certification outranks a proven binding, so + // the conflicting grant supersedes it rather than leaving it as it was. + it('an in-force grant is a binding source; a conflicting certification supersedes a proven binding; one naming this node with a foreign key confers nothing and is reported', async () => { try { const N = node('auth-view'); const p = party(); const q = party(); const qOther = party(); @@ -314,8 +316,10 @@ describe('§6.6.9 and §6.6.10 on a node', () => { const foreign = grant('anchor', { nodeId: N.nodeId, key: qOther.key }, 'admin'); N.submitAuthority(foreign); assert.strictEqual(N._identityKey(p.nodeId), p.key, 'the grant taught the key'); - assert.strictEqual(N._identityKey(q.nodeId), q.key, 'a proven binding stays'); - assert.strictEqual(N.resolveRole(q.nodeId), 'participant', 'roles follow the key the node is bound to'); + assert.strictEqual(N._identityKey(q.nodeId), qOther.key, 'the certification supersedes the proven binding'); + assert.deepStrictEqual(N._roster.superseded(q.nodeId), { key: q.key, by: qOther.key }, 'kept, set aside'); + assert.strictEqual(N.resolveRole(q.nodeId), 'admin', 'roles follow the key the node is bound to'); + assert.strictEqual(N.resolveRole(q.nodeId, { key: q.key }), 'participant', 'and give nothing to the superseded key'); assert.strictEqual(N.resolveRole(N.nodeId), 'participant', 'a grant to this nodeId with a foreign key confers nothing'); assert.ok(metrics.some((m) => m.type === 'authority-foreign-self-key' && m.id === sid(foreign))); } finally { await stopAll(); } diff --git a/tests/roster-keys.test.js b/tests/roster-keys.test.js index 727c6a9e..e76c0e95 100644 --- a/tests/roster-keys.test.js +++ b/tests/roster-keys.test.js @@ -69,18 +69,44 @@ describe('the conflict matrix, row by row', () => { assert.strictEqual(r.conflicts().length, 0); }); - 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]]); - }); - } + // Changed in 0.14.3 (the certification design): an anchor certification outranks a session, so a + // proven source naming another key is refused; a pin outranks the certification, so it binds. + it('a certification binds the nodeId, a DIFFERENT key from proven → refused and recorded: the certification decides', () => { + 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, 'proven'); + assert.deepStrictEqual([res.bound, res.reason, res.had], [false, 'conflict', k1]); + assert.strictEqual(r.get('N'), k1); + assert.strictEqual(r.size(), 0, 'nothing stored'); + assert.deepStrictEqual(r.conflicts().map((c) => [c.nodeId, c.had, c.got, c.hadSource, c.gotSource]), [['N', k1, k2, 'grant', 'proven']]); + }); + + it('a certification binds the nodeId, a DIFFERENT key from pinned → bound pinned: the pin holds, the disagreement recorded', () => { + const k1 = key(); const k2 = key(); + const r = new RosterKeyRegistry({ grantView: (id) => (id === 'N' ? k1 : undefined) }); + const res = r.bind('N', k2, 'pinned'); + assert.deepStrictEqual([res.bound, res.conflict, res.had], [true, 'grant', k1]); + assert.strictEqual(r.get('N'), k2); + assert.deepStrictEqual(r.conflicts().map((c) => [c.nodeId, c.had, c.got, c.hadSource, c.gotSource, c.kind]), [['N', k2, k1, 'pinned', 'grant', 'pin-over-certification']]); + }); + + it('nodeId bound (proven), a certification of a DIFFERENT key → the binding is superseded: kept, marked, recorded, verifying nothing', () => { + const k1 = key(); const k2 = key(); let inEffect = false; + const r = new RosterKeyRegistry({ grantView: (id) => (id === 'N' && inEffect ? k2 : undefined) }); + r.bind('N', k1, 'proven'); + inEffect = true; + assert.strictEqual(r.get('N'), k2, 'the certification decides as soon as it is in force, before it is offered'); + r.noteVerified('N', k2); + assert.strictEqual(r.entries()[0].verified, null, 'and what verifies under it credits nothing to the binding of another key'); + assert.strictEqual(r.bind('N', k2, 'grant').bound, true); + assert.strictEqual(r.get('N'), k2, 'the certification decides'); + assert.deepStrictEqual(r.superseded('N'), { key: k1, by: k2 }); + assert.deepStrictEqual(r.conflicts().map((c) => [c.nodeId, c.had, c.got, c.hadSource, c.gotSource, c.kind]), [['N', k2, k1, 'grant', 'proven', 'superseded']]); + inEffect = false; + assert.strictEqual(r.get('N'), undefined, 'revoked: still conflicted (fail closed)'); + assert.strictEqual(r.source('N'), 'conflicted'); + }); 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; @@ -92,7 +118,8 @@ describe('the conflict matrix, row by row', () => { 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']]) { + // ['proven', 'grant'] left this list in 0.14.3: a certification supersedes a stored proven binding (above). + for (const [first, second] of [['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. @@ -167,7 +194,7 @@ describe('persistence and the 0.13 file', () => { // The floor arms for a routed nodeId on a proof, as its own persisted fact (security review). const r1 = new RosterKeyRegistry({ dir, isRouted: (id) => id === 'N' || id === 'M' }); r1.bind('N', k, 'proven'); - r1.bind('N', k2, 'grant'); + r1.bind('N', k2, 'proven'); // a conflict (0.14.3: a grant not in force records nothing, so a session makes it) r1.bind('M', key(), 'proven'); r1.resetFloor('M'); assert.deepStrictEqual(lines(dir)[0], { ...FORMAT_MARKER }); diff --git a/tests/security-0.14.3-cert.test.js b/tests/security-0.14.3-cert.test.js new file mode 100644 index 00000000..e9ee4d90 --- /dev/null +++ b/tests/security-0.14.3-cert.test.js @@ -0,0 +1,473 @@ +'use strict'; + +process.env.SYM_SEMANTIC_ENCODER = process.env.SYM_SEMANTIC_ENCODER || 'off'; +require('./_isolate-home'); // redirect $HOME before lib/config loads + +/** + * Key-binding precedence (0.14.3, the certification design). A nodeId is self-chosen, so "nodeId X + * has key K" is a statement some source makes, and precedence decides which key verifies: + * + * the anchor pin and this node's own key > an operator's out-of-band pin > an anchor + * certification (an in-force key-binding grant: unscoped, its chain unscoped admins up to the + * anchor) > a proven session (trust on first proven use) > earned and legacy-claim bindings. + * + * The R2-1 fix on 9f6847c made the certification the WEAKEST source: a squatter that reached a + * receiver first with its own key under a certified nodeId kept it. Each rule is a test here; the + * ones that fail on 9f6847c say "fails before", the ones that already held there say "held before". + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { SymNode } = require('../lib/node'); +const { NullDiscovery } = require('../lib/discovery'); +const { nodeDirById } = require('../lib/config'); +const { RosterKeyRegistry } = require('../lib/roster-keys'); +const A = require('../lib/core/authority'); +const { identity, connectNodes, admitAs, deliver, signedRecord } = require('./_core-secure'); +const { settle } = require('./_settle'); + +const uniq = (b) => `${b}-${process.pid}-${Math.random().toString(36).slice(2, 7)}`; +const made = []; +function mk(base, extra = {}) { + const n = new SymNode({ name: extra.name || uniq(base), silent: true, discovery: new NullDiscovery(), room: 'cert', ...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 anchor = identity('anchor'); +const PIN = { threshold: 1, keys: [{ key: anchor.publicKey }] }; +const sign = (byId, by, fields) => A.signStatement({ ...fields, authorisedBy: byId, nonce: A.freshNonce(), sigs: [] }, by.privateKey, by.publicKey); +/** The anchor certifies nodeId -> key (an unscoped anchor grant: a key-binding source). */ +function certify(R, nodeId, key, role = 'participant') { + const g = sign('anchor', anchor, { kind: 'grant', subject: { nodeId, key }, role }); + assert.strictEqual(R.submitAuthority(g).status, 'in-force'); + return g; +} +function revoke(R, g) { + assert.strictEqual(R.submitAuthority(sign('anchor', anchor, { kind: 'revoke', targets: [A.statementId(g)] })).status, 'in-force'); +} +/** A node that claims `victim`'s nodeId under its own key (the attacker runs what it likes). */ +function squatterOf(victim, base = 'squatter') { + const S = mk(base, { anchor: PIN }); + S.nodeId = victim.nodeId; if (S._identity) S._identity.nodeId = victim.nodeId; + return S; +} +const refusedConnect = async (a, b) => { await assert.rejects(connectNodes(a, b, { timeoutMs: 1500 }), /did not confirm/); }; +/** Deliver a room record by `author` on `session`; resolve with the verified-record events for its nodeId. */ +async function recordAs(R, session, author, focus) { + const seen = []; + const on = (e) => { if (e.verification && e.verification.authorNodeId === author.nodeId) seen.push(e); }; + R.on('verified-record', on); + try { + deliver(R, session, { type: 'cmb', timestamp: Date.now(), cmb: signedRecord(author, { categories: { focus }, room: 'cert' }) }); + await settle(60); + } finally { R.off('verified-record', on); } + return seen; +} +const key = () => identity().publicKey; +const DAY = 24 * 60 * 60 * 1000; + +describe('rule 1 and 2: an anchor certification outranks a session and any earned binding', () => { + it('R2-1 still holds: a scoped issuer seating one world binds no key, and the victim\'s handshake is admitted (held before)', async () => { + try { + const R = mk('c-r21', { anchor: PIN }); + const V = mk('c-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'); + assert.strictEqual(R._identityKey(V.nodeId), undefined, 'a scoped grant certifies nothing'); + assert.strictEqual(R._authority.grantKey(V.nodeId), undefined); + const carrier = admitAs(R, I); + assert.strictEqual((await recordAs(R, carrier, { nodeId: V.nodeId, name: 'victim', privateKey: I.privateKey }, 'forged in the victim name')).length, 0, 'a record in the victim\'s name signed by the issuer is not verified'); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.ok(R._peers.has(V.nodeId), 'the victim connects'); + assert.strictEqual(R._roster.conflicts().length, 0, 'and a scoped grant fills no conflict record'); + } finally { await stopAll(); } + }); + + it('certified X -> K1, then a squatter proving X with K2 at a fresh receiver: refused, recorded, shown; the K1 session is admitted (fails before)', async () => { + try { + const R = mk('c-fresh', { anchor: PIN }); + const V = mk('c-fresh-victim', { anchor: PIN }); + const S = squatterOf(V); + certify(R, V.nodeId, V._identity.publicKey); + const metrics = []; R.on('metric', (m) => { if (m.type === 'identity-conflict') metrics.push(m); }); + await refusedConnect(S, R); + assert.ok(!R._peers.has(V.nodeId), 'the squatter is not a peer'); + assert.ok(R._roster.conflicts().some((c) => c.nodeId === V.nodeId && c.had === V._identity.publicKey && c.hadSource === 'grant' && c.got === S._identity.publicKey), 'the conflict is recorded'); + assert.ok(R.status().coreSecure.keyConflicts.some((c) => c.nodeId === V.nodeId && c.got === S._identity.publicKey), 'and shown in status'); + assert.ok(metrics.some((m) => m.nodeId === V.nodeId && m.against === 'grant'), 'never silent'); + const carrier = admitAs(R, identity('carrier')); + assert.strictEqual((await recordAs(R, carrier, { nodeId: V.nodeId, name: 'v', privateKey: S._identity.privateKey }, 'squatter speaks')).length, 0, 'a record claiming X signed by K2 is not verified as X'); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.strictEqual(R._peers.get(V.nodeId).identityKey, V._identity.publicKey, 'the certified key is admitted'); + assert.strictEqual(R._roster.durable(V.nodeId), undefined, 'and stores nothing on the grant\'s account'); + } finally { await stopAll(); } + }); + + it('a stored TOFU binding X -> K2, then a certification X -> K1: K2 is superseded (kept, marked, recorded), verifies nothing, and its live session is closed (fails before)', async () => { + try { + const R = mk('c-supersede', { anchor: PIN }); + if (typeof R._store.load === 'function') await R._store.load(); + const X = identity('x-honest'); + const squat = { ...identity('x-squat'), nodeId: X.nodeId }; + const s = admitAs(R, squat); + assert.strictEqual((await recordAs(R, s, squat, 'first in')).length, 1); + assert.strictEqual(R._roster.get(X.nodeId), squat.publicKey, 'the scene: the squatter earned a TOFU binding'); + assert.strictEqual(R._roster.source(X.nodeId), 'proven'); + const before = R._roster.entries().find((e) => e.nodeId === X.nodeId); + certify(R, X.nodeId, X.publicKey); + assert.strictEqual(R._identityKey(X.nodeId), X.publicKey, 'the certification decides'); + assert.strictEqual(R._keySource(X.nodeId), 'grant'); + assert.strictEqual(R._roster.durable(X.nodeId), squat.publicKey, 'the superseded binding is kept on disk'); + assert.strictEqual(R._roster.entries().find((e) => e.nodeId === X.nodeId).superseded, X.publicKey, 'and marked'); + assert.ok(R._roster.conflicts().some((c) => c.nodeId === X.nodeId && c.kind === 'superseded' && c.had === X.publicKey && c.got === squat.publicKey), 'and recorded'); + assert.ok(s.closed && s.sent.some((f) => f.type === 'error' && f.code === 1009), 'the squatter\'s live session is closed as an identity conflict'); + const carrier = admitAs(R, identity('carrier')); + assert.strictEqual((await recordAs(R, carrier, squat, 'still me')).length, 0, 'a record by X signed with K2 is not verified'); + const sx = admitAs(R, X); + const ok = await recordAs(R, sx, X, 'the real X'); + assert.strictEqual(ok.length, 1, 'one signed with K1 is'); + assert.strictEqual(ok[0].verification.authorKey, X.publicKey); + const after = R._roster.entries().find((e) => e.nodeId === X.nodeId); + assert.strictEqual(after.verified, before.verified, 'what verifies under the certification credits nothing to the binding it superseded'); + assert.deepStrictEqual(R.keyBindings().filter((b) => b.nodeId === X.nodeId).map((b) => [b.key, b.source]), [[X.publicKey, 'grant']], 'a host reading keyBindings sees the key that verifies, once'); + } finally { await stopAll(); } + }); + + it('a certification outranks a live session at once: between a resolution and its settle, nothing verifies under the session\'s key (fails before)', async () => { + try { + const R = mk('c-order', { anchor: PIN }); + const X = identity('x'); + const squat = { ...identity('x-squat'), nodeId: X.nodeId }; + admitAs(R, squat); + assert.strictEqual(R._identityKey(X.nodeId), squat.publicKey, 'the scene: a live session-scoped binding'); + const g = sign('anchor', anchor, { kind: 'grant', subject: { nodeId: X.nodeId, key: X.publicKey }, role: 'participant' }); + assert.strictEqual(R._authority.ingest(g).result, 'held'); + R._authority.resolve(); // resolved, not yet settled: no session has been closed + assert.strictEqual(R._identityKey(X.nodeId), X.publicKey); + } finally { await stopAll(); } + }); + + it('a session admitted before the certification is closed when it enters, and the certified key connects (fails before)', async () => { + try { + const R = mk('c-live', { anchor: PIN }); + const V = mk('c-live-victim', { anchor: PIN }); + const S = squatterOf(V); + await connectNodes(S, R, { timeoutMs: 3000 }); + assert.strictEqual(R._identityKey(V.nodeId), S._identity.publicKey, 'the scene: the squatter came first (session-scoped)'); + certify(R, V.nodeId, V._identity.publicKey); + await settle(60); + assert.ok(!R._peers.has(V.nodeId), 'its session is closed'); + assert.ok(R._roster.conflicts().some((c) => c.nodeId === V.nodeId && c.got === S._identity.publicKey)); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.strictEqual(R._peers.get(V.nodeId).identityKey, V._identity.publicKey); + } finally { await stopAll(); } + }); +}); + +describe('rule 3: revoked, a superseded binding stays conflicted; a nodeId with nothing else is unbound', () => { + it('after the revoke the superseded binding verifies nothing and every session for X is refused until the operator resolves it (fails before)', async () => { + try { + const R = mk('c-revoke', { anchor: PIN }); + const V = mk('c-revoke-victim', { anchor: PIN }); + const S = squatterOf(V); + R._roster.bind(V.nodeId, S._identity.publicKey, 'proven'); // the squatter's earned binding + const g = certify(R, V.nodeId, V._identity.publicKey); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.ok(R._peers.has(V.nodeId), 'certified, the real key connects'); + revoke(R, g); + await settle(60); + assert.strictEqual(R._identityKey(V.nodeId), undefined, 'fail closed: the authority once said K2 was not X\'s'); + assert.strictEqual(R._roster.get(V.nodeId), undefined); + assert.strictEqual(R._keySource(V.nodeId), 'conflicted'); + assert.ok(!R._peers.has(V.nodeId), 'the session the certification admitted is closed too: X is conflicted'); + assert.ok(!R.keyBindings().some((b) => b.nodeId === V.nodeId), 'no key is listed for a conflicted nodeId'); + await refusedConnect(S, R); + await refusedConnect(V, R); + assert.deepStrictEqual(R._roster.resolveConflict(V.nodeId, V._identity.publicKey), { resolved: true }); + assert.strictEqual(R._identityKey(V.nodeId), V._identity.publicKey, 'the operator resolved it'); + } finally { await stopAll(); } + }); + + it('a nodeId only a certification bound is unbound by the revoke: a later session binds it by first proven use (held before)', async () => { + try { + const R = mk('c-unbound', { anchor: PIN }); + const V = mk('c-unbound-victim', { anchor: PIN }); + const g = certify(R, V.nodeId, V._identity.publicKey); + assert.strictEqual(R._keySource(V.nodeId), 'grant'); + revoke(R, g); + assert.strictEqual(R._roster.source(V.nodeId), undefined, 'nothing binds it'); + assert.strictEqual(R._roster.size(), 0, 'nothing was stored on the grant\'s account'); + const S = squatterOf(V, 'later'); + await connectNodes(S, R, { timeoutMs: 3000 }); + assert.strictEqual(R._identityKey(V.nodeId), S._identity.publicKey, 'trust on first proven use'); + } finally { await stopAll(); } + }); + + it('a supersession survives a restart: a node that starts with a certification in force sets aside a stored binding of another key (fails before)', async () => { + try { + const R1 = mk('c-restart', { anchor: PIN }); + const X = identity('x'); const K2 = key(); + R1._roster.bind(X.nodeId, K2, 'proven'); + // The certification held, as a 0.14.3-base node left it: the registry was never told. + const g = sign('anchor', anchor, { kind: 'grant', subject: { nodeId: X.nodeId, key: X.publicKey }, role: 'participant' }); + assert.strictEqual(R1._authority.ingest(g).result, 'held'); + const name = R1.name; + await R1.stop(); + const R2 = new SymNode({ name, silent: true, discovery: new NullDiscovery(), room: 'cert', anchor: PIN }); + made.push(R2); + assert.strictEqual(R2._identityKey(X.nodeId), X.publicKey); + assert.deepStrictEqual(R2._roster.superseded(X.nodeId), { key: K2, by: X.publicKey }, 'set aside at start'); + revoke(R2, g); + assert.strictEqual(R2._identityKey(X.nodeId), undefined, 'and it stays conflicted after the revoke'); + } finally { await stopAll(); } + }); +}); + +describe('rule 4: a pin outranks a certification; the conflict is recorded and shown', () => { + it('pin first, then a certification of another key: the pin verifies, its session is admitted, the conflict is recorded (held before)', async () => { + try { + const R = mk('c-pin1', { anchor: PIN }); + const V = mk('c-pin1-v', { anchor: PIN }); + assert.strictEqual(R.acceptInvite(V.inviteURL({ room: 'cert' })).pinned, true); + const other = key(); + certify(R, V.nodeId, other); + assert.strictEqual(R._identityKey(V.nodeId), V._identity.publicKey, 'the pin wins'); + assert.strictEqual(R._keySource(V.nodeId), 'pinned'); + const c = R.status().coreSecure.keyConflicts.find((x) => x.nodeId === V.nodeId); + assert.ok(c && new Set([c.had, c.got]).has(other) && new Set([c.had, c.got]).has(V._identity.publicKey), 'recorded and shown'); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.ok(R._peers.has(V.nodeId)); + } finally { await stopAll(); } + }); + + it('a certification first, then a pin of another key: the pin binds and verifies, the conflict is recorded (held before)', async () => { + try { + const R = mk('c-pin2', { anchor: PIN }); + const V = mk('c-pin2-v', { anchor: PIN }); + const other = key(); + certify(R, V.nodeId, other); + assert.strictEqual(R.acceptInvite(V.inviteURL({ room: 'cert' })).pinned, true); + assert.strictEqual(R._identityKey(V.nodeId), V._identity.publicKey); + const c = R.status().coreSecure.keyConflicts.find((x) => x.nodeId === V.nodeId); + assert.ok(c && new Set([c.had, c.got]).has(other), 'recorded and shown'); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.ok(R._peers.has(V.nodeId)); + } finally { await stopAll(); } + }); + + it('a pin settles a binding a certification superseded (fails before)', () => { + const view = new Map(); + const r = new RosterKeyRegistry({ grantView: (id) => view.get(id) }); + const K1 = key(); const K2 = key(); + r.bind('X', K2, 'proven'); + view.set('X', K1); r.bind('X', K1, 'grant'); + view.delete('X'); + assert.strictEqual(r.get('X'), undefined, 'conflicted'); + assert.strictEqual(r.bind('X', K1, 'pinned').bound, true, 'a pin of the certified key settles it'); + assert.strictEqual(r.get('X'), K1); + assert.strictEqual(r.source('X'), 'pinned'); + // A pin of the superseded key itself settles it too (the operator's decision). + r.bind('Y', K2, 'proven'); + view.set('Y', K1); r.bind('Y', K1, 'grant'); + view.delete('Y'); + assert.strictEqual(r.get('Y'), undefined); + assert.strictEqual(r.bind('Y', K2, 'pinned').bound, true); + assert.strictEqual(r.get('Y'), K2); + assert.strictEqual(r.superseded('Y'), null, 'no longer set aside'); + }); + + it('a pin made while a session holds the nodeId under another key closes that session (fails before)', async () => { + try { + const R = mk('c-pin-live', { anchor: PIN }); + const V = mk('c-pin-live-v', { anchor: PIN }); + const S = squatterOf(V); + await connectNodes(S, R, { timeoutMs: 3000 }); + assert.strictEqual(R.acceptInvite(V.inviteURL({ room: 'cert' })).pinned, true); + await new Promise((r) => setTimeout(r, 60)); + assert.ok(!R._peers.has(V.nodeId), 'the squatter\'s session is closed: the pin decides'); + await connectNodes(V, R, { timeoutMs: 3000 }); + assert.strictEqual(R._peers.get(V.nodeId).identityKey, V._identity.publicKey); + } finally { await stopAll(); } + }); +}); + +describe('rule 5: a grant gives its role only to records verified under its subject key', () => { + const scopes = { test: () => true }; + const cmb = { metadata: {}, categories: {} }; + + it('a grant naming X with K2 gives nothing to X verified under K1, nor to another nodeId holding K2; a grant of K1 gives its role inside its scope only (held before)', async () => { + try { + const R = mk('c-roles', { anchor: PIN, authorityScopes: scopes }); + const X = identity('x'); const other = identity('other'); + R._roster.bind(X.nodeId, X.publicKey, 'pinned'); + // An unscoped validator grant naming X with K2 is a certification of K2, which the pin outranks; + // a scoped one certifies nothing. Neither gives anything to X's real key. + certify(R, X.nodeId, other.publicKey, 'validator'); + assert.strictEqual(R.submitAuthority(sign('anchor', anchor, { kind: 'grant', subject: { nodeId: X.nodeId, key: other.publicKey }, role: 'validator', scope: 'test:w' })).status, 'in-force'); + assert.strictEqual(R._identityKey(X.nodeId), X.publicKey); + assert.strictEqual(R.resolveRole(X.nodeId), 'participant'); + assert.strictEqual(R.resolveRole(X.nodeId, { cmb }), 'participant'); + assert.strictEqual(R.lifecycleAuthority(X.nodeId, X.publicKey, cmb), 'none'); + assert.strictEqual(R._attesterRole({ by: X.nodeId, of: 'cmb-none' }, X.publicKey), 'participant'); + assert.strictEqual(R.resolveRole(other.nodeId, { key: other.publicKey, cmb }), 'participant', 'nothing to another nodeId holding K2'); + assert.strictEqual(R.lifecycleAuthority(other.nodeId, other.publicKey, cmb), 'none'); + assert.strictEqual(R.submitAuthority(sign('anchor', anchor, { kind: 'grant', subject: { nodeId: X.nodeId, key: X.publicKey }, role: 'validator', scope: 'test:w' })).status, 'in-force'); + assert.strictEqual(R.lifecycleAuthority(X.nodeId, X.publicKey, cmb), 'validated', 'the grant of K1, inside its scope'); + assert.strictEqual(R.resolveRole(X.nodeId), 'participant', 'and only there'); + assert.strictEqual(R._identityKey(X.nodeId), X.publicKey, 'a scoped grant binds no key'); + } finally { await stopAll(); } + }); + + it('an attestation is weighed under the key that verified it, not the key bound now (fails before: the role was judged under the bound key)', async () => { + try { + const R = mk('c-attkey', { anchor: PIN }); + const X = identity('x'); const other = identity('other'); + certify(R, X.nodeId, other.publicKey, 'validator'); + assert.strictEqual(R._identityKey(X.nodeId), other.publicKey, 'the scene: X is certified to K2 now'); + assert.strictEqual(R._attesterRole({ by: X.nodeId, of: 'cmb-none' }, X.publicKey), 'participant', 'one verified under K1 gets nothing from the grant of K2'); + assert.strictEqual(R._attesterRole({ by: X.nodeId, of: 'cmb-none' }, other.publicKey), 'validator'); + } finally { await stopAll(); } + }); +}); + +describe('a certified key is a key this node may grant a role to', () => { + it('grantRole names the certified key of a nodeId with no session here (fails before)', async () => { + try { + const R = mk('c-grantrole', { anchor: PIN }); + certify(R, R.nodeId, R._identity.publicKey, 'admin'); // R signs below its own admin grant + const X = identity('x'); + certify(R, X.nodeId, X.publicKey); + const s = R.grantRole(X.nodeId, 'validator'); + assert.ok(s, 'made'); + assert.strictEqual(s.subject.key, X.publicKey); + } finally { await stopAll(); } + }); +}); + +describe('rule 6: this node\'s own nodeId is never bound by a grant', () => { + it('a certification naming this node with a foreign key binds nothing and is reported (held before)', async () => { + try { + const R = mk('c-self', { anchor: PIN }); + const metrics = []; R.on('metric', (m) => { if (m.type === 'authority-foreign-self-key') metrics.push(m); }); + certify(R, R.nodeId, key(), 'admin'); + assert.strictEqual(R._identityKey(R.nodeId), R._identity.publicKey); + assert.strictEqual(R._roster.source(R.nodeId), 'self'); + assert.strictEqual(R._roster.size(), 0, 'nothing stored for it'); + assert.strictEqual(metrics.length, 1); + } finally { await stopAll(); } + }); +}); + +describe('rule 7: a certification is no earned binding; a superseded one is outside the share and never expires', () => { + it('a superseded binding leaves the new-binding share and never expires; a certification never ages out (fails before)', () => { + let t = 1_000_000; + const view = new Map(); + const r = new RosterKeyRegistry({ now: () => t, maxBindings: 16, grantView: (id) => view.get(id) }); // share: 4 + const keys = [key(), key(), key(), key()]; + keys.forEach((k, i) => assert.strictEqual(r.bind(`n${i}`, k, 'proven').created, true)); + assert.strictEqual(r.bind('late', key(), 'proven').reason, 'full', 'the scene: the share is full'); + const K1 = key(); + view.set('n0', K1); r.bind('n0', K1, 'grant'); + assert.strictEqual(r.bind('late', key(), 'proven').created, true, 'a superseded binding does not count against the share'); + const C = key(); view.set('c', C); + t += 400 * DAY; + r.expire(); + assert.strictEqual(r.durable('n0'), keys[0], 'the superseded binding never expires'); + assert.strictEqual(r.get('c'), C, 'a certification is a view: nothing to age out'); + assert.ok(!r.entries().some((e) => e.nodeId === 'c'), 'and nothing stored for it'); + r.noteVerified('n0'); + assert.strictEqual(r.entries().find((e) => e.nodeId === 'n0').verified, null, 'nothing credits a superseded binding'); + view.delete('n0'); + t += 400 * DAY; + r.expire(); + assert.strictEqual(r.get('n0'), undefined, 'revoked, it is still conflicted'); + assert.strictEqual(r.bind('n0', key(), 'proven').reason, 'conflict', 'and no newcomer takes its nodeId'); + }); +}); + +describe('the registry: certifications act only while in force, settle, persist and contradict', () => { + it('a grant not in force supersedes nothing and records nothing (fails before: it recorded a conflict)', () => { + const r = new RosterKeyRegistry(); + const K2 = key(); + r.bind('X', K2, 'proven'); + assert.strictEqual(r.bind('X', key(), 'grant').reason, 'not-in-effect'); + assert.strictEqual(r.get('X'), K2); + assert.strictEqual(r.conflicts().length, 0); + }); + + it('fail closed: a superseded binding refuses every source but a pin, its own key included (fails before)', () => { + const view = new Map(); + const r = new RosterKeyRegistry({ grantView: (id) => view.get(id) }); + const K1 = key(); const K2 = key(); + r.bind('X', K2, 'legacy-claim'); + view.set('X', K1); r.bind('X', K1, 'grant'); + assert.strictEqual(r.get('X'), K1, 'a certification outranks a legacy claim of another key'); + view.delete('X'); + for (const [k, src] of [[K2, 'proven'], [K1, 'proven'], [key(), 'proven'], [K2, 'legacy-claim']]) { + const res = r.bind('X', k, src); + assert.strictEqual(res.bound, false, `${src} refused`); + assert.strictEqual(res.reason, 'conflict'); + } + assert.strictEqual(r.get('X'), undefined); + }); + + it('a new certification naming the superseded key settles it (fails before)', () => { + const view = new Map(); + const r = new RosterKeyRegistry({ grantView: (id) => view.get(id) }); + const K1 = key(); const K2 = key(); + r.bind('X', K2, 'proven'); + view.set('X', K1); r.bind('X', K1, 'grant'); + view.delete('X'); + assert.strictEqual(r.get('X'), undefined); + view.set('X', K2); r.bind('X', K2, 'grant'); + assert.strictEqual(r.superseded('X'), null, 'settled'); + view.delete('X'); + assert.strictEqual(r.get('X'), K2, 'and it outlives that certification: it is its own binding'); + }); + + it('certifications naming two keys for one nodeId certify neither: nothing verifies as it (fails before)', async () => { + try { + const R = mk('c-two', { anchor: PIN }); + const V = mk('c-two-v', { anchor: PIN }); + const Ad = identity('admin'); + const gAd = sign('anchor', anchor, { kind: 'grant', subject: { nodeId: Ad.nodeId, key: Ad.publicKey }, role: 'admin' }); + assert.strictEqual(R.submitAuthority(gAd).status, 'in-force'); + certify(R, V.nodeId, V._identity.publicKey); + assert.strictEqual(R.submitAuthority(sign(A.statementId(gAd), Ad, { kind: 'grant', subject: { nodeId: V.nodeId, key: Ad.publicKey }, role: 'participant' })).status, 'in-force'); + assert.strictEqual(R._identityKey(V.nodeId), undefined, 'fail closed'); + await refusedConnect(V, R); + assert.ok(R._roster.conflicts().some((c) => c.nodeId === V.nodeId && c.kind === 'certifications-disagree'), 'recorded'); + } finally { await stopAll(); } + }); + + it('a supersession is persisted: it reloads, and compacts (fails before)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rk-cert-')); + try { + const view = new Map(); + const K1 = key(); const K2 = key(); + const r1 = new RosterKeyRegistry({ dir, grantView: (id) => view.get(id) }); + r1.bind('X', K2, 'proven'); + view.set('X', K1); r1.bind('X', K1, 'grant'); + const r2 = new RosterKeyRegistry({ dir }); // nothing certifies now + assert.strictEqual(r2.get('X'), undefined, 'still conflicted after a reload'); + assert.deepStrictEqual(r2.superseded('X'), { key: K2, by: K1 }); + r2._compact(); + const r3 = new RosterKeyRegistry({ dir }); + assert.deepStrictEqual(r3.superseded('X'), { key: K2, by: K1 }, 'a compacted line carries it'); + r3.bind('X', K2, 'pinned'); + assert.strictEqual(new RosterKeyRegistry({ dir }).get('X'), K2, 'and a pin that settled it replays as settled'); + } 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 b002015a..fbfe439e 100644 --- a/tests/security-0.14.3.test.js +++ b/tests/security-0.14.3.test.js @@ -7,7 +7,8 @@ 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-1 a delegating grant, even a scoped one, bound any unbound nodeId to its holder's key (the + * precedence of the certification that remains: security-0.14.3-cert.test.js) * 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 @@ -85,18 +86,20 @@ describe('R2-1: a grant binds a key only when it is unscoped and its chain is th } finally { await stopAll(); } }); - it('R2-1: a grant view never refuses a proving session: the session is admitted and the conflict recorded', async () => { + // Changed by the certification design (0.14.3): an anchor certification outranks a session's proof, + // so a session proving another key is refused (security-0.14.3-cert.test.js has the rest). + it('R2-1: an anchor certification refuses a session proving another key: refused, 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'); + assert.strictEqual(R._identityKey(V.nodeId), evil.publicKey, 'the scene: the anchor certifies another key'); + await assert.rejects(connectNodes(V, R, { timeoutMs: 1500 }), /did not confirm/); + assert.ok(!R._peers.has(V.nodeId), 'the certification decides which key is the nodeId\'s'); + assert.strictEqual(R._identityKey(V.nodeId), evil.publicKey); + assert.ok(R._roster.conflicts().some((c) => c.nodeId === V.nodeId && c.hadSource === 'grant' && c.got === V._identity.publicKey), 'the conflict is recorded'); } finally { await stopAll(); } }); From a1615d1de020ee8d2d52910787076a2bfe1aaac2 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 14 Oct 2026 19:30:13 +0100 Subject: [PATCH 2/2] Docs: the certification design as built (CHANGELOG 0.14.3 R2-1 and tests, SECURITY, README, operator guide) The R2-1 entry described the grant view as the weakest source; it now describes the precedence as built: the anchor and this node's own key, an out-of-band pin, an anchor certification, trust on first proven use, a legacy claim; supersession, failing closed after a revoke, pins over certifications, two certified keys, roles under the verified key. The Tests section gains the new test file, the updated tests with their reasons, the 28-mutant check and this branch's suite, integration, gate and audit results. SECURITY and the README no longer say that no source overrides a different key; the operator guide says that an unscoped grant certifies its key, and what a wrong key costs. --- CHANGELOG.md | 52 +++++++++++++++++++++++++++++++------- README.md | 16 +++++++----- SECURITY.md | 43 +++++++++++++++++++++---------- docs/AUTHORITY-OPERATOR.md | 9 +++++++ 4 files changed, 91 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c5b475..40d8af48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 24bd3e3a..babec692 100644 --- a/README.md +++ b/README.md @@ -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 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 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. diff --git a/SECURITY.md b/SECURITY.md index c7e716bf..87445baf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/docs/AUTHORITY-OPERATOR.md b/docs/AUTHORITY-OPERATOR.md index a477dea1..8104b58f 100644 --- a/docs/AUTHORITY-OPERATOR.md +++ b/docs/AUTHORITY-OPERATOR.md @@ -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