schollz/croc · error · Error

Peer sent an unexpected control handshake

Error message

Peer sent an unexpected control handshake

What it means

While probing for local-IP connectivity (the handshake/probe exchange on the control socket), the client received bytes that decrypted and decoded but failed JSON.parse. The peer was expected to send a JSON 'pake1' probe object; anything that is not valid JSON at this point is treated as an unexpected handshake. It indicates protocol divergence or garbage on the control channel, not a wrong passphrase (decryption already succeeded).

Source

Thrown at web/src/protocol/client.ts:190

    }
    if (bytesEqual(plain, IP_REQUEST) && localKey) {
      await socket.send(
        await engine.encrypt(textEncoder.encode(JSON.stringify([])), localKey),
      );
      continue;
    }

    let probe: {
      Bytes?: string;
      Bytes2?: string;
      Kind?: string;
      Version?: number;
      Curve?: string;
    };
    try {
      probe = JSON.parse(textDecoder.decode(plain)) as typeof probe;
    } catch {
      throw new Error("Peer sent an unexpected control handshake");
    }
    if (probe.Kind !== "pake1" || !probe.Bytes || !probe.Curve) {
      throw new Error("Peer sent an unexpected control handshake");
    }
    requirePakeVersion(probe.Version);
    const initiator = base64ToBytes(probe.Bytes);
    const pake = await engine.pakeInitWithIdentities(
      textEncoder.encode(passphrase),
      1,
      probe.Curve,
      PAKE_PURPOSE_LOCAL_PROBE,
      room,
    );
    const finished = await engine.pakeUpdate(pake.handle, initiator);
    const salt = randomBytes(PAKE_SALT_SIZE);
    const keys = await engine.derivePeerKeys(
      finished.key,
      salt,

View on GitHub (pinned to e25f1bdc04)

Solutions

  1. Ensure both peers run compatible croc builds (same PAKE protocol version)
  2. Retry with a fresh connection; do not reuse a control socket after a failed handshake
  3. If behind a custom gateway, verify it does not inject frames into the relay data stream

Example fix

// before: retrying on a consumed socket
try { await waitForHandshake(control, ...); } catch { /* control buffer is dirty */ }
await waitForHandshake(control, ...); // JSON.parse of leftover bytes fails

// after: reconnect from scratch on failure
control?.close();
const relay = await connectRelay(settings, room, port, signal);
control = relay.socket;
Defensive patterns

Strategy: try-catch

Try / catch

try {
  await waitForHandshake(control, room, passphrase, signal);
} catch (e) {
  if (/unexpected control handshake/.test(e.message)) {
    control.close();
    const relay = await connectRelay(settings, room, port, signal); // fresh socket
    return waitForHandshake(relay.socket, room, passphrase, signal);
  }
  throw e;
}

Prevention

When it happens

Trigger: A peer or middlebox sending a raw binary banner where the JSON probe was expected; a croc version that emits a different probe framing; leftover buffered bytes from a previous failed handshake being read as the probe.

Common situations: Mixing client protocol versions; connecting through a gateway that injects its own keepalive frames; retry logic that reuses a half-consumed socket.

Understand the failure class

Related errors


AI-assisted analysis of schollz/croc@e25f1bdc04 (2026-08-15). Data as JSON: /api/errors/2538aacbac90c06e. Report an issue: GitHub.