Courier spray: move courierAck to capability bit 12

Bit 8 is claimed by privateMedia in #1434, which #1463 and #1466 build on;
9 and 10 are claimed by that same stack. Skip to 12. The gap is free on the
wire: encoded() drops only trailing zero bytes, so every bit in 8...15
encodes to the same two bytes.

Also in this commit:

- Restore .periphery.baseline.json to the repo's minified formatting. It had
  been regenerated with a JSON pretty-printer, so 537 bytes of whitespace
  churn sat on top of the four real entries.
- Reject spray receipts whose payload is not a 16-byte ciphertext hash before
  using it as a map key. The signature check already gates the handler, so
  this is consistency with the deposit path rather than a fix.
- Record the trust assumption behind cancelSpray: a taker can sign a decline
  and keep the copy, bounded at 2x per envelope by maxCopies. Spray only
  reaches favorites and verified peers, who can already drop carried mail
  outright, so the restore is not defended against.
- Record the isolation invariant offerSprayCopies depends on: no suspension
  point between the scan and the commit, since acceptance puts copies on the
  wire irreversibly. The sole caller satisfies it with an await-free
  @MainActor task; adding an await there would reopen the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ecgang 2026-07-25 13:01:47 -07:00
parent 444da6b684
commit bd0d1a3188
3 changed files with 23 additions and 2 deletions

View File

@ -4939,6 +4939,7 @@ extension BLEService {
/// offer.
private func handleCourierSprayAck(_ packet: BitchatPacket, from peerID: PeerID) {
guard let takerKey = verifiedSprayReceiptTakerKey(packet, from: peerID, kind: "ack") else { return }
guard packet.payload.count == CourierEnvelope.tagLength else { return }
let ciphertextHash = packet.payload
guard courierStore.confirmSpray(courierNoiseKey: takerKey, ciphertextHash: ciphertextHash) else { return }
let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey)
@ -4967,6 +4968,7 @@ extension BLEService {
/// the baseline the strictly worse failure, so we accept the floor.
private func handleCourierSprayDecline(_ packet: BitchatPacket, from peerID: PeerID) {
guard let takerKey = verifiedSprayReceiptTakerKey(packet, from: peerID, kind: "decline") else { return }
guard packet.payload.count == CourierEnvelope.tagLength else { return }
let ciphertextHash = packet.payload
guard courierStore.cancelSpray(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey) else { return }
let key = PendingSprayTimeoutKey(ciphertextHash: ciphertextHash, courierNoiseKey: takerKey)

View File

@ -491,6 +491,15 @@ final class CourierStore {
}
var acceptedCount = 0
// CALLER INVARIANT: no suspension point between the scan above and the
// commit below. `accepting` puts copies on the wire irreversibly, so a
// commit that then loses its revalidation leaves those copies uncharged
// two couriers each offered `copies / 2` before either commits would
// put 6 copies out from a budget of 4. What prevents it is that the sole
// caller (`BLEService.sprayCourierMail`) runs scan/send/commit inside a
// single `Task { @MainActor }` containing no `await`, so the actor's
// executor runs it to completion. Adding an `await` anywhere in that
// block reopens this. See `concurrentOffersToDifferentCouriersConserveCopies`.
for copy in offered where accepting(copy) {
// As with `transferSprayCopies`, BLE acceptance runs outside the
// store queue. Revalidate and commit the exact budget that left this
@ -558,6 +567,14 @@ final class CourierStore {
/// (envelope, courier) means every receipt resolves that one entry
/// idempotently.
///
/// **Trust assumption.** A taker can sign a decline and keep the copy, so the
/// giver restores a budget the copy still occupies: at most 2x on that
/// envelope, bounded by `maxCopies`. This is not defended against, because
/// spray only ever reaches favorites and verified peers (`courierDepositPolicy`),
/// and a peer inside that boundary can already drop carried mail outright
/// total loss, no protocol needed. Closing the smaller hole would cost a
/// three-round offer/accept/deliver handshake on contacts that last seconds.
///
/// The restore is gated on the matched record *still listing this courier in
/// `sprayedTo`*, not on the ciphertext hash alone. A hash match is not proof
/// of identity across a remove+redeposit: handover/eviction/prune can drop

View File

@ -44,8 +44,10 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
/// a giver can defer spending a spray copy's budget until the taker either
/// confirms it stored the copy or reports a deterministic refusal.
///
/// Bits 8-11 are left to the private-media stack (#1434, #1463, #1466);
/// 8-15 all encode to the same two wire bytes, so the gap costs nothing.
/// Bits 8-10 are deliberately skipped: they are claimed by in-flight work
/// (`privateMedia` 8, `privateMediaReceipts` 9, `nonDestructiveNoiseReplacement`
/// 10). The gap costs nothing on the wire `encoded()` drops only trailing
/// zero bytes, so every bit in 8...15 encodes to the same two bytes.
public static let courierAck = PeerCapabilities(rawValue: 1 << 12)
/// Minimal little-endian byte encoding; always at least one byte so an