Skip to content

Proof watchdog drains parked Noise payloads with no session, dropping acks and receipts #1743

Description

@jozanek

handlePrivateMediaProofTimeout drains the peer's parked Noise payloads whenever deferredOutbound is false, whether or not a Noise session exists (BLEService.swift:1336). The drain, sendPendingNoisePayloadsAfterHandshake, takes every parked payload, encrypts under the current session, and never re-queues. With no session every encryption fails: a payload with a transferId gets a visible "Failed to encrypt media" rejection, and one without — a delivery ack, a read receipt, a verify, vouch or group payload — is dropped with only a log line.

How it happens without an attacker

  1. You pick a photo for a peer that advertises private-media support and you have no session with yet. resolvePrivateMediaSendPolicy arms the capability-proof watchdog (privateMediaCapabilityProofTimeoutSeconds, 5 s) and starts a handshake.
  2. Something else for that peer parks behind the same handshake — for example the delivery ack for a message they just sent you (sendDeliveryAck queues it when there is no session).
  3. The handshake takes longer than 5 s, which is ordinary over a multi-hop mesh. The watchdog fires, settles the media policy, and drains the queue. The ack fails to encrypt and is gone; when the session comes up a moment later there is nothing left to send.

noteNoiseSessionCleared also re-arms the watchdog at the moment a session is cleared, so a peer with a pending media decision who drops and reconnects slowly hits the same path.

Of the four production callers of the drain, the watchdog is the only one without a session precondition; the other three run as a session authenticates or when a proof arrives over one. The timeout-restore case is already held back by deferredOutbound — "The flag also holds the proof watchdog's drain to the same rule" — so this looks like the one case that slipped through.

Encryption fails closed: nothing is sent unencrypted or downgraded. This is message loss, not a confidentiality issue.

Reproduction

PrivateMediaEndToEndTests.proofTimeoutBeforeHandshakeLeavesParkedAckForTheSession drives it through the public path: seed a .privateMedia peer with no session, resolvePrivateMediaSendPolicy, sendDeliveryAck, then force the watchdog as the neighbouring proof-timeout tests do. Every setup expectation holds — the ack is parked (count 1) and the policy settles to legacyRequiresConsent — and then the parked count is 0.

It needs one DEBUG-only hook, _test_pendingTypedPayloadCount(for:), because the existing _test_privateMediaTransferState can only see payloads that carry a transferId, and acks do not.

Proposed fix

Drain from the watchdog only when a session is established; otherwise leave the payloads for the drain that already runs when the session authenticates. With that guard the new test passes and so do the other 28 tests in PrivateMediaEndToEndTests. PR to follow.

Open question

Should the drain's error path also re-queue payloads without a transferId when encryption fails for lack of a session, as defence in depth? The guard above closes the one caller that reaches it with no session, so I have left that alone rather than change a path queuedPrivateEncryptionFailureRejectsBoundTransfer deliberately pins.

Activity

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

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