Skip to content

Commit 412b584

Browse files
Branimir Rakiccursoragent
authored andcommitted
fix(network): throw on unsigned messages in dkgGossipMsgId (Codex PR #501)
Codex review feedback on PR #501 (round 4): > For type: 'unsigned' this falls back to fromBytes = [] and seqno = > 0n, so two different unsigned publishers sending the same payload > on the same topic will produce the same msgId and one publish will > be falsely deduplicated. Because this helper is now exported > publicly, consumers can hit that collision even before core wires > it in. Real bug at the API surface level. The earlier draft handled unsigned by zero-filling the publisher and seqno fields, which preserves the upstream-default false-dedup property — fine if nothing ever calls it with unsigned messages, but a footgun for external consumers of the exported function. V10 configures gossipsub with the StrictSign default, so unsigned messages don't appear in the wild today. Throwing here makes the "unsigned not supported in this msgId scheme" stance explicit: - any code path that tries to publish an unsigned message fails loudly (easy to debug), - external consumers of the exported function can't accidentally hit the false-dedup case, - a future PR that wants to support unsigned has to deliberately extend the scheme with a per-message identity (nonce / hash prefix / etc.), pinned by tests. Implementation: - New `DkgGossipUnsignedMessageError` class (also exported) with a message that explains the constraint and points at the workarounds. - `dkgGossipMsgId` throws the error for `msg.type !== 'signed'` before building the hash input. - Updated tests: replaced the "unsigned: length-framed sha256 with seqno=0n and empty from" case with "unsigned: throws DkgGossipUnsignedMessageError"; dropped the now-impossible "signed and unsigned with same topic/payload differ" case. Tests: - core gossip-msg-id 10/10 (was 11; net -1 because the signed-vs-unsigned diff test became unreachable) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 76de8e7 commit 412b584

4 files changed

Lines changed: 55 additions & 24 deletions

File tree

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export {
2222
type ResolveOpts,
2323
PeerResolver,
2424
dkgGossipMsgId,
25+
DkgGossipUnsignedMessageError,
2526
} from './network/index.js';
2627
export {
2728
ProtocolRouter,

packages/core/src/network/gossip-msg-id.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,28 @@
3636
* sequence, etc.) maps the same (topic, payload, from, seq) tuple
3737
* to the same hash.
3838
*
39-
* Unsigned messages don't carry a seqno; for them we use `0n`. The
40-
* upstream behaviour for unsigned was `sha256(data)` — pure dedup
41-
* by payload, which V10 doesn't rely on. We don't ship unsigned
42-
* publishes today; the branch exists so the function is total over
43-
* the `Message` union.
39+
* Why throw on unsigned
40+
* ---------------------
41+
* Codex review feedback on PR #501 round 4: with `type: 'unsigned'`
42+
* the message has no publisher identity (no `from`) and no seqno.
43+
* The earlier draft fell back to `fromBytes = []` and `seqno = 0n`,
44+
* which means two different publishers sending the same payload on
45+
* the same topic produce the SAME msgId — one publish gets falsely
46+
* deduplicated. (The upstream default for unsigned —
47+
* `sha256(data)` — has the same property, but a public function
48+
* shouldn't replicate that pitfall in a freshly-shipped contract.)
49+
*
50+
* V10 configures gossipsub with the StrictSign default, so unsigned
51+
* messages don't appear in the wild today. Throwing here makes the
52+
* "unsigned not supported in this msgId scheme" stance explicit:
53+
*
54+
* - any code path that tries to publish an unsigned message
55+
* fails loudly (easy to debug),
56+
* - external consumers of the exported function can't accidentally
57+
* hit the false-dedup case,
58+
* - a future PR that wants to support unsigned has to deliberately
59+
* extend the scheme with a per-message identity (nonce / hash
60+
* prefix / etc.), pinned by tests.
4461
*
4562
* v1 ships only `LibP2PGossipBackend`, so this function only changes
4663
* which sha256 inputs gossipsub feeds itself; nothing observable on
@@ -50,8 +67,6 @@
5067
import { sha256 } from '@noble/hashes/sha2.js';
5168
import type { Message } from '@libp2p/gossipsub';
5269

53-
const EMPTY = new Uint8Array(0);
54-
5570
function u32be(n: number): Uint8Array {
5671
const b = new Uint8Array(4);
5772
new DataView(b.buffer).setUint32(0, n, false);
@@ -64,11 +79,28 @@ function u64be(n: bigint): Uint8Array {
6479
return b;
6580
}
6681

82+
export class DkgGossipUnsignedMessageError extends Error {
83+
constructor() {
84+
super(
85+
'dkgGossipMsgId: unsigned messages are not supported. The DKG msgId ' +
86+
'scheme requires a publisher identity (`from`) and `sequenceNumber` ' +
87+
'to avoid false dedup of payload-identical publishes from different ' +
88+
'publishers. Use StrictSign (the gossipsub default) or extend the ' +
89+
'scheme with a per-message nonce.',
90+
);
91+
this.name = 'DkgGossipUnsignedMessageError';
92+
}
93+
}
94+
6795
export function dkgGossipMsgId(msg: Message): Uint8Array {
96+
if (msg.type !== 'signed') {
97+
throw new DkgGossipUnsignedMessageError();
98+
}
99+
68100
const topicBytes = new TextEncoder().encode(msg.topic);
69101
const data = msg.data;
70-
const fromBytes = msg.type === 'signed' ? msg.from.toMultihash().bytes : EMPTY;
71-
const seqno = msg.type === 'signed' ? msg.sequenceNumber : 0n;
102+
const fromBytes = msg.from.toMultihash().bytes;
103+
const seqno = msg.sequenceNumber;
72104

73105
const total =
74106
4 + topicBytes.length +

packages/core/src/network/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ export type {
1919
} from './peer-resolver.js';
2020
export { PeerResolver } from './peer-resolver.js';
2121

22-
export { dkgGossipMsgId } from './gossip-msg-id.js';
22+
export { dkgGossipMsgId, DkgGossipUnsignedMessageError } from './gossip-msg-id.js';

packages/core/test/gossip-msg-id.test.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, it, expect } from 'vitest';
22
import { sha256 } from '@noble/hashes/sha2.js';
3-
import { dkgGossipMsgId } from '../src/network/gossip-msg-id.js';
3+
import {
4+
dkgGossipMsgId,
5+
DkgGossipUnsignedMessageError,
6+
} from '../src/network/gossip-msg-id.js';
47

58
/**
69
* RFC 07 §5.4 — `dkgGossipMsgId` is the content-deterministic msgId
@@ -61,10 +64,14 @@ describe('dkgGossipMsgId (RFC 07 §5.4)', () => {
6164
expect(id.length).toBe(32);
6265
});
6366

64-
it('unsigned: length-framed sha256 with seqno=0n and empty from', () => {
65-
const id = dkgGossipMsgId({ type: 'unsigned', topic: TOPIC, data: PAYLOAD });
66-
expect(id).toEqual(expected(TOPIC, PAYLOAD, new Uint8Array(0), 0n));
67-
expect(id.length).toBe(32);
67+
// Codex review feedback on PR #501 round 4: unsigned messages have
68+
// no publisher identity and no seqno, which would let two different
69+
// publishers' identical payloads collapse to the same msgId. The
70+
// function rejects them so the false-dedup case is impossible.
71+
it('unsigned: throws DkgGossipUnsignedMessageError', () => {
72+
expect(() =>
73+
dkgGossipMsgId({ type: 'unsigned', topic: TOPIC, data: PAYLOAD }),
74+
).toThrow(DkgGossipUnsignedMessageError);
6875
});
6976

7077
it('different topics → different msgIds (same payload + from + seqno)', () => {
@@ -107,15 +114,6 @@ describe('dkgGossipMsgId (RFC 07 §5.4)', () => {
107114
expect(a).not.toEqual(b);
108115
});
109116

110-
it('signed and unsigned with same topic/payload differ', () => {
111-
const signed = dkgGossipMsgId({
112-
type: 'signed', topic: TOPIC, data: PAYLOAD, from: fakePeerId,
113-
sequenceNumber: 0n, signature: new Uint8Array(), key: {} as never,
114-
});
115-
const unsigned = dkgGossipMsgId({ type: 'unsigned', topic: TOPIC, data: PAYLOAD });
116-
expect(signed).not.toEqual(unsigned);
117-
});
118-
119117
it('UTF-8 topics work', () => {
120118
const utf8Topic = 'cg/тема/1.0.0';
121119
const id = dkgGossipMsgId({

0 commit comments

Comments
 (0)