diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b17c1f..438bb3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## Unreleased + +### Added — a peer's admission verdict is observable as it lands (`attestation-received`) + +A receiver signs an Admission Attestation for every CMB it gates and gossips it to the room. Until now the +author, or anything watching the mesh, could see those verdicts only by polling `attestationsFor(cmbKey)`. +The node now emits `attestation-received` the first time it verifies and records a peer's attestation. + +Fields covered by the attester's signature, passed on as signed: + +- `of` (the gated CMB key), `by` (the attester's nodeId), `at`, `roster`, `seq` and `prev` (its place on the + attester's hash chain); +- `verdict`, and `categories`: the seven CAT7 fields (focus / issue / intent / motivation / commitment / + perspective / mood), each carrying one of admit / guard / redundant / reject / silent, as the strings the + signature covers. Any other key in the frame is unsigned and dropped; +- `role`, the role the attester claims. A node can stamp any role and still sign validly; +- `sig` and `sigAlg`, so a consumer holding the attester's key can re-check the event with + `verifyAttestation(event, key)` instead of trusting `verified`. + +Fields this node adds, which the signature does not vouch for: + +- `verified: true`, and `keySource`, where the verifying key came from: `anchor`, `grant`, or `handshake`. A + `handshake` key is trusted on first use: it belongs to whoever completed the handshake under that nodeId; +- `roleResolved` and `roleMatches`, the role this node's grant chain resolves for the attester, and + `roleClaimed`, the claim `roleMatches` compares against (`participant` when the attester stamped none). + Both are compared as strings, the form the signature covers, so a claim that signs identically matches + identically. + Weight a verdict by these, not by `role`; +- `methodUnsigned`, the evaluation method (`heuristic` / `neural`). It is outside the signed bytes, so a relay + could have changed it; +- `byName`, the name the attester announced when it connected, only when it is a direct peer. It is a label, + not an authenticated name: `by` is the identity; +- `from` / `fromPeerId`, the peer that delivered the frame, and `relayed`: true when that peer is not the + attester, false when it is, null when the deliverer is unknown; +- `receivedAt`, this node's clock, not the attester's `at`. + +A duplicate, a rate-limited copy, a roster mismatch, an unknown attester or a bad signature emits nothing. +Neither does this node's own verdict: read that from `svaf-decision` or `attestationsFor()`. + +The event holds only primitives and is frozen. A field the attester filled with an object is given as the +string its signature covers, so a listener cannot change the stored record or what the next listener sees. +Each listener is called on its own, in registration order, from a snapshot taken when dispatch starts. The +calls are synchronous, on the frame-ingest path: gossip to the room has already gone out, but a listener that +blocks delays this node's next frames, so hand heavy work to a queue. A +listener that throws (any value), or an async listener whose promise rejects, is logged with the attestation +it was handling. It does not stop later listeners, undo the ingest, or become an unhandled rejection. An event +that cannot be built is reported on `metric` as `attestation-event-dropped`. That one metric is delivered to +each `metric` listener the same isolated way, not through `emit()`, so code that wraps `emit` does not see it. +The attestation is still recorded. With no listener, none of this work is done. + ## 0.13.13 (2026-10-01) ### Fixed — a crashed Windows session no longer locks its node name until reboot diff --git a/lib/node.js b/lib/node.js index 2f4c5445..607a20e3 100644 --- a/lib/node.js +++ b/lib/node.js @@ -38,7 +38,7 @@ const { e2eGenerateKeyPair, e2eDeriveSharedSecret, encryptCategories, signCMB, assertionIdV2_0, mintRemixKey, signAttestation, verifyAttestation, verifyAttestationRole, merkleRoot, signCheckpoint, verifyCheckpoint, signWitness, verifyWitness, - signGrant, roleRank, + signGrant, roleRank, CAT7_CATEGORIES, } = require('./core'); const { MMP_EMIT_V2 } = require('./emit-policy'); // the single reader-first flip switch const { FrameHandler } = require('./frame-handler'); @@ -1183,6 +1183,11 @@ class SymNode extends EventEmitter { * outcome so the frame-handler can log. Verification of a RELAYED attester (one that * is not a direct peer) needs the permissioned-roster key registry — pending; in the * current fully-connected roster every attester is a direct peer. + * + * On first sight of a verified attestation this emits 'attestation-received', so an + * observer (an author watching its CMB's admission, a mesh visualiser) sees a peer's + * verdict as it lands instead of polling attestationsFor(). A duplicate, a rate-limited + * copy, a roster mismatch, an unknown attester or a bad signature emits nothing. * @private */ _ingestAttestation(att, fromPeerId, fromPeerName) { @@ -1199,10 +1204,112 @@ class SymNode extends EventEmitter { if (!v.valid) return { ok: false, reason: v.error || 'bad-signature' }; // Record (rate-limited as ingested). Relay-once only on first sight. const r = this._attestations.record(att, { ingested: true }); - if (r.stored) this._gossipAttestation(att, fromPeerId); + if (r.stored) { + this._gossipAttestation(att, fromPeerId); + this._emitAttestationReceived(att, fromPeerId, fromPeerName); + } return { ok: r.stored, reason: r.reason }; } + /** + * Emit 'attestation-received' for an attestation that was just verified and recorded. + * + * Signed fields pass through as signed: `of`, `by`, `at`, `roster`, `verdict`, `seq`, `prev`, + * `role`, and the seven CAT7 `categories` as the strings the signature covers (any other key is + * unsigned and dropped), with `sig`/`sigAlg`, so verifyAttestation(event, key) re-checks it. + * `role` is the attester's own CLAIM: a node can stamp any role and still sign validly, so + * `roleResolved`/`roleMatches` give the role this node's grant chain resolves. What the signature + * does not cover is named so: `methodUnsigned` is outside the signed bytes, so a relay could have + * changed it, and `byName` is the label the peer announced; `by` is the identity. + * + * `verified` means the signature checked out against the key this node holds for `by`, and + * `keySource` says where that key came from: 'anchor', 'grant', or 'handshake' (trust on first + * use: whoever completed the handshake under that nodeId). `from` is the peer that delivered the + * frame; `relayed` is true when that peer is not the attester, null when the deliverer is + * unknown. `receivedAt` is this node's clock, not the attester's `at`. + * + * The event holds only primitives, frozen: an object-valued field is given as the string its + * signature covers, so a listener can neither reach the stored record nor change what the next + * listener sees. `roleClaimed` is the claim `roleMatches` compares with `roleResolved` + * ('participant' when the attester stamped none), both as the strings the signature would cover. Each listener is called on its own: one that throws, or returns + * a promise that rejects, is logged and cannot starve later listeners or undo the ingest. Listeners + * run synchronously on the frame-ingest path (gossip has already gone out), so heavy work belongs + * in a queue. An event that cannot be built is reported on 'metric' as 'attestation-event-dropped', + * dispatched the same isolated way rather than through emit(). + * @private + */ + _emitAttestationReceived(att, fromPeerId, fromPeerName) { + 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. + const note = (m) => { try { this._log(m); } catch { /* nothing left to tell */ } }; + const subject = `${String(att.of).slice(0, 12)} by ${String(att.by).slice(0, 8)}`; + // The canonicalizer joins these with ToString, so String() of an object keeps the signed bytes. + const signed = (v) => (v === null || v === undefined ? null : (typeof v === 'object' || typeof v === 'function') ? String(v) : v); + let event; + try { + const categories = {}; + for (const f of CAT7_CATEGORIES) { + const v = att.categories && att.categories[f]; + categories[f] = v ? String(v) : null; // the form canonicalCategories signs + } + const role = verifyAttestationRole(att, (id, at) => this.resolveRole(id, at)); + // As strings, the form the canonicalizer signs: ['anchor'] and 'anchor' claim the same, as do 2 and + // '2'. Neither field is signed, and both default to 'participant', so String() never yields 'null'. + const roleClaimed = String(role.claimed); + const roleResolved = String(role.resolved); + event = Object.freeze({ + of: signed(att.of), + by: signed(att.by), + byName: signed(this._peers.get(att.by)?.name), + verdict: signed(att.verdict), + categories: Object.freeze(categories), + role: signed(att.role), + roleClaimed, + roleResolved, + roleMatches: roleClaimed === roleResolved, + methodUnsigned: signed(att.method), + roster: signed(att.roster), + at: signed(att.at), + seq: signed(att.seq), + prev: signed(att.prev), + sig: signed(att.sig), + sigAlg: signed(att.sigAlg), + verified: true, + keySource: this._roster.source(att.by) ?? (this._peerIdentityKeys?.has(att.by) ? 'handshake' : null), + from: signed(fromPeerName), + fromPeerId: signed(fromPeerId), + relayed: fromPeerId ? fromPeerId !== att.by : null, + receivedAt: Date.now(), + }); + } catch (err) { + note(`attestation-received not emitted for ${subject}: ${why(err)}`); + this._emitIsolated('metric', { type: 'attestation-event-dropped', of: signed(att.of), by: signed(att.by), reason: why(err) }, subject, why, note); + return; + } + this._emitIsolated('attestation-received', event, subject, why, note); + } + + /** + * Call each listener of `name` on its own, in order, from a snapshot. Not this.emit(): that stops + * at the first listener that throws and leaves an async listener's rejection unhandled. A throw + * or a rejection is noted with `subject` and the next listener still runs. rawListeners() keeps + * once() wrappers, so a once-listener still removes itself. + * @private + */ + _emitIsolated(name, payload, subject, why, note) { + for (const listener of this.rawListeners(name)) { + try { + const r = listener.call(this, payload); + if (r && typeof r.then === 'function') { + r.then(undefined, (err) => note(`${name} listener rejected for ${subject}: ${why(err)}`)); + } + } catch (err) { + note(`${name} listener failed for ${subject}: ${why(err)}`); + } + } + } + /** * Chain-integrity check for an attester (default: this node) — the local half of * omission-evidence. Reports `seq` gaps (suppressed attestations) and `prev` diff --git a/tests/admission-attestation.test.js b/tests/admission-attestation.test.js index b2799f30..5dd3362e 100644 --- a/tests/admission-attestation.test.js +++ b/tests/admission-attestation.test.js @@ -19,7 +19,7 @@ const fs = require('fs'); const { SymNode } = require('../lib/node'); const { NullDiscovery } = require('../lib/discovery'); const { nodeDir } = require('../lib/config'); -const { verifyAttestation, verifyAttestationRole } = require('../lib/core'); +const { verifyAttestation, verifyAttestationRole, signAttestation } = require('../lib/core'); // Construct (no start) — the builder needs only identity / room / role / chain state. function withNode(baseName, opts, fn) { @@ -148,3 +148,300 @@ describe('node checkpoints its chain; reconciliation catches omission (D3)', () }); }); }); + +describe("'attestation-received' — a peer's verified verdict is observable as it lands", () => { + // B ingests attestations A built, as it would from an `attestation` frame. B holds A's key the + // way a handshake leaves it (_pinPeerKey), so verification runs for real; no transport is needed. + function makePair({ aOpts = {}, aAsPeer = null } = {}) { + const tag = () => `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const A = new SymNode({ name: `att-rx-a-${tag()}`, silent: true, discovery: new NullDiscovery(), room: 'hotel', ...aOpts }); + const B = new SymNode({ name: `att-rx-b-${tag()}`, silent: true, discovery: new NullDiscovery(), room: 'hotel' }); + B._pinPeerKey(A.nodeId, A._identity.publicKey); + // A direct peer is what gives the attester a name; the label is whatever it announced. + if (aAsPeer) B._peers.set(A.nodeId, { peerId: A.nodeId, name: aAsPeer, transport: { send() {} } }); + const seen = []; + const cleanup = () => { for (const n of [A, B]) fs.rmSync(nodeDir(n.name), { recursive: true, force: true }); }; + return { A, B, seen, cleanup, collect: () => B.on('attestation-received', (e) => seen.push(e)) }; + } + function withPair(opts, fn) { + const p = makePair(opts); + try { p.collect(); return fn(p); } finally { p.cleanup(); } + } + + it('emits once per verified attestation: who judged which CMB, how, where on the chain, re-verifiable', () => { + withPair({}, ({ A, B, seen }) => { + const first = A._buildAdmissionAttestation('cmb-request-1', 'aligned', verdicts, 'heuristic'); + const att = A._buildAdmissionAttestation('cmb-request-2', 'guarded', verdicts, 'neural'); + assert.deepStrictEqual(B._ingestAttestation(att, A.nodeId, 'concierge'), { ok: true, reason: undefined }); + assert.strictEqual(seen.length, 1); + const e = seen[0]; + assert.strictEqual(e.of, 'cmb-request-2'); + assert.strictEqual(e.by, A.nodeId); + assert.strictEqual(e.verdict, 'guarded'); + assert.deepStrictEqual({ ...e.categories }, verdicts); + assert.strictEqual(e.methodUnsigned, 'neural'); + assert.strictEqual(e.roster, 'hotel'); + assert.strictEqual(e.seq, 2); + assert.strictEqual(e.prev, crypto.createHash('sha256').update(first.sig).digest('hex')); + assert.strictEqual(e.sig, att.sig); + assert.strictEqual(e.sigAlg, 'ed25519'); + assert.strictEqual(e.verified, true); + assert.strictEqual(e.keySource, 'handshake'); + assert.strictEqual(e.from, 'concierge'); + assert.strictEqual(e.fromPeerId, A.nodeId); + assert.strictEqual(e.relayed, false); + assert.strictEqual(typeof e.receivedAt, 'number'); + assert.deepStrictEqual(verifyAttestation(e, A._identity.publicKey), { signed: true, valid: true }, + 'a consumer holding the key can re-check the event itself'); + assert.ok(Object.isFrozen(e) && Object.isFrozen(e.categories), 'the event is frozen'); + }); + }); + + it('names the attester by its own peer entry, never by the peer that relayed the copy', () => { + withPair({ aAsPeer: 'front-desk' }, ({ A, B, seen }) => { + const att = A._buildAdmissionAttestation('cmb-request-3', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, 'relay-node-id', 'housekeeping').ok, true); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].by, A.nodeId); + assert.strictEqual(seen[0].byName, 'front-desk'); + assert.strictEqual(seen[0].from, 'housekeeping'); + assert.strictEqual(seen[0].fromPeerId, 'relay-node-id'); + assert.strictEqual(seen[0].relayed, true); + }); + }); + + it('reports relayed as unknown, not direct, when the deliverer is unknown', () => { + withPair({}, ({ A, B, seen }) => { + const att = A._buildAdmissionAttestation('cmb-request-4', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att).ok, true); + assert.strictEqual(seen[0].relayed, null); + assert.strictEqual(seen[0].from, null); + assert.strictEqual(seen[0].byName, null, 'not a direct peer, so no name'); + }); + }); + + it('gives the claimed role beside the role this node resolves', () => { + withPair({ aOpts: { lifecycleRole: 'anchor' } }, ({ A, B, seen }) => { + const att = A._buildAdmissionAttestation('cmb-request-5', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(seen[0].role, 'anchor', 'what A stamped'); + assert.strictEqual(seen[0].roleClaimed, 'anchor'); + assert.strictEqual(seen[0].roleResolved, 'participant', 'B holds no grant that makes A an anchor'); + assert.strictEqual(seen[0].roleMatches, false); + }); + }); + + it('passes on only what the signature covers: CAT7 categories as signed strings, nothing a relay added', () => { + withPair({}, ({ A, B, seen }) => { + // A signs a nested object as a category value: the signature covers its string form only. + const att = A._buildAdmissionAttestation('cmb-request-6', 'aligned', { ...verdicts, focus: { verdict: 'admit' } }, 'heuristic'); + // A relay adds a category key and rewrites the method; neither is in the signed bytes. + const tampered = { ...att, method: 'neural', categories: { ...att.categories, recommendation: 'escalate' } }; + assert.strictEqual(B._ingestAttestation(tampered, 'relay-node-id', 'housekeeping').ok, true, 'still verifies'); + const e = seen[0]; + assert.deepStrictEqual(Object.keys(e.categories), ['focus', 'issue', 'intent', 'motivation', 'commitment', 'perspective', 'mood']); + assert.strictEqual(e.categories.focus, '[object Object]', 'the signed form, not the object'); + assert.strictEqual('method' in e, false, 'an unsigned method is never presented as signed'); + assert.strictEqual(e.methodUnsigned, 'neural'); + assert.deepStrictEqual(verifyAttestation(e, A._identity.publicKey), { signed: true, valid: true }); + }); + }); + + it('stays silent for a duplicate, a forgery, a foreign roster and an unknown attester', () => { + withPair({}, ({ A, B, seen }) => { + const att = A._buildAdmissionAttestation('cmb-request-7', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(B._ingestAttestation({ ...att }, A.nodeId, 'concierge').reason, 'duplicate'); + + const forged = { ...A._buildAdmissionAttestation('cmb-request-8', 'rejected', verdicts, 'heuristic'), verdict: 'aligned' }; + assert.strictEqual(B._ingestAttestation(forged, A.nodeId, 'concierge').ok, false); + + const foreign = A._buildAdmissionAttestation('cmb-request-9', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation({ ...foreign, roster: 'other-room' }, A.nodeId, 'concierge').reason, 'roster-mismatch'); + + withNode('att-rx-stranger', { room: 'hotel' }, (S) => { + const unknown = S._buildAdmissionAttestation('cmb-request-10', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(unknown, S.nodeId, 'stranger').reason, 'unknown-attester-key'); + }); + + assert.strictEqual(seen.length, 1, 'only the first verified copy is announced'); + assert.strictEqual(seen[0].of, 'cmb-request-7'); + }); + }); + + it('stays silent for a rate-limited copy: a flood about one CMB is not an event stream', () => { + withPair({}, ({ A, B, seen }) => { + const limit = B._attestations._ratePerWindow; + const reasons = []; + for (let i = 0; i <= limit; i++) { + const att = A._buildAdmissionAttestation('cmb-flooded', 'aligned', verdicts, 'heuristic'); + reasons.push(B._ingestAttestation(att, A.nodeId, 'concierge').reason); + } + assert.strictEqual(reasons[limit], 'rate-limited'); + assert.strictEqual(seen.length, limit, 'one event per recorded attestation, none for the rate-limited one'); + }); + }); + + it("gives a foreign attester's object-valued or missing fields in their signed form", () => { + withPair({}, ({ A, B, seen }) => { + // Validly signed but unusual: the canonicalizer signs an array through ToString, and no role. + const base = A._buildAdmissionAttestation('cmb-request-13', 'aligned', verdicts, 'heuristic'); + const att = { ...base, prev: ['abc'] }; + delete att.role; + signAttestation(att, A._identity.privateKey); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + const e = seen[0]; + assert.strictEqual(e.prev, 'abc', 'a string, so no listener can change it under the next one'); + assert.strictEqual(e.role, null, 'no role was signed'); + assert.strictEqual(e.roleClaimed, 'participant', 'the claim roleMatches is computed against'); + assert.strictEqual(e.roleMatches, true); + assert.deepStrictEqual(verifyAttestation(e, A._identity.publicKey), { signed: true, valid: true }); + for (const [k, v] of Object.entries(e)) { + if (k !== 'categories') assert.ok(v === null || typeof v !== 'object', `${k} is a primitive`); + } + }); + }); + + it("coerces an object-valued role claim, so it neither aliases the record nor fails to match", () => { + withPair({ aOpts: { lifecycleRole: 'participant' } }, ({ A, B, seen }) => { + const base = A._buildAdmissionAttestation('cmb-request-18', 'aligned', verdicts, 'heuristic'); + const att = { ...base, role: ['participant'] }; // signs exactly as 'participant' does + signAttestation(att, A._identity.privateKey); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + const e = seen[0]; + assert.strictEqual(e.role, 'participant'); + assert.strictEqual(e.roleClaimed, 'participant'); + assert.strictEqual(e.roleMatches, true, 'a byte-identical claim matches'); + for (const [k, v] of Object.entries(e)) { + if (k !== 'categories') assert.ok(v === null || typeof v !== 'object', `${k} is a primitive`); + } + assert.deepStrictEqual(verifyAttestation(B.attestationsFor('cmb-request-18')[0], A._identity.publicKey), { signed: true, valid: true }); + }); + }); + + it('matches a numeric role claim against the same role resolved as a string', () => { + withPair({}, ({ A, B, seen }) => { + const base = A._buildAdmissionAttestation('cmb-request-21', 'aligned', verdicts, 'heuristic'); + const att = { ...base, role: 2 }; // signs as '2' + signAttestation(att, A._identity.privateKey); + B.resolveRole = () => '2'; + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(seen[0].roleClaimed, '2'); + assert.strictEqual(seen[0].roleResolved, '2'); + assert.strictEqual(seen[0].roleMatches, true); + assert.deepStrictEqual(verifyAttestation(seen[0], A._identity.publicKey), { signed: true, valid: true }); + }); + }); + + it('does no work when nothing listens', () => { + const { A, B, cleanup } = makePair(); + try { + const metrics = []; + B.on('metric', (m) => metrics.push(m)); + let called = false; + B.resolveRole = () => { called = true; throw new Error('must not be called without a listener'); }; + const att = A._buildAdmissionAttestation('cmb-request-19', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(called, false, 'the grant chain is not walked with no listener'); + assert.strictEqual(metrics.filter((m) => m.type === 'attestation-event-dropped').length, 0); + } finally { cleanup(); } + }); + + it('dispatches in listener order from a snapshot, like emit', () => { + withPair({}, ({ A, B, seen }) => { + const order = []; + const collector = B.rawListeners('attestation-received')[0]; + const late = () => order.push('late'); + B.on('attestation-received', () => order.push('appended')); + B.prependListener('attestation-received', () => { + order.push('first'); + B.off('attestation-received', collector); // removed mid-dispatch: still sees this event + B.on('attestation-received', late); // added mid-dispatch: does not + }); + const att = A._buildAdmissionAttestation('cmb-request-14', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.deepStrictEqual(order, ['first', 'appended'], 'the prepended listener runs first'); + assert.strictEqual(seen.length, 1, 'the collector removed during dispatch still got this event'); + const next = A._buildAdmissionAttestation('cmb-request-15', 'aligned', verdicts, 'heuristic'); + B._ingestAttestation(next, A.nodeId, 'concierge'); + assert.deepStrictEqual(order, ['first', 'appended', 'first', 'appended', 'late']); + assert.strictEqual(seen.length, 1, 'and nothing after it was removed'); + }); + }); + + it('reports an event it could not build on metric, and still records the attestation', () => { + withPair({}, ({ A, B, seen }) => { + const metrics = []; + B.on('metric', () => { throw new Error('counter rejects an unknown type'); }); // must not starve the next + B.on('metric', async () => { throw new Error('shipper offline'); }); // must not go unhandled + B.on('metric', (m) => { m.node = 'b'; metrics.push(m); }); // stamps its payload, like the other 22 metrics allow + B.resolveRole = () => { throw new Error('grant chain unreadable'); }; + const att = A._buildAdmissionAttestation('cmb-request-16', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + assert.strictEqual(seen.length, 0); + const dropped = metrics.filter((m) => m.type === 'attestation-event-dropped'); + assert.strictEqual(dropped.length, 1); + assert.strictEqual(dropped[0].of, 'cmb-request-16'); + assert.strictEqual(dropped[0].reason, 'grant chain unreadable'); + assert.strictEqual(B.attestationsFor('cmb-request-16').length, 1); + }); + }); + + it('a log sink that throws does not turn a failing listener into a failed ingest', async () => { + const { A, B, cleanup } = makePair(); + const unhandled = []; + const onUnhandled = (r) => unhandled.push(r); + process.on('unhandledRejection', onUnhandled); + try { + B._log = () => { throw new Error('log pipe closed'); }; + // Only an async listener first, so the rejection handler's own logging is what is under test. + const onlyAsync = async () => { throw new Error('view socket closed'); }; + B.on('attestation-received', onlyAsync); + const att = A._buildAdmissionAttestation('cmb-request-17', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true); + await new Promise((r) => setTimeout(r, 20)); + assert.deepStrictEqual(unhandled, [], 'the rejection handler cannot itself reject'); + // Then a synchronous throw, whose catch block logs through the same failing sink. + B.off('attestation-received', onlyAsync); + B.on('attestation-received', () => { throw new Error('listener bug'); }); + const next = A._buildAdmissionAttestation('cmb-request-20', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(next, A.nodeId, 'concierge').ok, true, 'the catch block cannot throw out'); + } finally { + process.off('unhandledRejection', onUnhandled); + cleanup(); + } + }); + + it('a failing listener cannot reach the stored record, starve a later listener, or undo the ingest', async () => { + const { A, B, seen, cleanup, collect } = makePair(); + const unhandled = []; + const onUnhandled = (r) => unhandled.push(r); + process.on('unhandledRejection', onUnhandled); + try { + // Registered BEFORE the collector, so a throw that escaped would starve it. + B.on('attestation-received', (e) => { e.categories.focus = 'reject'; }); // frozen: throws in strict mode + B.on('attestation-received', () => { throw null; }); // not an Error + B.on('attestation-received', async () => { throw new Error('view socket closed'); }); + let once = 0; + B.once('attestation-received', () => { once++; }); + collect(); + + const att = A._buildAdmissionAttestation('cmb-request-11', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(att, A.nodeId, 'concierge').ok, true, 'the ingest still reports success'); + const second = A._buildAdmissionAttestation('cmb-request-12', 'aligned', verdicts, 'heuristic'); + assert.strictEqual(B._ingestAttestation(second, A.nodeId, 'concierge').ok, true); + await new Promise((r) => setTimeout(r, 20)); + + assert.strictEqual(seen.length, 2, 'the later listener saw both events'); + assert.strictEqual(once, 1, 'a once-listener fired once and removed itself'); + assert.deepStrictEqual(unhandled, [], 'an async listener that rejects is not an unhandled rejection'); + const stored = B.attestationsFor('cmb-request-11'); + assert.strictEqual(stored.length, 1, 'the attestation is recorded'); + assert.strictEqual(stored[0].categories.focus, 'admit', 'the stored categories are untouched'); + assert.deepStrictEqual(verifyAttestation(stored[0], A._identity.publicKey), { signed: true, valid: true }); + } finally { + process.off('unhandledRejection', onUnhandled); + cleanup(); + } + }); +}); diff --git a/tests/integration/e2e-attestation-gossip.js b/tests/integration/e2e-attestation-gossip.js index 878885e8..56715c20 100644 --- a/tests/integration/e2e-attestation-gossip.js +++ b/tests/integration/e2e-attestation-gossip.js @@ -46,6 +46,8 @@ describe('E2E Admission Attestation gossip (D2)', () => { A._addPeer(A._createPeer(tA, B.nodeId, bName, true, 'bonjour')); B._addPeer(B._createPeer(tB, A.nodeId, aName, false, 'bonjour')); await sleep(400); + const announced = []; + B.on('attestation-received', (e) => announced.push(e)); assert.ok(B._peerIdentityKeys.get(A.nodeId), 'B has A\'s authenticated identity key from the handshake'); const entry = B.remember({ @@ -63,6 +65,16 @@ describe('E2E Admission Attestation gossip (D2)', () => { assert.strictEqual(att.of, K, 'bound to B\'s gated CMB'); assert.deepStrictEqual(verifyAttestation(att, A._identity.publicKey), { signed: true, valid: true }, 'A\'s original signature verifies end-to-end'); assert.ok(A.attestationsFor(K).length >= 1, 'the gater A also holds it'); + // The author sees A's verdict live, over the real frame path, without polling. + const live = announced.filter((e) => e.of === K); + assert.strictEqual(live.length, 1, "B announced A's attestation once"); + assert.strictEqual(live[0].by, A.nodeId); + assert.strictEqual(live[0].byName, aName); + assert.strictEqual(live[0].from, aName); + assert.strictEqual(live[0].relayed, false); + assert.strictEqual(live[0].verified, true); + assert.strictEqual(live[0].keySource, 'handshake'); + assert.strictEqual(live[0].sig, att.sig); // A forged attestation (bad signature) must be dropped on ingest. const before = B._attestations.size(); @@ -72,6 +84,7 @@ describe('E2E Admission Attestation gossip (D2)', () => { }); await sleep(50); assert.strictEqual(B._attestations.size(), before, 'a forged attestation is rejected, not recorded'); + assert.strictEqual(announced.filter((e) => e.sig === 'AAAAforged').length, 0, 'a forged attestation is never announced'); await A.stop(); await B.stop(); fs.rmSync(nodeDir(aName), { recursive: true, force: true });