Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions lib/frame-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions tests/cmb-signing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Loading