Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions clipsync/_syncthing_hashes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""SHA-256 hashes of the pinned Syncthing release archives.

Generated by ``tools/refresh_syncthing_hashes.py``, which fetches
Syncthing's ``sha256sum.txt.asc`` and refuses to emit anything unless the
PGP signature verifies against the pinned release key in
``_release_key.py``. Do not hand-edit: regenerate.

Why these live in the source tree rather than being fetched at runtime:
verifying the signature at runtime needs gpg on PATH, and most end users
do not have it (stock Windows and macOS ship without it), so that check
silently degraded to hash-only. Pinning moves the trust anchor into code
that goes through review and git history, and removes the network from
the verification path entirely for the pinned version.
"""

from __future__ import annotations

SYNCTHING_PINNED_SHA256: dict[str, dict[str, str]] = {
"v2.0.16": {
"syncthing-linux-386-v2.0.16.tar.gz": "407076caead4eec3c1ecacb5e3466958784220b5d646c83f5e178ee85d62b4a7",
"syncthing-linux-amd64-v2.0.16.tar.gz": "d5ca379993844b0e6e4fced05e3ac4a6c4513dee916ab65516c6d07d5e53e317",
"syncthing-linux-arm-v2.0.16.tar.gz": "93a52ffd06b1627810a229c1e26d242cbd267b3d1902ed918819a897f0724237",
"syncthing-linux-arm64-v2.0.16.tar.gz": "0254442af9be1886a7b55f63ad3c24c32fc2e294a3a2efd75a3b8b00335e5bd7",
"syncthing-linux-loong64-v2.0.16.tar.gz": "0d4682079380b3abce25ba588765e40660e39fead6bb0601835d9df389078599",
"syncthing-linux-mips-v2.0.16.tar.gz": "adc3cadf33df284d4d5c91d8edfd4f8aaf3876d0d435c0eea540383a7a29d78a",
"syncthing-linux-mips64-v2.0.16.tar.gz": "6c89cb7ec39a247e0c63e737ec8dd7d3d8f4f81bb80730e5053d0e7d3a0e87f9",
"syncthing-linux-mips64le-v2.0.16.tar.gz": "f5be69b95d5927213d52aee81f069ff16bb965918531e4630cb4ced8cc2b6623",
"syncthing-linux-mipsle-v2.0.16.tar.gz": "855b481ce19d0c25c044511284b3357630e9929c550c8d88e50839f8dc4f4753",
"syncthing-linux-ppc64le-v2.0.16.tar.gz": "0762d74b2a4d1ea167956c393b3ea7e639699eee78446b085a63e412d7aabbc0",
"syncthing-linux-riscv64-v2.0.16.tar.gz": "6d53cb3870e78a1c27dc68e4fe401de072bbbe64b72b3ae39d761511c7bf252a",
"syncthing-linux-s390x-v2.0.16.tar.gz": "cb9a5efb1b07c22f14264cf47f785702f1aad7fb85499b633fc05987d8059bf7",
"syncthing-macos-amd64-v2.0.16.zip": "2b5fe419de35c26354843ae567b2ae5c1bf82b151e3aea3dcfb620ca590999d4",
"syncthing-macos-arm64-v2.0.16.zip": "a90318e32ffab04561c7ececc3be79be3db478dabff3e3e3ee1183e38a4bfa3b",
"syncthing-macos-universal-v2.0.16.zip": "a767bf73035af2cb7755abc23666778a7edbf6f42ef3f319f5804ff87159f8fa",
"syncthing-windows-386-v2.0.16.zip": "c8e17cb290290f78823e0900b46a3b6020a1afbf8760533534f3a6fc8a6e2538",
"syncthing-windows-amd64-v2.0.16.zip": "5b519408c11e69e712702911caa399077e3fc602d8d70d6147f620e67bd83037",
"syncthing-windows-arm64-v2.0.16.zip": "0d0ea5ced8d900ec861628ff8fa4d7ec529c8a662f5183d7c49d5333d0cae71e",
},
}
53 changes: 45 additions & 8 deletions clipsync/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@

log = logging.getLogger(__name__)

# The OUT loop reads the system clipboard while the IN loop writes it, from two
# different threads. The native clipboard is a single shared object on every
# platform and is not thread-safe: on macOS, pyperclip's PyObjC backend (chosen
# over pbcopy/pbpaste whenever AppKit is importable, as it is in the bundled
# app) drives NSPasteboard directly, and a read racing a write segfaults inside
# -[_NSPasteboardOwnersCollection handleOwnershipChange]. Serialize every native
# clipboard touch through this lock.
_CLIPBOARD_LOCK = threading.RLock()

_HOSTNAME = _safe_hostname()


Expand Down Expand Up @@ -339,6 +348,19 @@ def reply(p: int) -> None:
pass


def _truncate_for_log(value: object, limit: int = 40) -> str:
"""repr *value* for a log line, truncating long payloads.

