Skip to content

stratum-apps: NoiseTcpWriteHalf::try_write_frame can desynchronize the Noise transport #734

Description

@plebhash

NoiseTcpWriteHalf::try_write_frame encrypts the frame (advancing the Noise nonce) before it attempts the socket write.

If the write returns WouldBlock, the method returns Ok(false) and discards both the ciphertext and the already-consumed nonce.

The next write — whether a retry of the same frame or a new one — is sealed under a later nonce while the peer's decryptor is still expecting the discarded one. The peer's next decryption fails its MAC check and the connection is permanently desynchronized.

Partial writes have a related problem: a ciphertext prefix can reach the socket while the remainder is discarded.

Location

pub fn try_write_frame(&mut self, frame: StandardEitherFrame<Message>) -> Result<bool, Error> {
let buf = self.encoder.encode(frame, &mut self.state)?;
match self.writer.try_write(buf.as_ref()) {
Ok(n) if n == buf.len() => Ok(true),
Ok(_) => Err(Error::SocketClosed),
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(false),
Err(_) => Err(Error::SocketClosed),
}
}

Why this is a bug

ChaCha20-Poly1305 is deterministic given (key, nonce, plaintext), and the Noise transport nonce is a strictly monotonic counter.

Once the frame is encrypted, it is bound to a specific nonce.

The Ok(false) contract ("not ready, retry later") is therefore unfulfillable: there is no way to retry that frame under the same nonce. The retry seals a different nonce, the peer's decryptor is out of step, authentication fails, and the connection dies (or, with a partial write, the peer receives a truncated ciphertext it can never reconcile).

Contrast the read half — it is safe by construction:

pub fn try_read_frame(&mut self) -> Result<Option<StandardEitherFrame<Message>>, Error> {
let expected = self.decoder.writable_len();
if self.current_frame_buf.len() != expected {
self.current_frame_buf.resize(expected, 0);
self.bytes_read = 0;
}
match self
.reader
.try_read(&mut self.current_frame_buf[self.bytes_read..])
{
Ok(0) => return Err(Error::SocketClosed),
Ok(n) => self.bytes_read += n,
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(None),

try_read_frame returns Ok(None) on WouldBlock before any decryption is attempted. The decrypt nonce is only ever consumed once a complete frame is present.

Impact

  • Not a confidentiality breach — the connection fails closed (MAC rejection), not open.
  • It is an availability/liveness and API-contract bug: a caller using the documented Ok(false) retry semantics will always tear down its own connection the moment the socket is briefly not ready.
  • Current exposure: try_write_frame has no in-repo callers, so no shipping binary hits this today. It is a latent public API, and the contract as documented invites exactly the misuse that triggers the bug.

Notes / context

  • Pre-existing behavior; surfaced while reviewing the Noise transport-state hardening (Noise sv2 hardening stratum#2283, Noise-sv2 hardening  #706), where the write half now owns a dedicated TransportEncryptState.
  • The nonce desync is precisely the class of failure the state split was meant to make impossible — but it can't fix an API whose control flow consumes the nonce before the I/O outcome is known:

Possible fixes

  1. Retain pending ciphertext + write offset in NoiseTcpWriteHalf, so a WouldBlock/partial write preserves the already-sealed bytes and completes them on the next call. This keeps the non-blocking API and makes Ok(false) honest.
  2. Remove the non-blocking write API (or make it always-encrypt-at-write), eliminating the impossible contract. Simplest if no caller needs it.
  3. At minimum, document the hazard and change the return type so Ok(false) cannot be mistaken for "nothing happened".

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions