Skip to content

perf: encode ed25519 signatures as CBOR byte strings, not 64-element arrays - #576

Draft
sanity wants to merge 3 commits into
mainfrom
fix/575-signature-bytes
Draft

perf: encode ed25519 signatures as CBOR byte strings, not 64-element arrays#576
sanity wants to merge 3 commits into
mainfrom
fix/575-signature-bytes

Conversation

@sanity

@sanity sanity commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

Draft — blocked behind #572, and must NOT merge in its current shape.
This PR carries the source change and tests only. The contract re-key
(rebuilt room_contract.wasm + legacy_room_contracts.toml entry +
migration.rs count/fingerprint pin + riverctl bump) lands as a later
commit on this same PR
, after rebasing onto post-#572 main, per the repo's
five-precedent single-commit pattern. See Sequencing — merging
the source without the bundle would leave the deployed contract unable to
parse what the UI and CLI write.

Problem

ed25519_dalek::Signature's derived Serialize calls serialize_tuple(64),
ciborium maps a tuple to serialize_seq, and every byte >= 0x18 then costs
two bytes on the wire. A 64-byte signature occupies ~122 CBOR bytes where a
byte string needs 66.

River already documents this trap for its own Vec<u8> fields
(content.rs:80-92; #443 fixed ActionContentV1::payload). What was missed is
that it also applies to imported types whose Serialize impl we do not
control
, and it is invisible at the call site because River's own
SignatureBytes newtype already uses serialize_bytes — so the natural
assumption is that all 64-byte signatures cost 66.

Ten room-state fields carry a signature. Measured on a 200-member
MembersV1 + MemberInfoV1 state built in-test with real keys:

signatures 400
legacy (array) encoding 92,076 B
byte-string encoding 69,206 B
saved 22,870 B — 24.8%
per signature 57.2 B

The direct-message wire-format fixture shrank from 501 B to 390 B (2
signatures, 111 B, 55.5 B each).

Correction to the issue's figure. #575 quotes a flat 55 B per signature.
The saving is exactly the number of bytes in that signature that are >=
0x18
, so it is signature-dependent: ~58 B on average over uniformly
distributed signature bytes, 55 B for the issue's single sample, 61 B for the
golden vector in these tests. The tests assert the exact per-signature
population rather than a constant, so the number cannot rot. The 23.9%-of-state
headline in the issue reproduces (24.8% on the members+member_info subset).

Approach

One serde(with = ...) helper, river_core::util::sig_serde
(common/src/util.rs):

  • serialize writes a CBOR byte string.
  • deserialize accepts both a byte string and the legacy 64-element
    integer array, via deserialize_any. This is required, not cosmetic:
    existing rooms hold the array form and contract migration re-PUTs that state,
    and three long-lived user-facing artifacts embed an AuthorizedMember
    ?invitation=… links, DM-carried invites, and armored identity-export
    tokens. Without the legacy arm all of them would go dead.

Shape and documentation follow content.rs's payload_bytes (#443), including
its notes on deserialize_any vs Header::Tag and on keeping core::fmt out
of the contract WASM. The unbounded-preallocation hazard that helper guards
against does not arise here — the destination is a fixed [u8; 64] and the
loop refuses a 65th element — but that is stated at the code rather than left
implicit.

Safety verification — per field, not assumed

sign_struct / verify_struct (common/src/util.rs:6-20) ciborium-serialize
the inner struct only, so in an AuthorizedX { x, signature } wrapper the
signature sits outside the signed bytes. Every field was checked individually
against its own verify path before being touched:

# Field (line on this branch) Wrapper What is actually signed Verdict
1 configuration.rs:18 signature AuthorizedConfigurationV1 { configuration, signature } verify_signature re-serializes self.configuration only (configuration.rs:157-161) outside — changed
2 member.rs:642 signature AuthorizedMember { member, signature } verify_struct(&self.member, …) (member.rs:665) outside — changed
3 member_info.rs:354 signature AuthorizedMemberInfo { member_info, signature } verify_struct(&self.member_info, …) (member_info.rs:388) outside — changed
4 message.rs:1000 signature AuthorizedMessageV1 { message, signature } verify_struct(&self.message, …) (message.rs:1042) outside — changed
5 ban.rs:446 signature AuthorizedUserBan { ban, banned_by, signature } verify_struct(&self.ban, …) (ban.rs:491) — banned_by is outside too, and untouched outside — changed
6 upgrade.rs:20 signature AuthorizedUpgradeV1 { upgrade, signature } verify_struct(&self.upgrade, …) (upgrade.rs:108) outside — changed
7 secret.rs:286 owner_signature AuthorizedSecretVersionRecord { record, owner_signature } verify_struct(&self.record, …) (secret.rs:308) outside — changed
8 secret.rs:331 owner_signature AuthorizedEncryptedSecretForMember { secret, owner_signature } verify_struct(&self.secret, …) (secret.rs:350) outside — changed
9 direct_messages.rs:270 sender_signature AuthorizedDirectMessage { message, sender_signature } a hand-built byte layout (build_direct_message_signed_bytes) — not CBOR at all outside — changed
10 direct_messages.rs:305 recipient_signature AuthorizedRecipientPurges { recipient_id, state, recipient_signature } hand-built build_recipient_purges_signed_bytes outside — changed
11 web_container.rs:26 signature WebContainerMetadata { version, signature } web-interface bytes + version — also outside EXCLUDED, see below

Two further checks behind that table:

  • No signature is nested inside another signature's representation. All
    eight signed inner structs (Configuration, Member, MemberInfo,
    MessageV1, UserBan, UpgradeV1, SecretVersionRecordV1,
    EncryptedSecretForMemberV1) were read field by field; none contains a
    Signature. So no sign_struct call site has its input re-encoded.
  • The message.rs:577 hazard class is untouched. RoomMessageBody's
    data / ciphertext live inside MessageV1, which verify_struct
    re-serializes. They are not changed, and the helper's doc comment names them
    as the boundary not to cross.

Excluded: WebContainerMetadata::signature

Not excluded for the signed-representation reason — that signature is outside
the signed bytes like the rest. It is excluded because its reader is a
separately deployed contract
: contracts/web-container-contract
deserializes this metadata inside the already-published web-container WASM,
which lives at its own fixed address and is not re-keyed by the room-contract
migration. Emitting the byte-string form from the publish tool would produce
metadata the live contract cannot parse, and every River UI publish would be
rejected. Moving it requires deploying an accept-both contract first and only
then switching the writer. The prize is ~58 bytes on one signature per UI
publish. The reason is recorded at the field so the next person doesn't
"finish the job".

Not touched: the member_info summary

member_info.rs's type Summary = BTreeMap<MemberId, (u32, Signature)> uses
the bare Signature type, whose Serialize impl is untouched by this PR. That
is deliberate: #572 is replacing that tuple with a digest, and shrinking it here
would collide.

Stability properties worth stating

  • Every derived identifier is unchanged. MessageId, BanId,
    PurgeToken and AuthorizedConfigurationV1::id() all hash
    signature.to_bytes() — the raw 64 bytes, never the CBOR encoding. Dedup,
    retention ordering and DM purge tombstones do not shift.
  • JSON output is byte-identical. serde_json writes serialize_bytes as
    an array of numbers, so riverctl … --json and cli/src/storage.rs's
    on-disk format are unchanged, and old JSON storage files round-trip through
    the legacy visit_seq arm. Two pre-existing tests
    (json_round_trip_of_full_chat_room_state_preserves_dms,
    storage::tests::invitation_secrets_round_trip_via_disk) turn out to guard
    this and fail when the legacy arm is removed.
  • No old-form byte alias can reach storage. The room contract's
    update_state deserializes the incoming state/delta into ChatRoomStateV1
    and re-serializes with into_writer (contracts/room-contract/src/lib.rs:180)
    — there is no path that stores received bytes verbatim. Verified by reading
    the contract, and pinned in tests: for every changed type, decoding the legacy
    form and re-encoding produces the new form byte-for-byte. This is the
    freenet-core #4857 byte-compare divergence class, so it is checked rather
    than assumed.

Consequences that are real but accepted

  • New artifacts are not readable by pre-perf: ed25519 Signature serializes as a CBOR int array — ~55 B/signature overhead, ~24% of a 470-member room's state #575 clients. Invitation links,
    DM-carried invites and identity tokens minted after this change use the byte
    string; an older River UI or riverctl cannot decode them. This is the same
    cut-over the contract re-key already forces, and it is why the riverctl bump
    belongs in the migration bundle.
  • One-time invite-dedup drift. The processed-invite fingerprint is
    Invitation::to_encoded_string(), so it changes with the encoding. A user who
    accepted a link on an old build and clicks the same link after upgrading may
    see the modal once more. Within a version the fingerprint is consistent
    (both URL-time and dismiss-time go through the canonical re-encode).

Testing

cargo test --workspace green. New common/tests/signature_encoding_test.rs
(13 tests) plus a legacy-import test in identity.rs and one in
direct_messages_test.rs, and legacy-invite tests in both the UI and the CLI.

  • Golden vectors from outside the code. The fixed signature was produced
    with Python's cryptography (Ed25519 is deterministic per RFC 8032) and both
    CBOR framings were hand-derived from RFC 8949 — 66 B for the byte string,
    127 B for the array (this signature has 61 bytes >= 0x18).
  • Legacy fixtures are genuinely old-form, built through shadow structs that
    use the plain Signature derive, plus two frozen historical artifacts whose
    pre-change values are preserved verbatim and must never be regenerated:
    common/tests/direct_messages_wire_format_legacy_sig_array.hex and
    INVITATION_LEGACY_SIG_ARRAY_FIXTURE (kept in step in the UI and the CLI).
  • Anti-vacuity guard. every_case_has_a_genuinely_distinct_legacy_encoding
    fails if any "legacy" fixture is byte-equal to its new form, and asserts the
    size delta equals the count of signature bytes >= 0x18. Without it a shadow
    struct drifting into the new encoding would make the whole file pass while
    testing nothing.
  • Verification after legacy decode, per type — decoding to the right value
    is not the same as the signature still verifying.
  • Rejections: wrong-length byte string, short array, long array, element
    above 255, and a lying 0x9B FF..FF length header (error, not panic).
  • Size, rebuilding the old encoding from the same records in-test, per the
    member_info_summary_stays_small_per_entry discipline from perf(room-state): carry a signature digest in the member_info summary, not the signature #572.

Mutation checks — each toggled and run, not reasoned about

Mutation Result
Remove the visit_seq legacy arm 6 fail in signature_encoding_test, 3 in direct_messages_test (incl. 2 pre-existing JSON tests); CLI + UI legacy-invite tests fail; storage::tests::invitation_secrets_round_trip_via_disk fails
Revert serialize to the derive 3 fail in signature_encoding_test, 2 in direct_messages_test
Drop visit_seq's length checks short_legacy_array_is_rejected, long_legacy_array_is_rejected fail
Drop the > u8::MAX element check legacy_array_element_above_255_is_rejected fails
Drop #[serde(with)] from AuthorizedMember every_case_has_a_genuinely_distinct_legacy_encoding, state_size_saving_matches_the_signature_population fail
Switch the shadow structs to the NEW encoding (the vacuity scenario) golden_legacy_form_is_the_hand_derived_int_array, every_case_has_a_genuinely_distinct_legacy_encoding fail

cargo fmt clean. Clippy is disabled repo-wide (#573); cargo clippy --workspace -- -D warnings was run anyway and its result is reported in a
comment below — no pre-existing lints were fixed.

Sequencing

This change re-keys the room contract, and its migration must not share a
re-key with #572's — two independent encoding changes in one migration is an
unscopeable rollback. #572 merges and publishes first.

So this PR deliberately does not rebuild or commit room_contract.wasm,
does not add a legacy_room_contracts.toml entry, and does not bump riverctl.
Both migration CI jobs stay green in that state (check-cli-wasm compares the
two committed WASM blobs against each other and only demands a riverctl bump
when a WASM file changes; check-room-contract-migration only fires on a
committed WASM change).

The consequence, and the reason for the banner at the top: while this PR sits
source-only, the committed WASM no longer matches the source.
Merging it
alone would put a UI and CLI that write byte-string signatures in front of a
deployed contract that can only parse arrays. The migration bundle must land on
this branch, after a rebase onto post-#572 main, before merge.

Refs #575

[AI-assisted - Claude]

sanity added 3 commits July 30, 2026 17:22
## Problem

`ed25519_dalek::Signature`'s derived `Serialize` calls `serialize_tuple(64)`,
which ciborium maps to `serialize_seq` — so every signature in River's room
state goes on the wire as a CBOR array of 64 integers. Any byte >= 0x18 costs
two bytes in that encoding, so a 64-byte signature occupies ~122 bytes instead
of the 66 a CBOR byte string needs.

River already knew this trap for its own `Vec<u8>` fields (`content.rs`
documents it; #443 fixed `ActionContentV1::payload`). What was missed is that
it also applies to imported types whose `Serialize` impl River does not
control, and it is invisible at the call site because River's own
`SignatureBytes` newtype already uses `serialize_bytes`.

Ten room-state fields carry a signature. On a 200-member members +
member_info state the overhead measures 22,870 B, 24.8% of the encoded size.

## Approach

One `serde(with = ...)` helper, `river_core::util::sig_serde`:

* `serialize` writes a CBOR byte string;
* `deserialize` accepts BOTH a byte string and the legacy 64-element integer
  array, because existing rooms hold the array form and contract migration
  re-PUTs that state — and because `?invitation=…` links are base58(CBOR) of a
  struct containing an `AuthorizedMember`, so outstanding links must keep
  working.

Applied to all ten room-state signature fields. `WebContainerMetadata::
signature` is deliberately excluded and the reason is documented at the field:
its reader is the separately deployed web-container contract, which is not
re-keyed by the room-contract migration.

Every field was checked against the signed representation before being
touched: `sign_struct`/`verify_struct` serialize the INNER struct only, so an
`AuthorizedX { x, signature }` wrapper's signature is outside the signed bytes.

## Testing

New `common/tests/signature_encoding_test.rs` (13 tests): golden vectors
produced with Python `cryptography` and hand-derived CBOR headers, per-type
round-trip, legacy acceptance via shadow structs that use the plain derive,
signature verification after legacy decode, canonicalization (old bytes in,
new bytes out), rejection of malformed inputs, and an in-test size baseline
that rebuilds the old encoding from the same records.

Two frozen fixtures were regenerated and their pre-change values preserved as
permanent legacy-decode fixtures:
`common/tests/direct_messages_wire_format_legacy_sig_array.hex` and
`INVITATION_LEGACY_SIG_ARRAY_FIXTURE` (UI + CLI).

Refs #575
Armored identity tokens embed an `AuthorizedMember` and an optional
`AuthorizedMemberInfo`, and users save them to disk and paste them into
other devices — so they are a third long-lived artifact (alongside
`?invitation=…` links and DM-carried invites) that carries the pre-#575
array-encoded signature and must stay importable.

The fixture is built through shadow structs using the plain `Signature`
derive, and the test asserts it is genuinely distinct from the current
encoding before asserting it imports, so it cannot pass vacuously.
Clippy's field_reassign_with_default fired on the new signature-encoding
test. Clippy is disabled repo-wide (#573), but code this PR
adds should not add to that backlog.
@sanity

sanity commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Clippy

Clippy is disabled repo-wide (#573), so it was run manually as the brief asked:

cargo clippy --workspace --all-targets -- -D warnings

84 warnings, none in any file this PR touches. The file-scoped check:

cargo clippy --workspace --all-targets --message-format=short 2>&1 \
  | grep -E 'common/src/util.rs|common/tests/signature_encoding_test.rs|common/src/room_state/(identity|member|member_info|message|ban|upgrade|secret|configuration|direct_messages)\.rs|common/src/web_container.rs|cli/src/api.rs|ui/src/components/members.rs'

returns nothing. The 84 that remain are the pre-existing #573 backlog and were left alone.

One warning WAS introduced by this PR and is fixed in 0d694a1: field_reassign_with_default on the Configuration fixture in the new test.

cargo fmt --all is clean. cargo test --workspace: 1665 passed, 0 failed, 4 ignored (the 4 are pre-existing).

Note for anyone reproducing locally: cargo test --workspace needs ui/assets/styles.css, which is gitignored and produced by npm run build:css (or cargo make build-tailwind). Without it the river-ui test target fails to compile with Asset at /assets/styles.css doesn't exist before any test runs.

[AI-assisted - Claude]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant