Skip to content

perf: DM summary carries raw 64-byte Ed25519 signatures — 19.8 KB at the DM cap (deferred half of #571) #596

Description

@sanity

Problem

DirectMessagesSummary.message_signatures (common/src/room_state/direct_messages.rs:1391) carries raw 64-byte Ed25519 signatures, one per DM held, capped at DEFAULT_MAX_DIRECT_MESSAGES = 300.

Measured (real ComposableState::summarize + ciborium::into_writer, the exact pair summarize_state uses): SignatureBytes encodes to 66 CBOR bytes, so this field is 19,803 bytes at the 300-DM cap — larger than the entire rest of the summary combined.

delta() uses it for a bare contains() and nothing else (direct_messages.rs:987-994). It is a value that is only ever compared for equality and never recovered — the textbook case for a digest.

.claude/rules/contract-summary-determinism.md already names this as the deferred half of #571. PR #571 did the same fix for member_info (value (u32, Signature) -> (u32, SigDigest)) and measured 18,803 -> 3,894 bytes at 139 members, i.e. 135.27 -> 28.01 bytes/entry. This is the remaining half.

Why it matters beyond River

Contract summaries are ~23.7% of all Freenet outbound bytes, and a fleet-wide measurement puts the mean summary at 16,675 bytes against a protocol digest-entry size of 21 bytes (freenet-core#5153). Summary size also sets the floor for a queued freenet-core fix that replaces a blind 111 KB full-state send with a summary exchange — so every byte cut here compounds.

Fix

Replace SignatureBytes with a 16-byte BLAKE3 digest in three places: message_signatures, and the signature inside DmOrderKey (which reaches pair_horizons and global_horizon).

Measured: 19,803 -> 5,103 bytes at the 300-DM cap (3.88x).

Use 16 bytes, not 8. An 8-byte digest gives 2,703 bytes (7.3x) but is grindable, and a DM-signature collision means a DM silently never propagates to a peer. River already has the right primitive in state: PurgeToken is a 16-byte BLAKE3 of the DM signature, so reusing it costs no new derivation.

Correctness coupling — flag this in review

DmOrderKey is also the in-state retention ordering key. Ordering by digest is a different total order than ordering by raw signature. sort_state and the horizon comparison m.order_key() > **oldest must switch together, or the horizon silently changes meaning and re-opens the resend loop that #485 closed.

Related, not in scope here

  • Scope. Every peer's summary advertises every DM in the room to every member, participant or not — a per-broadcast cost and a privacy leak of exact DM volume. The module docs (direct_messages.rs:99) already name per-member DM contracts as the intended fix.
  • SecretsSummary.member_secrets is a latent bomb for private rooms. It is BTreeSet<(SecretVersion, MemberId)> — one entry per (member x secret version) — measured at 27,649 bytes for 200 members x 20 versions. No live private room was available to measure. Worth its own issue before a private room hits that shape.

Cost to weigh

This re-keys the room contract and strands a 32nd legacy generation. Every stranded generation keeps failing anti-entropy forever (freenet-core#5153 and its stranded-generation sub-issue), so there is a real argument for landing the freenet-core retention fix first and batching River summary changes rather than shipping them one at a time.

Testing

Golden-vector test pinning the digest and its CBOR encoding (one fixed input, one fixed expected digest, one fixed expected byte length) — a randomised oracle misses byte-order bugs intermittently. Plus a test asserting sort_state and the horizon comparison agree under the new ordering.

[AI-assisted - Claude]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions