{"record":{"id":"2538aacbac90c06e","repo":"schollz/croc","slug":"peer-sent-an-unexpected-control-handshake","errorCode":null,"errorMessage":"Peer sent an unexpected control handshake","messagePattern":"Peer sent an unexpected control handshake","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"web/src/protocol/client.ts","lineNumber":190,"sourceCode":"    }\n    if (bytesEqual(plain, IP_REQUEST) && localKey) {\n      await socket.send(\n        await engine.encrypt(textEncoder.encode(JSON.stringify([])), localKey),\n      );\n      continue;\n    }\n\n    let probe: {\n      Bytes?: string;\n      Bytes2?: string;\n      Kind?: string;\n      Version?: number;\n      Curve?: string;\n    };\n    try {\n      probe = JSON.parse(textDecoder.decode(plain)) as typeof probe;\n    } catch {\n      throw new Error(\"Peer sent an unexpected control handshake\");\n    }\n    if (probe.Kind !== \"pake1\" || !probe.Bytes || !probe.Curve) {\n      throw new Error(\"Peer sent an unexpected control handshake\");\n    }\n    requirePakeVersion(probe.Version);\n    const initiator = base64ToBytes(probe.Bytes);\n    const pake = await engine.pakeInitWithIdentities(\n      textEncoder.encode(passphrase),\n      1,\n      probe.Curve,\n      PAKE_PURPOSE_LOCAL_PROBE,\n      room,\n    );\n    const finished = await engine.pakeUpdate(pake.handle, initiator);\n    const salt = randomBytes(PAKE_SALT_SIZE);\n    const keys = await engine.derivePeerKeys(\n      finished.key,\n      salt,","sourceCodeStart":172,"sourceCodeEnd":208,"githubUrl":"https://github.com/schollz/croc/blob/e25f1bdc04f07f094d50b0a1bf67e2563944b57a/web/src/protocol/client.ts#L172-L208","documentation":"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).","triggerScenarios":"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.","commonSituations":"Mixing client protocol versions; connecting through a gateway that injects its own keepalive frames; retry logic that reuses a half-consumed socket.","solutions":["Ensure both peers run compatible croc builds (same PAKE protocol version)","Retry with a fresh connection; do not reuse a control socket after a failed handshake","If behind a custom gateway, verify it does not inject frames into the relay data stream"],"exampleFix":"// before: retrying on a consumed socket\ntry { await waitForHandshake(control, ...); } catch { /* control buffer is dirty */ }\nawait waitForHandshake(control, ...); // JSON.parse of leftover bytes fails\n\n// after: reconnect from scratch on failure\ncontrol?.close();\nconst relay = await connectRelay(settings, room, port, signal);\ncontrol = relay.socket;","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  await waitForHandshake(control, room, passphrase, signal);\n} catch (e) {\n  if (/unexpected control handshake/.test(e.message)) {\n    control.close();\n    const relay = await connectRelay(settings, room, port, signal); // fresh socket\n    return waitForHandshake(relay.socket, room, passphrase, signal);\n  }\n  throw e;\n}","preventionTips":["Never reuse a control socket after a failed handshake attempt","Keep both clients on the same protocol build","Log the raw decrypted bytes on parse failure to spot gateway-injected frames"],"tags":["pake","handshake","protocol","json"],"backgroundTag":null,"analyzedSha":"e25f1bdc04f07f094d50b0a1bf67e2563944b57a","analyzedAt":"2026-08-15T12:53:39.096Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}