bytes needs this as much as str does: _last_synced holds the whole
clipboard, so once an image is synced it is megabytes of PNG. The
previous guard only tested str, so every heartbeat repr()'d the full
image into the log and churned the rotating handler.
"""
if isinstance(value, str | bytes) and len(value) > limit:
return repr(value[:limit]) + "..."
return repr(value)


def _normalize_newlines(s: str) -> str:
"""Collapse CRLF/CR to LF so Windows's clipboard normalization does not
look like a real change to the OUT loop after a remote update."""
Expand Down Expand Up @@ -715,7 +737,8 @@ def _is_paused(self) -> bool:

def _read_clipboard(self) -> str | None:
try:
value = pyperclip.paste()
with _CLIPBOARD_LOCK:
value = pyperclip.paste()
except Exception as exc:
msg = f"{type(exc).__name__}: {exc}"
if msg != self._last_read_error:
Expand Down Expand Up @@ -745,7 +768,8 @@ def _write_clipboard(self, value: str) -> bool:
self._last_write_error = msg
# fall through to pyperclip
try:
pyperclip.copy(value)
with _CLIPBOARD_LOCK:
pyperclip.copy(value)
log.debug("clipboard write (pyperclip) (%d chars)", len(value))
except Exception as exc:
msg = f"{type(exc).__name__}: {exc}"
Expand All @@ -760,14 +784,16 @@ def _write_clipboard(self, value: str) -> bool:

def _read_clipboard_image(self) -> bytes | None:
try:
return _read_image_from_system_clipboard()
with _CLIPBOARD_LOCK:
return _read_image_from_system_clipboard()
except Exception as exc:
log.debug("Image clipboard read failed: %s", exc)
return None

def _write_clipboard_image(self, png_bytes: bytes) -> bool:
try:
return _write_image_to_system_clipboard(png_bytes)
with _CLIPBOARD_LOCK:
return _write_image_to_system_clipboard(png_bytes)
except Exception as exc:
log.debug("Image clipboard write failed: %s", exc)
return False
Expand Down Expand Up @@ -831,7 +857,7 @@ def _out_loop(self) -> None:
log.debug(
"HEARTBEAT (host=%s): last_synced=%s, paused=%s",
_HOSTNAME,
(repr(last[:40]) + "...") if isinstance(last, str) and len(last) > 40 else repr(last),
_truncate_for_log(last),
self._is_paused(),
)

Expand All @@ -855,6 +881,11 @@ def _out_tick(self) -> None:
log.warning("OUT [%s]: %s", _HOSTNAME, reason)
self._last_decrypt_error = reason
except OSError:
# Roll back too: _last_synced is the "already sent" guard, so
# leaving it set after a failed write means every later tick
# sees this image as synced and it is never retried.
with self._lock:
self._last_synced = previous_last_synced
log.exception("OUT [%s]: Failed to write image file", _HOSTNAME)
return

Expand All @@ -878,6 +909,8 @@ def _out_tick(self) -> None:
log.warning("OUT [%s]: %s", _HOSTNAME, reason)
self._last_decrypt_error = reason
except OSError:
with self._lock:
self._last_synced = previous_last_synced
log.exception("OUT [%s]: Failed to write clipboard file", _HOSTNAME)

def _in_loop(self) -> None:
Expand Down Expand Up @@ -967,7 +1000,10 @@ class _ClipboardFileHandler(FileSystemEventHandler):
def __init__(self, sync: ClipboardSync) -> None:
super().__init__()
self._sync = sync
self._debounce_until = 0.0
# Per-path deadlines. A single shared deadline let a clipboard.txt and
# a clipboard.png update arriving within the debounce window suppress
# each other, so only one of the two was ever applied.
self._debounce_until: dict[str, float] = {}
# Fast name-based pre-filter to avoid Path.resolve() on every event.
# Syncthing generates many temp-file events; most are irrelevant.
self._target_names = {config.CLIPBOARD_FILENAME, config.CLIPBOARD_IMAGE_FILENAME}
Expand All @@ -988,9 +1024,10 @@ def _dispatch(self, path: str) -> None:
if not self._matches(path):
return
now = time.monotonic()
if now < self._debounce_until:
key = str(Path(path).name)
if now < self._debounce_until.get(key, 0.0):
return
self._debounce_until = now + 0.1
self._debounce_until[key] = now + 0.1
# Non-blocking: hand off to _in_loop so the watchdog thread pool
# is never held by clipboard I/O (avoids pool exhaustion on Windows).
self._sync._in_queue.put(path)
Expand Down
43 changes: 41 additions & 2 deletions clipsync/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,40 @@
APP_NAME = "ClipSync"
APP_ID = "clipsync"

ACCENT_COLOR = "#1A6B8A"
ACCENT_HOVER = "#145670"
# Legacy accent aliases kept for backwards compatibility.
ACCENT_COLOR = "#5A6BFF"
ACCENT_HOVER = "#4654CC"

# ---------------------------------------------------------------------------
# Pro theme palette (slate + indigo). These values are used directly by the
# UI module so changes stay centralized here instead of scattered through UI
# constructors.
# ---------------------------------------------------------------------------

COLOR_PRIMARY = "#5A6BFF" # indigo action accent
COLOR_PRIMARY_HOVER = "#4654CC"
COLOR_PRIMARY_MUTED = (228, 231, 255) # light-mode card tint, RGB tuple

COLOR_SUCCESS = "#2DD36F"
COLOR_DANGER = "#FF4D4D"
COLOR_DANGER_HOVER = "#CC3D3D"
COLOR_WARNING = "#FFB020"

# Light mode
COLOR_BG_LIGHT = "#F5F6F8"
COLOR_CARD_LIGHT = "#FFFFFF"
COLOR_TEXT_LIGHT = "#11131A"
COLOR_TEXT_MUTED_LIGHT = "#6B7280"
COLOR_BORDER_LIGHT = "#E2E4E9"
COLOR_ROW_BG_LIGHT = "#F0F1F5"

# Dark mode
COLOR_BG_DARK = "#0F1117"
COLOR_CARD_DARK = "#181A21"
COLOR_TEXT_DARK = "#F0F1F5"
COLOR_TEXT_MUTED_DARK = "#8B92A5"
COLOR_BORDER_DARK = "#2A2D38"
COLOR_ROW_BG_DARK = "#1E212B"

SYNCTHING_VERSION = "v2.0.16"
SYNCTHING_API_HOST = "127.0.0.1"
Expand Down Expand Up @@ -85,6 +117,13 @@ def assets_dir() -> Path:
"history_max_items": 50,
"history_auto_clear_minutes": 0,
"theme": "System",
# Mirror this device's log into the shared folder so peers can see it.
# Off by default: it is a debugging aid, and the sync folder is replicated
# to every paired device, so leaving it on ships your log (hostnames,
# device IDs, file names, error traces) to all of them forever. No
# clipboard text is ever logged, but none of that is obvious from the
# tray, and it was previously always on with no way to turn it off.
"debug_log_mirror": False,
}

HISTORY_FILE = APP_DATA_DIR / "clipsync_history.json"
Expand Down
107 changes: 107 additions & 0 deletions clipsync/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import base64
import logging
import os
from pathlib import Path
from typing import Final

from cryptography.fernet import Fernet, InvalidToken
Expand Down Expand Up @@ -105,6 +106,112 @@ def decrypt(data: bytes, passphrase: str) -> bytes | None:
return None


# ---------------------------------------------------------------------------
# Streaming file encryption
# ---------------------------------------------------------------------------
#
# Fernet holds the whole payload in memory, so the clipboard helpers above are
# unusable for file transfer: a multi-GB send would exhaust RAM. Files are
# therefore written as a sequence of independently-authenticated chunks.
#
# Layout:
# CSENCF\x01 magic + version (distinct from the CSENC clipboard
# payloads so the two can never be confused)
# salt 16 random bytes, PBKDF2 as v2 (600k iterations)
# repeated:
# 4-byte big-endian token length
# Fernet token over (8-byte big-endian chunk index || chunk bytes)
#
# The index is inside the authenticated plaintext, so chunks cannot be
# reordered or dropped without detection. The stream ends with an
# empty-payload chunk acting as an EOF marker, so truncation is caught too:
# without it, a cut-short file would otherwise decrypt cleanly to a prefix.
_FILE_MAGIC: Final = b"CSENCF\x01"
_FILE_CHUNK_SIZE: Final = 1024 * 1024


class StreamDecryptError(Exception):
"""Raised when an encrypted file cannot be decrypted or fails integrity."""


def encrypt_file(src: Path, dst: Path, passphrase: str) -> None:
"""Encrypt *src* into *dst* in bounded memory."""
salt = os.urandom(_SALT_LEN)
fernet = Fernet(_derive_key(passphrase, salt, _PBKDF2_ITERATIONS_V2))
with open(src, "rb") as fin, open(dst, "wb") as fout:
fout.write(_FILE_MAGIC)
fout.write(salt)
index = 0
while True:
chunk = fin.read(_FILE_CHUNK_SIZE)
if not chunk:
break
token = fernet.encrypt(index.to_bytes(8, "big") + chunk)
fout.write(len(token).to_bytes(4, "big"))
fout.write(token)
index += 1
# EOF marker: an authenticated empty chunk at the next index.
token = fernet.encrypt(index.to_bytes(8, "big"))
fout.write(len(token).to_bytes(4, "big"))
fout.write(token)


def decrypt_file(src: Path, dst: Path, passphrase: str) -> None:
"""Decrypt *src* into *dst*. Raises StreamDecryptError on any failure.

