From 0bafb27b2e9d41f0326af0e1e177c961fdcf7db1 Mon Sep 17 00:00:00 2001 From: "SYM.BOT" Date: Wed, 29 Jul 2026 18:46:23 +0100 Subject: [PATCH] fix(security): record WHY a CMB was refused, not a constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifyCMB already distinguishes legacy-key-rejected / bad-signature / content-mismatch / no-public-key. Every one of them was recorded as the constant focusLabel 'bad-signature' while the real reason went only to the console. On this host that made 1,538 rejections mutually indistinguishable in the decision log — 32.1% of all 4,791 SVAF decisions, the largest single verdict class. Separating them took a day of inference across three seats and still did not settle the mechanism; recording one field would have answered it in five minutes. The rejections also happen BEFORE SVAF evaluates, so they appear in no drift distribution and no admission tally: every complementarity figure any of us has published is conditioned on the survivors, and the exclusion is not random. Three changes, all in the one branch that writes this verdict: 1. focusLabel carries v.error instead of the constant, so the four verdicts are countable rather than inferable. 2. A pre-v1 key is logged as VERSION SKEW, not "forged/tampered". 764 of the 1,538 were legacy-scheme keys from peers that simply have not upgraded — logging those as forgery is both wrong and the sort of thing that gets noticed in a customer evaluation. 3. The decision records whether the refused CMB was a root or a remix. A rejected CMB is dropped and never stored, so this cannot be recovered afterwards from any store — it is captured here or not at all. The leading hypothesis for the v1 rejections is that remix key derivation consumes envelope data the receiver does not hold identically, which predicts these are overwhelmingly remixes; this boolean turns that from inference into a count. The metric keeps `reason: 'invalid'` so existing consumers are unaffected; the specific verdict rides alongside as `error`. Deliberately NOT touched: the fusion / re-signing path. The content-mismatch diagnosis that motivated it is not established for the population actually being dropped — of 459 rejected CMBs recoverable from a store, content-hash integrity was 100% (454/454 roots; remixes n=5, too few to conclude from) — and relay-key and key-churn were excluded as mechanisms. Instrumenting first is what makes that decision evidence-led rather than another inference. Tests: 4 new in cmb-signing.test.js pinning that a tampered CMB records content-mismatch, a spoofed one records bad-signature, the two are separable, the metric keeps its existing shape, and a remix rejection is flagged as one. Suite 296 tests / 295 pass. The one failure (memory-store "preserves the wire ancestor chain") is PRE-EXISTING on main, verified identical with the change stashed — it is the installed core 0.4.0 against main's ^0.3.49 pin, i.e. the cutover in flight, not this change. Co-Authored-By: Claude Opus 5 (1M context) --- lib/frame-handler.js | 23 +++++++++-- tests/cmb-signing.test.js | 82 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/lib/frame-handler.js b/lib/frame-handler.js index 4a27d08..66a79bb 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -409,13 +409,30 @@ class FrameHandler { if (!v.valid && !grandfathered) { const keyShort = String(cmbKeyOf(cmb) || '').slice(0, 16); - this._node._log(`[sym-security] BAD SIGNATURE on CMB ${keyShort} from ${peerName} — forged/tampered, rejected${v.error ? ' (' + v.error + ')' : ''}`); - this._node.emit('metric', { type: 'cmb-signature-rejected', from: peerName, key: cmbKeyOf(cmb), reason: 'invalid' }); + // verifyCMB distinguishes WHY it refused — 'legacy-key-rejected', 'bad-signature', + // 'content-mismatch', 'no-public-key'. Recording the constant instead of the reason + // made 1,538 rejections on this host — 32% of all SVAF decisions — indistinguishable: + // version-skew peers, real signature failures and content mismatches under one label. + // (§7.8 above already grandfathers 'unverified-legacy'; this branch is the REFUSED set.) + const reason = v.error || 'bad-signature'; + // A pre-v1 key is VERSION SKEW, not an attack: a peer that has not upgraded must not + // read as hostile in the security log. + const legacyKey = reason === 'legacy-key-rejected'; + // Root vs remix, captured HERE because a rejected CMB is dropped and never stored — + // this decision record is the only place the distinction can survive. Lineage lives in + // metadata on the two-section record; the top-level fallback reads pre-boundary frames. + const lin = (cmb.metadata && cmb.metadata.lineage) || cmb.lineage; + const remix = !!(lin && Array.isArray(lin.parents) && lin.parents.length); + this._node._log(legacyKey + ? `[sym-security] LEGACY-KEY CMB ${keyShort} from ${peerName} rejected — pre-v1 key scheme (version skew, not forgery)` + : `[sym-security] BAD SIGNATURE on CMB ${keyShort} from ${peerName} — forged/tampered, rejected (${reason})`); + // `reason` keeps 'invalid' so nothing downstream breaks; `error` carries the verdict. + this._node.emit('metric', { type: 'cmb-signature-rejected', from: peerName, key: cmbKeyOf(cmb), reason: 'invalid', error: reason }); if (typeof this._node._recordDecision === 'function') { this._node._recordDecision({ method: 'signature', source: msg.source || peerName, cmbKey: cmbKeyOf(cmb), decision: 'rejected-signature', totalDrift: null, categoryDrifts: null, gateValues: null, - focusLabel: 'bad-signature', + focusLabel: reason, remix, }); } return true; diff --git a/tests/cmb-signing.test.js b/tests/cmb-signing.test.js index 50d7afd..f99b5fa 100644 --- a/tests/cmb-signing.test.js +++ b/tests/cmb-signing.test.js @@ -158,3 +158,85 @@ describe('CMB authentication — Ed25519 sign + verify (MMP §8.3)', () => { }); }); }); + +/** + * Rejection DIAGNOSABILITY (2026-07-29). + * + * verifyCMB already distinguishes legacy-key-rejected / bad-signature / + * content-mismatch / no-public-key, but every one of them was recorded as the + * constant focusLabel 'bad-signature' while the real reason went only to the + * console. On this host that made 1,538 rejections — 32% of all SVAF decisions, + * the largest single verdict class — mutually indistinguishable in the log, and + * separating them took a day of inference across three seats. + * + * These pin the field that ends that: the decision records WHY, and whether the + * refused CMB was a root or a remix. + */ +describe('rejection diagnosability — the decision records WHY, not a constant', () => { + it('a content-tampered CMB records content-mismatch, not the generic label', async () => { + await withNode('diag-tamper', async (node) => { + node._svafEvaluator.evaluate = async () => ALIGNED; + const { pub, priv } = rawKeypair(); + node._peerIdentityKeys.set('peerA', pub); + const decisions = []; + node.on('svaf-decision', (d) => { if (d.decision === 'rejected-signature') decisions.push(d); }); + const frame = wire(signedCmbFrame(priv)); + frame.cmb.categories.focus.text = 'wire the funds to a new account'; + node._frameHandler.handle('peerA', 'peerA', frame); + await settle(); + assert.strictEqual(decisions.length, 1, 'the rejection is recorded'); + assert.strictEqual(decisions[0].focusLabel, 'content-mismatch', + 'the recorded reason is the ACTUAL verdict — this is what the constant hid'); + assert.strictEqual(decisions[0].remix, false, 'and whether it was a remix is recorded'); + }); + }); + + it('a spoofed signature records bad-signature — the two are now separable', async () => { + await withNode('diag-spoof', async (node) => { + node._svafEvaluator.evaluate = async () => ALIGNED; + const peer = rawKeypair(), attacker = rawKeypair(); + node._peerIdentityKeys.set('peerA', peer.pub); + const decisions = []; + node.on('svaf-decision', (d) => { if (d.decision === 'rejected-signature') decisions.push(d); }); + node._frameHandler.handle('peerA', 'peerA', wire(signedCmbFrame(attacker.priv))); + await settle(); + assert.strictEqual(decisions.length, 1); + assert.strictEqual(decisions[0].focusLabel, 'bad-signature'); + // The whole point: a genuine signature failure and a content mismatch no longer + // land under the same label, so a count can tell them apart without inference. + assert.notStrictEqual(decisions[0].focusLabel, 'content-mismatch'); + }); + }); + + it('the metric keeps its existing reason and carries the detail alongside it', async () => { + await withNode('diag-metric', async (node) => { + node._svafEvaluator.evaluate = async () => ALIGNED; + const peer = rawKeypair(), attacker = rawKeypair(); + node._peerIdentityKeys.set('peerA', peer.pub); + const metrics = []; + node.on('metric', (m) => { if (m.type === 'cmb-signature-rejected') metrics.push(m); }); + node._frameHandler.handle('peerA', 'peerA', wire(signedCmbFrame(attacker.priv))); + await settle(); + assert.strictEqual(metrics.length, 1); + assert.strictEqual(metrics[0].reason, 'invalid', 'existing consumers see no change'); + assert.strictEqual(metrics[0].error, 'bad-signature', 'the specific verdict rides alongside'); + }); + }); + + it('a REMIX rejection is flagged as one — the boolean the next diagnosis needs', async () => { + await withNode('diag-remix', async (node) => { + node._svafEvaluator.evaluate = async () => ALIGNED; + const peer = rawKeypair(), attacker = rawKeypair(); + node._peerIdentityKeys.set('peerA', peer.pub); + const decisions = []; + node.on('svaf-decision', (d) => { if (d.decision === 'rejected-signature') decisions.push(d); }); + const frame = wire(signedCmbFrame(attacker.priv)); + frame.cmb.lineage = { parents: ['cmb-' + 'a'.repeat(64)], ancestors: [], method: 'svaf-heuristic' }; + node._frameHandler.handle('peerA', 'peerA', frame); + await settle(); + assert.strictEqual(decisions.length, 1); + assert.strictEqual(decisions[0].remix, true, + 'a rejected CMB is dropped and never stored, so root-vs-remix can only be captured here'); + }); + }); +});