Skip to content

feat: advertise this client's reassembly ceiling in authenticated peer state - #879

Open
Chessing234 wants to merge 2 commits into
permissionlesstech:mainfrom
Chessing234:feat/advertise-reassembly-ceiling
Open

feat: advertise this client's reassembly ceiling in authenticated peer state#879
Chessing234 wants to merge 2 commits into
permissionlesstech:mainfrom
Chessing234:feat/advertise-reassembly-ceiling

Conversation

@Chessing234

Copy link
Copy Markdown

The Android half of the per-peer fragment-ceiling negotiation in permissionlesstech/bitchat#1671, which closes the TODO(#1434) iOS left when the blanket 256-fragment preflight was narrowed.

This is a live interop bug, not a future-proofing exercise

Three facts on current main, both sides:

  • This client advertises PRIVATE_MEDIA (bit 8) — PeerCapabilities.LOCAL_SUPPORTED is exactly that bit, sent in both the announce and authenticated peer state — and it implements the encrypted 0x20 path (NoisePayloadType.FILE_TRANSFER).
  • FragmentManager rejects any inbound stream whose total exceeds MAX_FRAGMENTS_PER_ID (256), regardless of packet type.
  • iOS reads bit 8 as "this peer has a reassembler as large as mine", so encrypted private media to us is planned against its own 10,000-fragment ceiling. Its 256-cap applies only to the directed fileTransfer migration fallback.

At MAX_FRAGMENT_SIZE = 469, 256 fragments is about 120 KiB. So an iOS→Android private image or voice note above roughly that size is planned at more than 256 fragments and dropped by our reassembler, with no failure visible to either side. The capability bit says what we understand; it has never said how much we can hold, and that is the gap.

The change

TLV 0x03 on AuthenticatedPeerState: a 2-byte big-endian count of fragments this client will reassemble for one packet, stated inside the established Noise session. Byte-identical to the field iOS #1671 reads.

  • Optional and absent on every released client, so nothing that exists today decodes differently. The decoder already skips unknown TLVs, which is what lets the two sides land independently in either order.
  • An unreadable 0x03 (zero, wrong width, duplicated) is rejected rather than skipped like an unknown type. Skipping would read as "said nothing", and "said nothing" is precisely what sends the sender back to the type guess this field exists to replace.
  • Not persisted. SecureIdentityStateManager's record stays three fields, and the ceiling is a per-session statement — the same reasoning as iOS, where it is pinned to the Noise session generation and deliberately not inherited across a rekey.
  • AuthenticatedPeerState.local() builds our own state. MeshCore and BluetoothMeshService had the same construction inlined twice; sharing it means the advertised number is pinned to the constant FragmentManager enforces rather than restated by hand next to a capability set.

Scope

Advertising only. This client already caps its own sends at MAX_FRAGMENTS_PER_ID, which is below iOS's ceiling, so nothing it sends is at risk today and honouring a peer's advertised ceiling on the send path is a separate concern for a follow-up.

Verification

Local run of the CI job (testDebugUnitTest lintDebug, JDK 21):

  • 784 → 790 tests, 0 failures, lint clean. The delta is exactly the 6 tests added here.
  • Both new guards were mutation-checked. Dropping the ceiling from local(), and downgrading the unreadable-0x03 rejection to a skip, each fail only their intended test — 2 failures, both named, nothing else moved.
  • Byte-level assertion against the canonical encoding, so the wire form is pinned rather than assumed compatible: 01 01 02 00 01 02 20 <key> 03 02 01 00.

CI has not run. Fork PRs on this repo sit at action_required until a maintainer approves the workflow, so the local run above is the evidence, not a green check. Same for #866/#867/#875.

Coordination

Answers the wire-constant question left open on iOS #1434 for this field specifically: 0x03, 2 bytes, big-endian, and the value Android sends is 256 because that is what its reassembler actually enforces. The field is additive in both directions, so this can land before, after, or without #1671 — but the benefit only appears once a sender reads it.

The private-media bit tells a sender we understand encrypted media. It
says nothing about how much of it we can hold, so a sender has to infer
the ceiling from the packet type — and that inference is wrong for this
client. FragmentManager rejects any stream above MAX_FRAGMENTS_PER_ID
whatever the type, while a sender that reads the bit as "large
reassembler" plans far more than 256 fragments and sees no failure.

Add TLV 0x03: a 2-byte big-endian count, optional, absent on every
already-released client. An unreadable one (zero, wrong width,
duplicated) is rejected rather than skipped like an unknown type —
skipping reads as "said nothing", and "said nothing" is what sends the
peer back to the guess.

`local()` builds our own state so the two mesh services cannot drift and
the advertised number is pinned to the constant the reassembler enforces.
Both built the local peer state inline and identically; they now share
the factory, which is also what makes the advertisement testable without
standing up a mesh service.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2d24d9a401

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

* Wire format:
* `[version=0x01][type=0x01][len=1...8][minimal LE capabilities]`
* `[type=0x02][len=32][Ed25519 public key]`
* `[type=0x03][len=2][reassembly fragment ceiling, big-endian]` (optional)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the peer-state wire spec with TLV 0x03

Adding TLV 0x03 changes the authenticated peer-state wire contract, but the published interop spec in docs/PRIVATE_MEDIA_V1.md still defines 0x21 as only the capabilities and Ed25519-key TLVs and says unknown TLVs are skipped. Since this field is meant for cross-client negotiation with other senders, leaving the spec stale makes the new canonical bytes and rejection rules ambiguous for clients implementing the same contract; please update the relevant spec alongside this wire-format change.

AGENTS.md reference: AGENTS.md:L90-L91

Useful? React with 👍 / 👎.

@Chessing234

Copy link
Copy Markdown
Author

correction on the numbers in the body: "784 → 790" came from an incremental gradle run, where result files left by earlier filtered runs get counted alongside the current ones. re-measured with --rerun-tasks, the full suite on main is 608 tests in 94 classes, so this branch is 608 → 614.

the delta is unchanged and still exactly the 6 tests added here — AuthenticatedPeerStateTest goes 4 → 10 and no other class moves — and the mutation results stand. only the absolute figures were wrong.

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