*dst* is removed on failure so a partial plaintext is never left behind
for the user to mistake for a complete file.
"""
try:
with open(src, "rb") as fin, open(dst, "wb") as fout:
if fin.read(len(_FILE_MAGIC)) != _FILE_MAGIC:
raise StreamDecryptError("not a clipsync encrypted file")
salt = fin.read(_SALT_LEN)
if len(salt) != _SALT_LEN:
raise StreamDecryptError("truncated header")
fernet = Fernet(_derive_key(passphrase, salt, _PBKDF2_ITERATIONS_V2))
expected = 0
saw_eof = False
while True:
raw_len = fin.read(4)
if not raw_len:
break
if len(raw_len) != 4:
raise StreamDecryptError("truncated chunk length")
token = fin.read(int.from_bytes(raw_len, "big"))
try:
plain = fernet.decrypt(token)
except (InvalidToken, ValueError) as exc:
raise StreamDecryptError("wrong passphrase or corrupt data") from exc
if len(plain) < 8:
raise StreamDecryptError("corrupt chunk")
if int.from_bytes(plain[:8], "big") != expected:
raise StreamDecryptError("chunks reordered or dropped")
body = plain[8:]
if not body:
saw_eof = True
break
fout.write(body)
expected += 1
if not saw_eof:
raise StreamDecryptError("file is truncated")
except StreamDecryptError:
dst.unlink(missing_ok=True)
raise
except OSError:
dst.unlink(missing_ok=True)
raise


def is_encrypted_file(path: Path) -> bool:
"""True if *path* begins with the streaming-file magic."""
try:
with open(path, "rb") as fh:
return fh.read(len(_FILE_MAGIC)) == _FILE_MAGIC
except OSError:
return False


def is_encrypted(data: bytes) -> bool:
"""Return True if *data* looks like a CSENC payload of ANY version.

Expand Down
Loading
Loading