Courier spray: send receipts direct-only to match the verify guard

The receipt verify path (verifiedSprayReceiptTakerKey) authenticates a
spray ack/decline exactly like a deposit: the claimed sender must be the
direct BLE ingress peer. But the send path used sendPacketDirected with
the default requireDirectPeerLink: false, so a receipt could relay
multi-hop when the direct link dropped. A relayed receipt arrives with
ingress != signer and is therefore always rejected by the guard -- so the
relay-capable send only ever produced a mesh flood the giver was
guaranteed to discard, and the doc comment claiming receipts are "relayed
across the mesh" was true of the send but false of the verify.

Make receipts direct-only on send (requireDirectPeerLink: true) so send,
verify, and the comment agree. Behavior is unchanged: with a direct link
the receipt is sent and verified as before; with none it is dropped and
the giver's offer degrades to timeout-commit (the spend stands) rather
than restoring -- never worse than today's optimistic decrement. This
also removes an unreachable-by-construction relay path.

Restoring the budget on a genuinely relayed decline needs a per-offer
nonce echoed giver->taker->giver (a CourierEnvelope wire change), left as
follow-up work along with re-keying the receipt lookup off the signed
sender.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ecgang 2026-07-13 16:45:19 -07:00
parent 874924caf8
commit 02e156e7a9

View File

@ -4750,7 +4750,15 @@ extension BLEService {
to: peerID,
type: type
)
self.sendPacketDirected(receiptPacket, to: peerID)
// Direct-only: the giver verifies receipts as direct (ingress peer
// == signer), so relaying one would only produce a mesh flood the
// giver is guaranteed to reject. If the taker has no direct link
// back, the decline is dropped and the giver's offer degrades to
// timeout-commit (the spend stands) rather than restoring never
// worse than today's optimistic decrement. Restoring on a genuinely
// relayed decline needs a per-offer nonce echoed giver->taker->giver
// (a CourierEnvelope wire change), left as follow-up work.
self.sendPacketDirected(receiptPacket, to: peerID, requireDirectPeerLink: true)
}
// Ack when we hold the copy (fresh store *or* idempotent dedup-hit,
// both `deposit == true`); decline only on a deterministic non-store
@ -4889,10 +4897,12 @@ extension BLEService {
/// only labels the security log line.
private func verifiedSprayReceiptTakerKey(_ packet: BitchatPacket, from peerID: PeerID, kind: String) -> Data? {
// Bind the receipt to *our* offer, exactly like `handleMeshPing`/
// `handleMeshPong` gate on `recipientID`. Receipts are directed packets
// relayed across the mesh; a valid signature only proves the courier
// signed *a* receipt, not that it was addressed to this giver. Without
// this a signed ack/decline meant for a different depositor of the same
// `handleMeshPong` gate on `recipientID`. Receipts are sent and
// verified direct-only, but a taker directly linked to two givers can
// still sign a receipt for the *other* giver's deposit of the same
// envelope; a valid signature only proves the courier signed *a*
// receipt, not that it was addressed to this giver. Without this bind a
// signed ack/decline meant for a different depositor of the same
// envelope could resolve our pending offer for the same (hash, courier)
// a cross-recipient decline would restore budget we already spent
// (inflation), a cross-recipient ack would clear a restore window we