From 2a963a582671415aea9e853c2efa197c3ab053af Mon Sep 17 00:00:00 2001 From: Nick Bolton Date: Fri, 18 Sep 2026 18:06:51 +0100 Subject: [PATCH 1/2] feat: add protocol harness that sweeps the wire format versions Fake peer that acts as a client or a server at each protocol version and checks every message against the formats Synergy 1.x, Barrier and Input Leap use, so a mismatch both halves of Deskflow agree on still fails. --- README.md | 7 + protocol_harness.py | 521 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 528 insertions(+) create mode 100755 protocol_harness.py diff --git a/README.md b/README.md index 0ce0236..4829abb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ To use, clone this repo into your `deskflow` repo. These scripts are meant only to add developer conveniences during daily development. They are not used by CI and are not required at all for Deskflow development. +## Protocol harness + +`protocol_harness.py` is a fake peer that steps through the protocol versions, as a client +against a Deskflow server or as a server against a Deskflow client, and checks each message +against the wire format that version's real peers (Synergy 1.x, Barrier, Input Leap) use. +Run it with `--help` for the two modes. + ## Security PoCs Proof-of-concept scripts for Deskflow vulnerabilities are **not** in this repo. diff --git a/protocol_harness.py b/protocol_harness.py new file mode 100755 index 0000000..bae56ea --- /dev/null +++ b/protocol_harness.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 + +# Deskflow -- mouse and keyboard sharing utility +# SPDX-FileCopyrightText: (C) 2026 Synergy App Ltd +# SPDX-License-Identifier: GPL-2.0-only WITH LicenseRef-OpenSSL-Exception + +"""Fake protocol peer that steps backward through the protocol versions. + +As a client it connects to a Deskflow server, answers the hello with each +version in turn and checks every message the server sends against the wire +format that version's real clients (Synergy 1.x, Barrier, Input Leap) parse. +As a server it accepts a Deskflow client, advertises each version in turn and +checks that the client negotiates, parses and answers that version's messages, +and refuses the versions it does not implement. + +The formats are taken from the Synergy sources at each protocol version, not +from Deskflow, so a mistake that both halves of Deskflow agree on still fails. + +TLS is detected from whether the peer speaks first: a plaintext Deskflow peer +sends its hello straight away, a TLS one waits for the handshake. The GUI's own +certificate is used, so the fingerprint prompts are the ones you already know. + +Client mode, Deskflow server under test (its layout needs a screen named after +the peer): + + protocol_harness.py client --name harness --wait 20 + +Server mode, Deskflow client under test (pointed at this machine): + + protocol_harness.py server +""" + +from __future__ import annotations + +import argparse +import os +import select +import socket +import ssl +import struct +import sys +import time +from typing import Dict, List, Optional, Tuple + +# code, format, first minor version to use this form; later rows replace earlier ones +MESSAGES = ( + ("CNOP", "", 0), + ("CBYE", "", 0), + ("CINN", "%2i%2i%4i%2i", 0), + ("COUT", "", 0), + ("CCLP", "%1i%4i", 0), + ("CSEC", "%1i", 0), + ("CROP", "", 0), + ("CIAK", "", 0), + ("QINF", "", 0), + ("DINF", "%2i%2i%2i%2i%2i%2i%2i", 0), + ("DSOP", "%4I", 0), + ("DKDN", "%2i%2i", 0), + ("DKRP", "%2i%2i%2i", 0), + ("DKUP", "%2i%2i", 0), + ("DMDN", "%1i", 0), + ("DMUP", "%1i", 0), + ("DMMV", "%2i%2i", 0), + ("DMWM", "%2i", 0), + ("DCLP", "%1i%4i%s", 0), + ("EICV", "%2i%2i", 0), + ("EBSY", "", 0), + ("EUNK", "", 0), + ("EBAD", "", 0), + ("DKDN", "%2i%2i%2i", 1), + ("DKRP", "%2i%2i%2i%2i", 1), + ("DKUP", "%2i%2i%2i", 1), + ("DMRM", "%2i%2i", 2), + ("CALV", "", 3), + ("DMWM", "%2i%2i", 3), + ("DFTR", "%1i%s", 5), + ("DDRG", "%2i%s", 5), + ("DCLP", "%1i%4i%1i%s", 6), + ("SECN", "%s", 7), + ("DKDL", "%2i%2i%2i%s", 8), + ("DKRP", "%2i%2i%2i%2i%s", 8), + ("LSYN", "%s", 8), +) + +NEWEST_MINOR = 8 +OLDEST_MINOR_A_CLIENT_SPEAKS = 6 +KEY_SHIFT_L = 0xEFE1 +KEY_BUTTON = 0x32 +INT_SIZES = {"1": ">B", "2": ">H", "4": ">I"} + + +def next_directive(fmt: str, i: int) -> Tuple[str, str, int]: + """Return (size, kind, next index) for the directive at fmt[i], where %s has no size digit.""" + if fmt[i + 1].isdigit(): + return fmt[i + 1], fmt[i + 2], i + 3 + return "", fmt[i + 1], i + 2 + + +def data_waiting(sock: socket.socket, seconds: float = 1.0) -> bool: + """Whether the peer spoke first: a plaintext Deskflow server sends its hello at once and a TLS + Deskflow client sends its handshake at once, while their counterparts wait for us.""" + readable, _, _ = select.select([sock], [], [], seconds) + return bool(readable) + + +def formats_at(minor: int) -> Dict[str, str]: + return {code: fmt for code, fmt, since in MESSAGES if since <= minor} + + +def introduced_in(code: str) -> Optional[int]: + versions = [since for c, _, since in MESSAGES if c == code] + return min(versions) if versions else None + + +def encode(fmt: str, *args) -> bytes: + out = b"" + values = list(args) + i = 0 + while i < len(fmt): + if fmt[i] != "%": + out += fmt[i].encode() + i += 1 + continue + size, kind, i = next_directive(fmt, i) + value = values.pop(0) + if kind == "i": + out += struct.pack(INT_SIZES[size], value) + elif kind == "s": + out += struct.pack(">I", len(value)) + value + elif kind == "I": + out += struct.pack(">I", len(value)) + for item in value: + out += struct.pack(INT_SIZES[size], item) + assert not values, "too many arguments for format" + return out + + +def decode(fmt: str, data: bytes) -> Tuple[list, int]: + values = [] + pos = 0 + i = 0 + while i < len(fmt): + if fmt[i] != "%": + if data[pos : pos + 1] != fmt[i].encode(): + raise ValueError("literal mismatch") + pos += 1 + i += 1 + continue + size, kind, i = next_directive(fmt, i) + if kind == "i": + width = int(size) + values.append(struct.unpack(INT_SIZES[size], data[pos : pos + width])[0]) + pos += width + elif kind == "s": + length = struct.unpack(">I", data[pos : pos + 4])[0] + pos += 4 + if pos + length > len(data): + raise ValueError("string runs past the end of the message") + values.append(data[pos : pos + length]) + pos += length + elif kind == "I": + count = struct.unpack(">I", data[pos : pos + 4])[0] + pos += 4 + width = int(size) + items = [] + for _ in range(count): + items.append(struct.unpack(INT_SIZES[size], data[pos : pos + width])[0]) + pos += width + values.append(items) + return values, pos + + +class Report: + def __init__(self): + self.failures: Dict[int, List[str]] = {} + self.minor = 0 + + def start(self, minor: int): + self.minor = minor + self.failures.setdefault(minor, []) + + def info(self, message: str): + print(f"[1.{self.minor}] {message}") + + def ok(self, message: str): + print(f"[1.{self.minor}] ok {message}") + + def fail(self, message: str): + print(f"[1.{self.minor}] FAIL {message}") + self.failures[self.minor].append(message) + + def summary(self) -> int: + print() + for minor, failures in self.failures.items(): + verdict = "FAIL" if failures else "PASS" + print(f"1.{minor}: {verdict}" + (f" ({len(failures)} problems)" if failures else "")) + return 1 if any(self.failures.values()) else 0 + + +class Peer: + def __init__(self, sock: socket.socket, timeout: float): + self.sock = sock + self.sock.settimeout(timeout) + + def send_raw(self, payload: bytes): + self.sock.sendall(struct.pack(">I", len(payload)) + payload) + + def send(self, code: str, fmt: str = "", *args): + self.send_raw(code.encode() + encode(fmt, *args)) + + def recv_raw(self) -> Optional[bytes]: + header = self._read(4) + if header is None: + return None + length = struct.unpack(">I", header)[0] + return self._read(length) if length else b"" + + def recv(self) -> Optional[Tuple[str, bytes]]: + frame = self.recv_raw() + if frame is None: + return None + return frame[:4].decode(errors="replace"), frame[4:] + + def _read(self, n: int) -> Optional[bytes]: + data = b"" + while len(data) < n: + chunk = self.sock.recv(n - len(data)) + if not chunk: + return None + data += chunk + return data + + def close(self): + try: + self.sock.close() + except OSError: + pass + + +def check(report: Report, minor: int, code: str, payload: bytes) -> Optional[list]: + fmt = formats_at(minor).get(code) + if fmt is None: + since = introduced_in(code) + if since is None: + report.fail(f"{code}: unknown message, a real 1.{minor} client would disconnect") + else: + report.fail(f"{code}: introduced in 1.{since}, sent to a 1.{minor} client") + return None + try: + values, consumed = decode(fmt, payload) + except (ValueError, struct.error): + report.fail(f"{code}: shorter than the 1.{minor} format {fmt!r}: {payload.hex()}") + return None + if consumed != len(payload): + extra = payload[consumed:] + hint = " (the 1.8 language string)" if code in ("DKRP", "DKDN") and len(extra) >= 4 else "" + report.fail(f"{code}: {len(extra)} bytes left over after the 1.{minor} format{hint}: {extra.hex()}") + return None + return values + + +def run_client(args, report: Report, minor: int): + report.start(minor) + sock = socket.create_connection((args.host, args.port), timeout=args.timeout) + use_tls = args.tls == "on" or (args.tls == "auto" and not data_waiting(sock)) + if use_tls: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + if os.path.exists(args.cert): + context.load_cert_chain(args.cert) + try: + sock = context.wrap_socket(sock, server_hostname=args.host) + except (ssl.SSLError, OSError) as error: + report.fail(f"tls handshake failed, trust this certificate in the server gui and rerun: {error}") + sock.close() + return + report.info("tls " + ("on" if use_tls else "off")) + peer = Peer(sock, args.timeout) + try: + hello = peer.recv_raw() + if hello is None or len(hello) < 11: + report.fail("no hello from the server") + return + name = hello[:7] + (major, server_minor), _ = decode("%2i%2i", hello[7:]) + report.info(f"server hello: {name.decode(errors='replace')} {major}.{server_minor}, replying 1.{minor}") + peer.send_raw(name + encode("%2i%2i%s", 1, minor, args.name.encode())) + + handshake_done = False + deadline = time.monotonic() + args.timeout + while not handshake_done and time.monotonic() < deadline: + try: + message = peer.recv() + except socket.timeout: + continue + if message is None: + report.fail("server hung up during the handshake") + return + code, payload = message + values = check(report, minor, code, payload) + if code == "QINF": + peer.send("DINF", formats_at(minor)["DINF"], 0, 0, 1920, 1080, 0, 960, 540) + elif code == "CALV": + peer.send("CALV") + elif code == "EICV": + report.fail(f"server rejected 1.{minor} as incompatible") + return + elif code == "EUNK": + report.fail(f"server does not know the screen {args.name!r}, add it to the layout or pass --name") + return + elif code == "EBSY": + report.fail(f"a client named {args.name!r} is already connected to the server") + return + elif code == "EBAD": + report.fail("server reported a protocol error") + return + elif code == "DSOP": + handshake_done = True + report.ok(f"handshake complete, server accepted 1.{minor}") + elif values is not None: + report.info(f"handshake message {code}") + if not handshake_done: + report.fail("handshake did not complete in time") + return + + if args.wait <= 0: + return + report.info(f"move the pointer to the screen named {args.name!r}, hold a key, then move back") + deadline = time.monotonic() + args.wait + seen: Dict[str, int] = {} + while time.monotonic() < deadline: + try: + message = peer.recv() + except socket.timeout: + continue + if message is None: + report.fail("server hung up while sending input") + return + code, payload = message + values = check(report, minor, code, payload) + seen[code] = seen.get(code, 0) + 1 + if code == "CALV": + peer.send("CALV") + elif code == "QINF": + peer.send("DINF", formats_at(minor)["DINF"], 0, 0, 1920, 1080, 0, 960, 540) + peer.send("CNOP") + if values is not None and code in ("DKDN", "DKDL", "DKRP", "DKUP"): + report.ok(f"{code} {values}") + if code == "COUT": + break + report.info("received: " + ", ".join(f"{code} x{n}" for code, n in sorted(seen.items()))) + if "DKRP" not in seen: + report.fail("no key repeat received, hold the key longer next time") + finally: + peer.close() + + +def key_messages(minor: int) -> List[Tuple[str, tuple]]: + key = (KEY_SHIFT_L, 0) + if minor == 0: + return [("DKDN", key), ("DKRP", key + (1,)), ("DKRP", key + (1,)), ("DKUP", key)] + down = ("DKDL", key + (KEY_BUTTON, b"en")) if minor >= 8 else ("DKDN", key + (KEY_BUTTON,)) + repeat = key + (1, KEY_BUTTON) + ((b"en",) if minor >= 8 else ()) + return [down, ("DKRP", repeat), ("DKRP", repeat), ("DKUP", key + (KEY_BUTTON,))] + + +def run_server(args, report: Report, minor: int, listener: socket.socket): + report.start(minor) + report.info(f"waiting for the client to connect, advertising 1.{minor}") + listener.settimeout(args.reconnect) + try: + sock, _ = listener.accept() + except socket.timeout: + report.fail("client did not connect, is it running and pointed at this port with TLS off?") + return + sock.settimeout(args.timeout) + use_tls = args.tls == "on" or (args.tls == "auto" and data_waiting(sock)) + if use_tls: + if not os.path.exists(args.cert): + report.fail(f"client wants tls but there is no certificate at {args.cert}") + sock.close() + return + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(args.cert) + try: + sock = context.wrap_socket(sock, server_side=True) + except (ssl.SSLError, OSError) as error: + report.fail(f"tls handshake failed, trust this certificate in the client gui and rerun: {error}") + sock.close() + return + report.info("tls " + ("on" if use_tls else "off")) + peer = Peer(sock, args.timeout) + fmt = formats_at(minor) + try: + peer.send_raw(args.protocol_name.encode() + encode("%2i%2i", 1, minor)) + hello_back = peer.recv_raw() + if hello_back is None: + if minor < OLDEST_MINOR_A_CLIENT_SPEAKS: + report.ok(f"client refused 1.{minor}, which it does not implement") + else: + report.fail(f"client hung up instead of replying to a 1.{minor} hello") + return + try: + (major, client_minor, client_name), _ = decode("%2i%2i%s", hello_back[7:]) + except (ValueError, struct.error, IndexError): + report.fail(f"malformed hello back: {hello_back.hex()}") + return + report.info(f"client {client_name.decode(errors='replace')!r} replied {major}.{client_minor}") + if minor < OLDEST_MINOR_A_CLIENT_SPEAKS: + report.fail(f"client accepted 1.{minor}, which it does not implement") + return + if client_minor != minor: + report.fail(f"client replied 1.{client_minor} to a 1.{minor} server") + return + + peer.send("QINF") + if minor >= 8: + peer.send("LSYN", fmt["LSYN"], b"en") + message = peer.recv() + if message is None or message[0] != "DINF": + report.fail("client did not answer QINF with DINF") + return + report.ok(f"DINF {decode(fmt['DINF'], message[1])[0]}") + peer.send("CIAK") + peer.send("DSOP", fmt["DSOP"], []) + + sequence: List[Tuple[str, tuple]] = [("CINN", (1, 1, 1, 0))] + sequence += key_messages(minor) + sequence.append(("DMWM", (0, 0) if minor >= 3 else (0,))) + if minor >= 2: + sequence.append(("DMRM", (0, 0))) + if minor >= 3: + sequence.append(("CALV", ())) + sequence.append(("COUT", ())) + + for code, values in sequence: + peer.send(code, fmt[code], *values) + replied = False + echoed = code != "CALV" + deadline = time.monotonic() + args.timeout + while not (replied and echoed) and time.monotonic() < deadline: + try: + message = peer.recv() + except socket.timeout: + break + if message is None: + report.fail(f"client hung up after {code}") + return + reply, payload = message + if reply == "CNOP": + replied = True + elif reply == "CALV": + echoed = True + elif reply == "CBYE": + report.fail(f"client closed the connection after {code}") + return + elif reply == "EBAD": + report.fail(f"client reported a protocol error after {code}") + return + if not replied: + report.fail(f"no reply to {code}, the client stopped parsing after it") + return + if not echoed: + report.fail("keep alive was not echoed") + return + report.ok(f"{code} parsed and answered") + try: + peer.send("CBYE") + except OSError: + pass + finally: + peer.close() + + +def parse_versions(text: str) -> List[int]: + return [int(item.strip().split(".")[-1]) for item in text.split(",") if item.strip()] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("mode", choices=("client", "server")) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=24800) + parser.add_argument("--name", default="harness", help="client mode: screen name to report") + parser.add_argument("--protocol-name", default="Barrier", choices=("Barrier", "Synergy"), help="server mode: hello name") + parser.add_argument("--versions", help="comma separated minor versions, newest first; default is every version") + parser.add_argument("--wait", type=float, default=0, help="client mode: seconds to wait for key input per version") + parser.add_argument("--timeout", type=float, default=10) + parser.add_argument("--reconnect", type=float, default=30, help="server mode: seconds to wait for the client to connect") + parser.add_argument("--tls", choices=("auto", "on", "off"), default="auto", help="auto detects it from the peer") + parser.add_argument( + "--cert", + default=os.path.expanduser("~/.config/Deskflow/tls/deskflow.pem"), + help="pem with key and certificate, presented to the server or served to the client", + ) + args = parser.parse_args() + + if args.versions: + versions = parse_versions(args.versions) + elif args.mode == "client": + versions = list(range(NEWEST_MINOR, -1, -1)) + else: + versions = list(range(NEWEST_MINOR, OLDEST_MINOR_A_CLIENT_SPEAKS - 2, -1)) + + report = Report() + if args.mode == "client": + for minor in versions: + run_client(args, report, minor) + else: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((args.host, args.port)) + listener.listen(1) + for minor in versions: + run_server(args, report, minor, listener) + return report.summary() + + +if __name__ == "__main__": + sys.exit(main()) From 3d1d8b21fd25ccae42e3d5c98375b73c490e0421 Mon Sep 17 00:00:00 2001 From: Nick Bolton Date: Fri, 18 Sep 2026 18:14:56 +0100 Subject: [PATCH 2/2] chore: rename protocol-debugger.py to protocol_debugger.py --- protocol-debugger.py => protocol_debugger.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename protocol-debugger.py => protocol_debugger.py (100%) diff --git a/protocol-debugger.py b/protocol_debugger.py similarity index 100% rename from protocol-debugger.py rename to protocol_debugger.py