pub struct SigDigest(pub [u8; 16]);Expand description
16-byte BLAKE3 digest of a signature: the equal-version tiebreak
discriminator in [member_info_rank], and the value MemberInfoV1’s
summary carries per member.
WHY A DIGEST AND NOT THE SIGNATURE (freenet/river#571, landed as PR #572;
every “#571” elsewhere in this file is that ISSUE, not the PR): the summary carried
the raw ed25519 Signature per member — ~124 of ~134 CBOR bytes per entry,
about 92% of it. That summary is re-sent on every state change to every
interested peer, and interest_sync_summaries was measured as the largest
single consumer of outbound bytes on the Freenet network (49.8%). The
signature is never verified here — it is only ever compared for equality and
ordering — so a digest serves the identical purpose. Measured through the real
summarize() on 470 entries with realistic MemberIds: 134.08 → 28.01 CBOR
bytes per entry, a 4.8x reduction. A 64-bit digest would be exactly 8 bytes
per entry cheaper; the next paragraph is why those 8 bytes are bought
deliberately. Both figures are measured, not derived, by
member_info_summary_stays_small_per_entry, which rebuilds the old shape from
the same records.
WHY 124 AND NOT 66, since 66 is the number the issue and the first draft of
this change both used: ed25519::Signature’s Serialize calls
serialize_tuple(64), which ciborium encodes as a CBOR ARRAY of 64 integers,
and a uniformly random byte costs 2 bytes there whenever it is >= 24. 66 is
the CBOR BYTE STRING encoding — what River’s own
crate::room_state::direct_messages::SignatureBytes newtype produces via
serialize_bytes, and what the deferred DirectMessagesSummary follow-up
will actually be saving. The member_info summary never used that type. Two
different encodings of the same 64 bytes; do not reason about both with one
number.
The 29.1 KB mean interest_sync_summaries message that motivated #571 is a
FLEET-WIDE mean across all rooms, so it is not this room’s own summary size
and the two figures must not be multiplied together: at the Official room’s
~470 records the member_info term ALONE measures ~63 KB, well above the fleet
mean, because that mean also averages in many far smaller rooms. No per-room
summary measurement is on record, so no claim is made about what any single
room’s total summary weighed before this change. (The issue’s own arithmetic
did not close for the same reason this doc’s did not: it used 66 rather than
~124 for the signature.)
WHY 128 BITS AND NOT 64: a collision here is not cosmetic, and it is not
self-correcting. Two same-version records whose discriminators tie are
INDISTINGUISHABLE to anti-entropy — summarize advertises an identical
(version, digest) on both peers, delta filters on strict > so neither
peer ever offers its record to the other, apply_delta replaces only on
strict > so each keeps whichever arrived first, and full-state merge does
not rescue it either (freenet-scaffold implements merge as summarize →
delta → apply_delta). The two halves of the network then disagree
permanently and SILENTLY on that member’s deputies, i.e. on who may ban
whom (#411 round 4 B is the bug that added this discriminator in the first
place). A member SELF-SIGNS their own record and has unlimited grinding
entropy for it — preferred_nickname is free-form and deputies entries
are never validated for membership — so the attacker controls BOTH sides of
the comparison: at 64 bits that is a ~2^32 birthday search, which is hours on
commodity hardware. 128 bits puts it at ~2^64.
This mirrors crate::room_state::direct_messages::PurgeToken, which
derives a 16-byte BLAKE3 value from a signature for the same reason under a
strictly WEAKER threat model (there the attacker cannot influence the other
side of the comparison, and it still chose 128 bits). The two are
deliberately NOT factored into a shared helper: each is an independent
wire-format commitment — PurgeToken’s bytes live in stored state, these
live in the summary — and they must stay free to evolve separately.
WHY BLAKE3 AND NOT freenet_scaffold::util::fast_hash: fast_hash is a
base-31 polynomial, fine for the accidental collisions MessageId and
BanId care about but trivially collidable by construction, which would
price the attack above at roughly nothing regardless of its width.
WIRE FORMAT, load-bearing in two ways. freenet-core byte-compares
summarize_state output for staleness, so this must be a fixed function of
the signature bytes; and the digest orders the records, so every peer must
derive the same bytes and compare them the same way. Both are pinned by
sig_digest_golden_vector:
- the digest is the FIRST 16 bytes of
blake3(signature.to_bytes()), kept in their natural order — there is no integer conversion, hence no endianness decision to get wrong (the 64-bit form neededfrom_le_bytesfor this); - ordering is plain lexicographic over those bytes (the derived
Ord); - it serializes as a CBOR byte string (17 bytes), via the hand-written
Serializebelow rather than the derive, which would emit a 16-element CBOR array — ~32 bytes for random digest content, since each byte >= 24 costs two — and undo most of the saving.
None of the three may change without re-keying the contract. See
.claude/rules/contract-summary-determinism.md and freenet/freenet-core#4857.
Tuple Fields§
§0: [u8; 16]